Class: Shugoi::Core
- Inherits:
-
Object
- Object
- Shugoi::Core
- Defined in:
- lib/shugoi/core.rb
Overview
Évaluation d'une requête : pre-flight PoW challenge, /__sg_challenge, blocage headless, cookie __sg_ok, preuve single-use, rate-limit du challenge, protection des assets. Parité avec core.ts (module Node).
Defined Under Namespace
Classes: Decision
Constant Summary collapse
- POW_OK_TTL_MS =
30 * 24 * 3600 * 1000
- POW_TTL_MS =
60_000- CHALLENGE_LIMIT =
60- CHALLENGE_WINDOW_MS =
60_000- CHALLENGE_MAX_BLOCK_MS =
15 * 60 * 1000
- VALIDATION_WARN_INTERVAL =
3_600_000
Instance Method Summary collapse
-
#challenge_page(ctx) ⇒ Object
Page challenge (tableau en commentaire + JS PoW inline).
-
#evaluate(ctx) ⇒ Decision?
Nil = laisser passer.
-
#initialize(config, pow, api_client, config_cache = nil) ⇒ Core
constructor
A new instance of Core.
-
#is_trusted_bot?(ua, ip) ⇒ Boolean
Vrai si un bot whitelisté (UA) est authentifié par DNS inverse (parité isTrustedBot).
Constructor Details
#initialize(config, pow, api_client, config_cache = nil) ⇒ Core
Returns a new instance of Core.
18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/shugoi/core.rb', line 18 def initialize(config, pow, api_client, config_cache = nil) @config = config @pow = pow @api_client = api_client @config_cache = config_cache @validation_valid = false @validation_failed = false @validation_warned_at = 0 @used_proofs = {} @challenge_limits = {} @mutex = Mutex.new @bot_verifier = config.verify_bots ? BotVerifier.new : nil end |
Instance Method Details
#challenge_page(ctx) ⇒ Object
Page challenge (tableau en commentaire + JS PoW inline).
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/shugoi/core.rb', line 122 def challenge_page(ctx) loc = Locales.resolve_locale(@config.locale, ctx[:accept_language]) if !allow_challenge?(ctx[:ip].to_s) msgs = Locales.(loc) return Decision.new(status: 429, content_type: "text/html", body: shield_page(msgs[:rate_limit_title], msgs[:rate_limit_body].call("1 min"), msgs[:rate_limit_badge], ctx[:host].to_s, 60, loc), headers: {}) end js = <<~JS (function(){ var P=new URLSearchParams(location.search); var salt=P.get('salt')||'', ts=P.get('ts')||'', diff=parseInt(P.get('diff')||'#{@config.pow_difficulty}',10), path=P.get('path')||'/'; // Open redirect (audit #5) : un //evil.com (protocole-relatif) ou un backslash // détourneraient le location.replace ci-dessous vers un domaine externe. if(path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf('\\\\')>=0)path='/'; var enc=new TextEncoder(); // Audit 2026-08-03 : comptage de bits CORRIGÉ (zéros internes du premier nibble // non-nul comptés) — DOIT rester synchrone avec Pow#valid? + guard + whitelist. function bits(d){var l=0;for(var i=0;i<d.length;i++){var b=parseInt(d[i],16);if(b===0){l+=4;continue}l+=(b&8)?0:(b&4)?1:(b&2)?2:3;break}return l} var n=0; function step(){ crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){ var h=Array.from(new Uint8Array(buf)).map(function(v){return v.toString(16).padStart(2,'0')}).join(''); if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+n.toString(16);location.replace(base+q)} else{n++;if(n<300000)step()} }).catch(function(){location.reload()}); } step(); })(); JS html = "<!--\n#{BLOCK_PAGE}-->\n<script>#{js}</script>" Decision.new(status: 200, content_type: "text/html", body: html, headers: {}) end |
#evaluate(ctx) ⇒ Decision?
Returns nil = laisser passer.
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/shugoi/core.rb', line 34 def evaluate(ctx) path = ctx[:path].to_s # Protection des assets à contenu (/assets/*.js, *.css) — parité core.ts. Le bundle # SPA est téléchargeable publiquement sans ce verrou : on exige le cookie # __sg_authorized posé par handleRender après un render réussi (grant valide). # NB : vérifié AVANT l'allowlist (les assets sont allowlistés pour le split-render). if path.match?(%r{/assets/[^?#]+\.(js|css)(\?|$)}) auth_ok = !ctx[:sg_authorized].to_s.empty? && @pow.(ctx[:sg_authorized]) return Decision.new(status: 403, content_type: "text/plain", body: BLOCK_PAGE, headers: {}) unless auth_ok end ensure_validated warn_if_validation_failed return nil if @config.is_allowlisted?(path) # Route du challenge JS (le navigateur arrive ici après le 307). return challenge_page(ctx) if path == "/__sg_challenge" # Pre-flight PoW challenge (anti-curl/view-source). return nil if path.include?("/__shugoi/") || path.start_with?("/api/") ua = ctx[:ua].to_s if ua.match?(/Mozilla/i) && !@config.signing_secret.to_s.empty? proof = ctx[:sg_proof].to_s valid_proof = !proof.empty? && @pow.valid?(proof) # Re-audit (résidu #3) : un cookie __sg_ok valide (HMAC serveur, 30 j) saute le # pre-flight PoW. Posé APRÈS une première résolution réussie (middleware). = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok]) # Round 16 (R2) : la preuve est SINGLE-USE (par IP) — un rejeu → 307. proof_fresh = valid_proof ? consume_proof(proof, ctx[:ip].to_s) : false unless || proof_fresh return pow_challenge_307(ctx) if allow_challenge?(ctx[:ip].to_s) loc = Locales.resolve_locale(@config.locale, ctx[:accept_language]) msgs = Locales.(loc) return Decision.new(status: 429, content_type: "text/html", body: shield_page(msgs[:rate_limit_title], msgs[:rate_limit_body].call("1 min"), msgs[:rate_limit_badge], ctx[:host].to_s, 60, loc), headers: {}) end end # Flags de détection (whitelist + skipPaths), parité fetchConfigForSiteKey. flags = @config_cache ? @config_cache.fetch(@config.site_key)[:flags] : {} headless_enabled = flags["enableHeadlessCheck"] != false # Rate limit check — activé uniquement si le flag est explicitement vrai. if flags["enableRateLimit"] == true rl = @api_client.check_rate_limit(@config.site_key, ctx[:ip].to_s, ctx[:ua].to_s) if rl && rl["allowed"] == false reset_at = rl["resetAt"].to_i reset_at = (reset_at / 1000.0).ceil if reset_at > 1_000_000_000_000 remain = [0, reset_at - Utils.now_sec].max mins = remain / 60 secs = remain % 60 time_str = if mins.positive? "#{mins} min#{mins > 1 ? 's' : ''}#{secs.positive? ? " #{secs} s" : ''}" else "#{secs} seconde#{secs > 1 ? 's' : ''}" end loc = @config.locale || Locales.resolve_locale(nil, ctx[:accept_language]) msgs = Locales.(loc) body = if @config.block_page.respond_to?(:call) @config.block_page.call(reason: "rate_limit", title: msgs[:rate_limit_title], message: msgs[:rate_limit_body].call(time_str), badge: msgs[:rate_limit_badge], host: ctx[:host].to_s, remainingSeconds: remain, locale: loc) else shield_page(msgs[:rate_limit_title], msgs[:rate_limit_body].call(time_str), msgs[:rate_limit_badge], ctx[:host].to_s, remain, loc) end return Decision.new(status: 429, content_type: "text/html", body: body, headers: {}) end end # Blocage headless (UA). Actif par défaut, y compris sans configuration chargée. if headless_enabled && !ua.empty? && !is_trusted_bot?(ua, ctx[:ip].to_s) && @config.is_headless?(ua) @api_client.post_event(@config.site_key, "headless", "") return Decision.new(status: @config.block_status, content_type: "text/plain", body: BLOCK_PAGE, headers: {}) end nil end |
#is_trusted_bot?(ua, ip) ⇒ Boolean
Vrai si un bot whitelisté (UA) est authentifié par DNS inverse (parité isTrustedBot).
113 114 115 116 117 118 119 |
# File 'lib/shugoi/core.rb', line 113 def is_trusted_bot?(ua, ip) return false unless @config.is_whitelisted_bot?(ua) return true unless @bot_verifier verified = @bot_verifier.verify(ua, ip) verified.nil? ? true : verified end |