Class: Shugoi::Core

Inherits:
Object
  • Object
show all
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

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.messages(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')||'', nonce=P.get('nonce')||'', 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+':'+nonce+':'+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.

Parameters:

  • ctx (Hash)

    { path:, ua:, ip:, host:, accept_language:, sec_fetch_dest:, sec_fetch_mode:, sg_proof:, sg_ok:, sg_authorized:, forwarded_prefix: }

Returns:

  • (Decision, nil)

    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.sg_authorized_valid?(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 !@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).
    valid_cookie = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok], ctx[:ip].to_s, ua)
    # Round 16 (R2) : la preuve est SINGLE-USE (par IP) — un rejeu → 307.
    proof_fresh = valid_proof ? consume_proof(proof) : false
    unless valid_cookie || 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.messages(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.messages(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).

Returns:

  • (Boolean)


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? ? false : verified
end