shugoi 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb399ab3d75413beea51021d09dba771d642cdeae0b6ebd08dee8cb9f43ebec9
4
- data.tar.gz: 39a2adb0773270d1bf1712a2626b8875c95758dee55eaeb7d179353a32518d50
3
+ metadata.gz: 57dac16265f7ee95d5a4b898026d14fb318c695681eef25607ba427fbc260b59
4
+ data.tar.gz: ec35d03b156df7fa25750d794e338978c47dc8288bded4aec4542a732eb70c58
5
5
  SHA512:
6
- metadata.gz: 6c9cd4918e288e14437d0c5c5bc5b7068fb63b84a9d4a56989525d45dbbcce8e10d1a9fd08d4d013d6ed0ff670d12acd679133e3b4b43a9b04147bcb3f53e616
7
- data.tar.gz: de83aeafd3d23b27cc4d60a5ff23325c81940c2fe8102a8d6c54e9f688c683876940bb0fbef001ddbc63b52c1c27a5af4ba3672df91bbdc6d97e767d5c6a8f7d
6
+ metadata.gz: 995c4835ad283fb5b7e614ab897c896d7dd29a0bb1ebbdb7a3bb7d3374a51921419e0f1160f59482b5e1e108f606a32a6d7b6f9adfded0a23b9531e6c4d3f1cd
7
+ data.tar.gz: 2af87cfffe903de0dffc18ba56887347e18af11c31d49fa1e3973b35d56e2c8d345b9d67b47592554798e7aa7afe72084d46abecefa70232ae43910b06300c75
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+
5
+ module Shugoi
6
+ # Vérifie par DNS inverse + forward que les bots whitelistés viennent bien de leurs
7
+ # plages officielles — parité avec verify-bot.ts (module Node). Résultat mis en cache.
8
+ class BotVerifier
9
+ BOT_DOMAINS = [
10
+ { pattern: /Googlebot|Google-InspectionTool|Storebot-Google/i, suffixes: [".googlebot.com", ".google.com"] },
11
+ { pattern: /Bingbot|adidxbot|BingPreview/i, suffixes: [".search.msn.com"] },
12
+ { pattern: /Slurp/i, suffixes: [".crawl.yahoo.net"] },
13
+ { pattern: /DuckDuckBot/i, suffixes: [".duckduckgo.com"] },
14
+ { pattern: /YandexBot/i, suffixes: [".yandex.ru", ".yandex.net", ".yandex.com"] },
15
+ { pattern: /Applebot/i, suffixes: [".applebot.apple.com"] }
16
+ ].freeze
17
+
18
+ VERIFY_TTL_MS = 3_600_000
19
+ MAX_ENTRIES = 5000
20
+
21
+ def initialize
22
+ @cache = {}
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # @param ua [String] User-Agent
27
+ # @param ip [String] IP du client
28
+ # @return [true, false, nil] true/false si UA reconnu, nil sinon
29
+ def verify(ua, ip)
30
+ entry = BOT_DOMAINS.find { |b| b[:pattern].match?(ua) }
31
+ return nil unless entry
32
+ return false if ip.to_s.empty? || ip == "unknown"
33
+
34
+ key = "#{ip}|#{entry[:suffixes][0]}"
35
+ @mutex.synchronize do
36
+ hit = @cache[key]
37
+ return hit[:ok] if hit && Utils.now_ms - hit[:at] < VERIFY_TTL_MS
38
+ end
39
+
40
+ ok = reverse_forward_match?(ip, entry[:suffixes])
41
+
42
+ @mutex.synchronize do
43
+ prune
44
+ @cache[key] = { ok: ok, at: Utils.now_ms }
45
+ end
46
+ ok
47
+ end
48
+
49
+ private
50
+
51
+ # PTR de l'IP → nom se terminant par un suffixe attendu, puis forward vérifié.
52
+ def reverse_forward_match?(ip, suffixes)
53
+ names = Resolv.getnames(ip)
54
+ name = names.find { |n| suffixes.any? { |s| n.downcase.end_with?(s) } }
55
+ return false unless name
56
+ addresses = Resolv.getaddresses(name)
57
+ addresses.include?(ip)
58
+ rescue Resolv::ResolvError, StandardError
59
+ false
60
+ end
61
+
62
+ def prune
63
+ if @cache.size >= MAX_ENTRIES
64
+ @cache.delete(@cache.keys.min_by { |k| @cache[k][:at] })
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/shugoi/config.rb CHANGED
@@ -23,8 +23,9 @@ module Shugoi
23
23
  split_render: true,
24
24
  multi_process: false,
25
25
  verify_bots: true,
26
- pow_difficulty: 10,
27
- pow_ttl_ms: 120_000
26
+ # Parité module Node : difficulté PoW 14 par défaut (configurable), TTL 60 s.
27
+ pow_difficulty: 14,
28
+ pow_ttl_ms: 60_000
28
29
  }.freeze
29
30
 
30
31
  attr_reader :options
data/lib/shugoi/core.rb CHANGED
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shugoi
4
- # Évaluation d'une requête : pre-flight PoW challenge, /__sg_challenge, blocage headless.
4
+ # Évaluation d'une requête : pre-flight PoW challenge, /__sg_challenge, blocage headless,
5
+ # cookie __sg_ok, preuve single-use, rate-limit du challenge, protection des assets.
5
6
  # Parité avec core.ts (module Node).
6
7
  class Core
7
8
  # Résultat de blocage renvoyé au middleware.
8
9
  Decision = Struct.new(:status, :content_type, :body, :headers, keyword_init: true)
9
10
 
11
+ POW_OK_TTL_MS = 30 * 24 * 3600 * 1000
12
+ POW_TTL_MS = 60_000
13
+ CHALLENGE_LIMIT = 60
14
+ CHALLENGE_WINDOW_MS = 60_000
15
+ CHALLENGE_MAX_BLOCK_MS = 15 * 60 * 1000
16
+ VALIDATION_WARN_INTERVAL = 3_600_000
17
+
10
18
  def initialize(config, pow, api_client)
11
19
  @config = config
12
20
  @pow = pow
@@ -14,18 +22,32 @@ module Shugoi
14
22
  @validation_valid = false
15
23
  @validation_failed = false
16
24
  @validation_warned_at = 0
25
+ @used_proofs = {}
26
+ @challenge_limits = {}
27
+ @mutex = Mutex.new
28
+ @bot_verifier = config.verify_bots ? BotVerifier.new : nil
17
29
  end
18
30
 
19
- # @param ctx [Hash] { path:, ua:, ip:, host:, accept_language:, sec_fetch_dest:, sec_fetch_mode:, sg_proof:, forwarded_prefix: }
31
+ # @param ctx [Hash] { path:, ua:, ip:, host:, accept_language:, sec_fetch_dest:, sec_fetch_mode:, sg_proof:, sg_ok:, sg_authorized:, forwarded_prefix: }
20
32
  # @return [Decision, nil] nil = laisser passer
21
33
  def evaluate(ctx)
22
34
  path = ctx[:path].to_s
35
+
36
+ # Protection des assets à contenu (/assets/*.js, *.css) — parité core.ts. Le bundle
37
+ # SPA est téléchargeable publiquement sans ce verrou : on exige le cookie
38
+ # __sg_authorized posé par handleRender après un render réussi (grant valide).
39
+ # NB : vérifié AVANT l'allowlist (les assets sont allowlistés pour le split-render).
40
+ if path.match?(%r{/assets/[^?#]+\.(js|css)(\?|$)})
41
+ auth_ok = !ctx[:sg_authorized].to_s.empty? && @pow.sg_authorized_valid?(ctx[:sg_authorized])
42
+ return Decision.new(status: 403, content_type: "text/plain", body: BLOCK_PAGE, headers: {}) unless auth_ok
43
+ end
44
+
45
+ ensure_validated
46
+ warn_if_validation_failed
23
47
  return nil if @config.is_allowlisted?(path)
24
48
 
25
49
  # Route du challenge JS (le navigateur arrive ici après le 307).
26
- if path == "/__sg_challenge"
27
- return challenge_page
28
- end
50
+ return challenge_page(ctx) if path == "/__sg_challenge"
29
51
 
30
52
  # Pre-flight PoW challenge (anti-curl/view-source).
31
53
  return nil if path.include?("/__shugoi/") || path.start_with?("/api/")
@@ -33,13 +55,23 @@ module Shugoi
33
55
 
34
56
  if ua.match?(/Mozilla/i) && !@config.signing_secret.to_s.empty?
35
57
  proof = ctx[:sg_proof].to_s
36
- unless @pow.valid?(proof)
37
- return pow_challenge_307(ctx)
58
+ valid_proof = !proof.empty? && @pow.valid?(proof)
59
+ # Re-audit (résidu #3) : un cookie __sg_ok valide (HMAC serveur, 30 j) saute le
60
+ # pre-flight PoW. Posé APRÈS une première résolution réussie (middleware).
61
+ valid_cookie = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok])
62
+ # Round 16 (R2) : la preuve est SINGLE-USE (par IP) — un rejeu → 307.
63
+ proof_fresh = valid_proof ? consume_proof(proof, ctx[:ip].to_s) : false
64
+ unless valid_cookie || proof_fresh
65
+ return pow_challenge_307(ctx) if allow_challenge?(ctx[:ip].to_s)
66
+
67
+ loc = Locales.resolve_locale(@config.locale, ctx[:accept_language])
68
+ msgs = Locales.messages(loc)
69
+ return Decision.new(status: 429, content_type: "text/html", body: shield_page(msgs[:rate_limit_title], msgs[:rate_limit_body], msgs[:rate_limit_badge], ctx[:host].to_s, 60, loc), headers: {})
38
70
  end
39
71
  end
40
72
 
41
- # Blocage headless (UA).
42
- if ua.match?(/Mozilla/i) == false && @config.is_headless?(ua)
73
+ # Blocage headless (UA). Actif par défaut, y compris sans configuration chargée.
74
+ if !ua.empty? && !is_trusted_bot?(ua, ctx[:ip].to_s) && @config.is_headless?(ua)
43
75
  @api_client.post_event(@config.site_key, "headless", "")
44
76
  return Decision.new(status: @config.block_status, content_type: "text/plain", body: BLOCK_PAGE, headers: {})
45
77
  end
@@ -47,14 +79,33 @@ module Shugoi
47
79
  nil
48
80
  end
49
81
 
82
+ # Vrai si un bot whitelisté (UA) est authentifié par DNS inverse (parité isTrustedBot).
83
+ def is_trusted_bot?(ua, ip)
84
+ return false unless @config.is_whitelisted_bot?(ua)
85
+ return true unless @bot_verifier
86
+
87
+ verified = @bot_verifier.verify(ua, ip)
88
+ verified.nil? ? true : verified
89
+ end
90
+
50
91
  # Page challenge (tableau en commentaire + JS PoW inline).
51
- def challenge_page
92
+ def challenge_page(ctx)
93
+ loc = Locales.resolve_locale(@config.locale, ctx[:accept_language])
94
+ if !allow_challenge?(ctx[:ip].to_s)
95
+ msgs = Locales.messages(loc)
96
+ return Decision.new(status: 429, content_type: "text/html", body: shield_page(msgs[:rate_limit_title], msgs[:rate_limit_body], msgs[:rate_limit_badge], ctx[:host].to_s, 60, loc), headers: {})
97
+ end
52
98
  js = <<~JS
53
99
  (function(){
54
100
  var P=new URLSearchParams(location.search);
55
- var salt=P.get('salt')||'', ts=P.get('ts')||'', diff=parseInt(P.get('diff')||'10',10), path=P.get('path')||'/';
101
+ var salt=P.get('salt')||'', ts=P.get('ts')||'', diff=parseInt(P.get('diff')||'#{@config.pow_difficulty}',10), path=P.get('path')||'/';
102
+ // Open redirect (audit #5) : un //evil.com (protocole-relatif) ou un backslash
103
+ // détourneraient le location.replace ci-dessous vers un domaine externe.
104
+ if(path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf('\\\\')>=0)path='/';
56
105
  var enc=new TextEncoder();
57
- 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}var s=b.toString(2),z=0;while(z<s.length&&s[z]==='0')z++;l+=z;break}return l}
106
+ // Audit 2026-08-03 : comptage de bits CORRIGÉ (zéros internes du premier nibble
107
+ // non-nul comptés) — DOIT rester synchrone avec Pow#valid? + guard + whitelist.
108
+ 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}
58
109
  var n=0;
59
110
  function step(){
60
111
  crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){
@@ -72,6 +123,24 @@ module Shugoi
72
123
 
73
124
  private
74
125
 
126
+ # Page de blocage néobrutaliste (parité shieldPage de core.ts).
127
+ def shield_page(title, msg, badge, host, remain_secs, locale)
128
+ msgs = Locales.messages(locale)
129
+ prefix = msg.to_s.sub(/Il reste \d+ seconde?s?.*$/, "").sub(/Retry in \d+s?.*$/, "").strip
130
+ countdown_script = if remain_secs.positive?
131
+ '<script>var s=' + remain_secs.to_s + ';var i=setInterval(function(){s--;var e=document.getElementById("cd");if(e){if(s<=0){e.innerHTML="0s";clearInterval(i);setTimeout(function(){location.reload()},500)}else{e.innerHTML=s+"s"}}},1000)</script>'
132
+ else
133
+ ""
134
+ end
135
+ desc = remain_secs.positive? ? "#{prefix} #{msgs[:retry_in_seconds].call(remain_secs)}" : msg.to_s
136
+ html_title = Utils.escape_html(title.to_s.empty? ? msgs[:blocked_title] : title)
137
+ html_badge = Utils.escape_html(badge.to_s.empty? ? msgs[:blocked_badge] : badge)
138
+ html_host = Utils.escape_html((host.to_s.empty? ? "shugoi.com" : host).slice(0, 120))
139
+ html_desc = Utils.escape_html(desc)
140
+ html_lang = locale == "fr" ? "fr" : "en"
141
+ '<!DOCTYPE html><html lang="' + html_lang + '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>@font-face{font-family:\'Alex Brush\';src:url(https://shugoi.com/alex-brush.woff2?v=2) format(\'woff2\');font-display:swap}*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;background:#fcf9f5}body{font-family:system-ui,-apple-system,\'Segoe UI\',Roboto,sans-serif;display:flex;align-items:center;justify-content:center;padding:1.2rem}#c{max-width:460px;width:100%;background:#fff;border:4px solid #000;border-radius:28px 6px 32px 10px;box-shadow:12px 12px 0 #000;padding:3rem 2.4rem 2.8rem;text-align:center}#c .l{width:80px;height:80px;pointer-events:none;transform:rotate(-2.5deg);margin:0 auto .6rem;display:block}#c .b{display:block;margin:0 auto .2rem;pointer-events:none;max-width:100%;height:auto}#c .bdg{display:inline-block;border:2px solid #000;border-radius:10px 2px 14px 4px;padding:.3rem .9rem;font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#E87090;margin-bottom:1.4rem}#c h2{font-family:\'Alex Brush\',Georgia,"Times New Roman",serif;font-size:2.2rem;color:#E87090;font-weight:400;margin:0 auto .6rem}#c p.desc{font-size:.9rem;color:#555;line-height:1.8;max-width:380px;margin:0 auto}#c p.ft{font-size:.55rem;color:#E87090;margin-top:1.8rem}</style></head><body><div id=c><img src=https://shugoi.com/favicon.png alt class=l><img src=https://shugoi.com/brand.png alt class=b><div class=bdg>' + html_badge + '</div><h2>' + html_title + '</h2><p class=desc>' + html_desc + '</p><p class=ft>' + html_host + ' \u00b7 Shugoi</p></div>' + countdown_script + '</body></html>'
142
+ end
143
+
75
144
  # 307 vers le challenge : body = tableau ASCII seul (curl le voit tel quel).
76
145
  def pow_challenge_307(ctx)
77
146
  ts = Utils.now_sec
@@ -79,10 +148,84 @@ module Shugoi
79
148
  prefix = ctx[:forwarded_prefix].to_s
80
149
  prefix = "" if prefix == "/"
81
150
  prefix = prefix.sub(%r{/\z}, "") unless prefix.empty?
82
- path = ctx[:path].to_s
83
- path = "/#{path}" unless path.start_with?("/")
151
+ path = safe_challenge_path(ctx[:path].to_s)
84
152
  chal_url = "#{prefix}/__sg_challenge?ts=#{ts}&salt=#{salt}&diff=#{@config.pow_difficulty}&path=#{URI.encode_www_form_component(prefix + path)}"
85
153
  Decision.new(status: 307, content_type: "text/plain", body: BLOCK_PAGE, headers: { "location" => chal_url })
86
154
  end
155
+
156
+ # Sanitisation open redirect (audit #5) : n'accepte qu'un chemin relatif commençant par
157
+ # UN SEUL '/', sans backslash ni caractères de contrôle.
158
+ def safe_challenge_path(p)
159
+ return "/" if p.to_s.empty?
160
+ return "/" unless p.start_with?("/")
161
+ return "/" if p.start_with?("//") || p.include?("\\")
162
+ return "/" if p.each_codepoint.any? { |c| c < 0x20 || c == 0x7f }
163
+
164
+ p
165
+ end
166
+
167
+ # Anti-scraping : borne par IP l'émission de challenges (quota + backoff exponentiel).
168
+ def allow_challenge?(ip)
169
+ return true if ip.to_s.empty? || ip == "unknown"
170
+
171
+ now = Utils.now_ms
172
+ @mutex.synchronize do
173
+ e = @challenge_limits[ip]
174
+ if e.nil? || now - e[:window_start] >= CHALLENGE_WINDOW_MS
175
+ @challenge_limits[ip] = { count: 1, window_start: now, blocked_until: 0 }
176
+ return true
177
+ end
178
+ e[:count] += 1
179
+ return false if e[:blocked_until] > now
180
+ if e[:count] > CHALLENGE_LIMIT
181
+ backoff = [60_000 * (2**[e[:count] - CHALLENGE_LIMIT, 10].min), CHALLENGE_MAX_BLOCK_MS].min
182
+ e[:blocked_until] = now + backoff
183
+ e[:count] = 0
184
+ return false
185
+ end
186
+ true
187
+ end
188
+ end
189
+
190
+ # Preuve PoW single-use (par IP) — entrées purgées après POW_TTL_MS.
191
+ def consume_proof(proof, ip)
192
+ key = "#{ip}:#{proof}"
193
+ @mutex.synchronize do
194
+ return false if @used_proofs.key?(key)
195
+ @used_proofs[key] = Utils.now_ms
196
+ now = Utils.now_ms
197
+ @used_proofs.delete_if { |_k, t| now - t > POW_TTL_MS }
198
+ true
199
+ end
200
+ end
201
+
202
+ # Validation de la clé (lazy) + avertissement périodique si elle échoue.
203
+ def ensure_validated
204
+ return unless @config.secret
205
+ return if @validation_valid || @validation_failed
206
+
207
+ result = @api_client.validate_key(@config.site_key, @config.secret)
208
+ if result["valid"] == true
209
+ @validation_valid = true
210
+ else
211
+ @validation_failed = true
212
+ end
213
+ rescue StandardError
214
+ @validation_failed = true
215
+ end
216
+
217
+ def warn_if_validation_failed
218
+ return unless @config.secret
219
+ return unless @validation_failed
220
+ return if Utils.now_ms - @validation_warned_at < VALIDATION_WARN_INTERVAL
221
+
222
+ @validation_warned_at = Utils.now_ms
223
+ warn(
224
+ "[shugoi] La validation de la clé a échoué pour le siteKey #{@config.site_key}.\n" \
225
+ "[shugoi] La protection reste active, mais cette installation n'est pas authentifiée.\n" \
226
+ "[shugoi] Vérifiez `site_key` et `secret` : https://shugoi.com/docs#validation"
227
+ )
228
+ @api_client.post_event(@config.site_key, "validation_failed", "")
229
+ end
87
230
  end
88
231
  end
data/lib/shugoi/csp.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Shugoi
6
+ # Construction + fusion de la Content-Security-Policy — parité avec csp.ts (module Node).
7
+ # Le module npm pose la CSP fusionnée sur chaque réponse (y compris les pages passées) :
8
+ # la base inclut l'origine de l'API (pour le guard client), les directives additionnelles
9
+ # et, en split-render, `unsafe-eval` (nécessaire au bootcode eval([...])).
10
+ class Csp
11
+ SHUGOI_ORIGIN = "https://shugoi.com".freeze
12
+
13
+ # Retourne l'origine (schéma://host[:port]) d'une base URL, nil sinon.
14
+ def self.origin_of(base_url)
15
+ return nil if base_url.to_s.empty?
16
+ u = URI.parse(base_url)
17
+ return nil unless %w[http https].include?(u.scheme)
18
+ origin = "#{u.scheme}://#{u.host}"
19
+ origin += ":#{u.port}" if u.port && u.port != u.default_port
20
+ origin
21
+ rescue URI::InvalidURIError
22
+ nil
23
+ end
24
+
25
+ # @param site_key [String] siteKey (parité d'API avec le module Node, inutilisé dans la construction)
26
+ # @param api_origin [String, nil] origine de l'API Shugoi à autoriser (connect-src/script-src…)
27
+ # @param extra_directives [Hash<String, Array<String>>] directives additionnelles fusionnées
28
+ # @param split_render [Boolean] false → retire 'unsafe-eval' de script-src
29
+ # @return [String] header CSP
30
+ def self.build(site_key:, api_origin: nil, extra_directives: {}, split_render: true)
31
+ api = [SHUGOI_ORIGIN, api_origin].compact.uniq
32
+ merged = {
33
+ "default-src" => ["'self'"],
34
+ "script-src" => ["'self'", "'unsafe-inline'", "'unsafe-eval'", *api],
35
+ "connect-src" => ["'self'", *api],
36
+ "style-src" => ["'self'", "'unsafe-inline'", *api],
37
+ "font-src" => ["'self'", *api, "data:"],
38
+ "img-src" => ["'self'", *api, "data:", "blob:"],
39
+ "frame-ancestors" => ["'self'"],
40
+ "object-src" => ["'none'"],
41
+ "base-uri" => ["'self'"],
42
+ "form-action" => ["'self'"]
43
+ }
44
+ extra_directives.each do |key, values|
45
+ merged[key] = ((merged[key] || []) + Array(values)).uniq
46
+ end
47
+ if split_render == false
48
+ merged["script-src"] = (merged["script-src"] || []).reject { |v| v == "'unsafe-eval'" }
49
+ end
50
+ merged.map { |k, v| "#{k} #{v.join(' ')}" }.join("; ")
51
+ end
52
+
53
+ # Fusionne une CSP existante (posée par l'app client) avec celle du module.
54
+ # Spec CSP : le mot-clé 'none' doit être SEUL dans une directive — sinon il est ignoré
55
+ # par le navigateur. Lors d'un merge, on garde uniquement 'none' (le plus restrictif).
56
+ def self.merge(existing, added)
57
+ return added if existing.to_s.empty?
58
+ base = parse(existing)
59
+ parse(added).each do |k, set|
60
+ base[k] = (base[k] || Set.new) | set
61
+ end
62
+ base.each do |k, set|
63
+ base[k] = Set.new(["'none'"]) if set.include?("'none'") && set.size > 1
64
+ end
65
+ base.map { |k, v| "#{k} #{v.to_a.join(' ')}" }.join("; ")
66
+ end
67
+
68
+ # Parse une CSP en Hash<String, Set<String>>.
69
+ def self.parse(csp)
70
+ out = {}
71
+ csp.to_s.split(";").each do |part|
72
+ name, *vals = part.strip.split(/\s+/)
73
+ next if name.nil? || name.empty?
74
+ out[name] = (out[name] || Set.new) | vals
75
+ end
76
+ out
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Messages localisés des pages de blocage — parité avec locales.ts (module Node).
5
+ module Locales
6
+ FR = {
7
+ rate_limit_title: "Trop de requêtes",
8
+ rate_limit_badge: "Rate Limit",
9
+ blocked_title: "Accès bloqué",
10
+ blocked_badge: "Blocage",
11
+ tamper_title: "Remplacement de contenu client détecté",
12
+ tamper_body: "Nous avons remarqué que vous avez tenté de modifier manuellement le rendu client côté navigateur via les DevTools. Cette pratique est évidemment bloquée par nos services.",
13
+ devtools_body: "L'utilisation des DevTools pour remplacer le contenu ou modifier les requêtes réseau a été détectée. L'intégrité de la page est protégée et toute altération est immédiatement bloquée.",
14
+ retry_in_seconds: ->(s) { "Il reste #{s}s avant de pouvoir réessayer." }
15
+ }.freeze
16
+
17
+ EN = {
18
+ rate_limit_title: "Too Many Requests",
19
+ rate_limit_badge: "Rate Limit",
20
+ blocked_title: "Access Blocked",
21
+ blocked_badge: "Blocked",
22
+ tamper_title: "Client Content Replacement Detected",
23
+ tamper_body: "We noticed you attempted to manually modify the client-side rendering via DevTools. This practice is obviously blocked by our services.",
24
+ devtools_body: "Using DevTools to replace content or modify network requests has been detected. Page integrity is protected and any alteration is immediately blocked.",
25
+ retry_in_seconds: ->(s) { "Retry in #{s}s." }
26
+ }.freeze
27
+
28
+ def self.messages(locale)
29
+ locale == "fr" ? FR : EN
30
+ end
31
+
32
+ def self.resolve_locale(explicit, accept_language)
33
+ return explicit.to_s if explicit
34
+ return "fr" if accept_language.to_s.match?(/^fr\b|,\s*fr\b/i)
35
+
36
+ "en"
37
+ end
38
+ end
39
+ end
data/lib/shugoi/notice.rb CHANGED
@@ -76,11 +76,15 @@ module Shugoi
76
76
  # @param html [String] HTML rendu
77
77
  # @param mid [String] machineId du client
78
78
  # @param site_key [String] siteKey
79
+ # @param base_url [String] base URL de l'API (injectée : window.__sg_baseUrl est
80
+ # nettoyé par _sgCl côté client après ~1,5 s — sinon la notice appellerait /notice
81
+ # relatif et l'ack ne passerait jamais)
79
82
  # @return [String] HTML avec la notice injectée
80
- def self.inject(html, mid, site_key)
83
+ def self.inject(html, mid, site_key, base_url = "")
81
84
  script = SCRIPT
82
85
  .gsub("var mid=window.__sg_mid||'';", "var mid=#{JSON.generate(mid)}||'';")
83
86
  .gsub("var sk=window.__sg_siteKey||'';", "var sk=#{JSON.generate(site_key)}||'';")
87
+ .gsub("var base=window.__sg_baseUrl||'';", "var base=#{JSON.generate(base_url.to_s)}||window.__sg_baseUrl||'';")
84
88
  if html.include?("</body>")
85
89
  html.sub("</body>", "#{script}</body>")
86
90
  else
data/lib/shugoi/pow.rb CHANGED
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shugoi
4
- # Proof-of-work anti-curl.
5
- # Parité avec core.ts (module Node) :
4
+ # Proof-of-work anti-curl + cookies HMAC — parité avec core.ts (module Node) :
6
5
  # salt = HMAC(secret, ts)
7
6
  # proof = "ts:nonce" où SHA256(salt:nonce) a >= POW_DIFFICULTY bits à zéro en tête.
7
+ # __sg_ok : ts:HMAC(secret, "sg_ok:ts") — 30 jours, saute le pre-flight PoW
8
+ # __sg_authorized: ts:HMAC(secret, "sg_authorized:ts") — 120 s, protège les assets /assets/*
8
9
  class Pow
9
- def initialize(signing_secret, difficulty = 10, ttl_ms = 120_000)
10
+ POW_OK_TTL_MS = 30 * 24 * 3600 * 1000
11
+ AUTHORIZED_TTL_MS = 120_000
12
+
13
+ def initialize(signing_secret, difficulty = 14, ttl_ms = 60_000)
10
14
  @secret = signing_secret.to_s
11
15
  @difficulty = difficulty
12
16
  @ttl_ms = ttl_ms
@@ -18,7 +22,7 @@ module Shugoi
18
22
  { ts: ts, salt: salt(ts), difficulty: @difficulty }
19
23
  end
20
24
 
21
- # Vérifie un proof "ts:nonce".
25
+ # Vérifie un proof "ts:nonce" (fenêtre @ttl_ms, comptage de bits CORRIGÉ).
22
26
  def valid?(proof)
23
27
  return false if proof.to_s.empty? || @secret.empty?
24
28
  ts_str, solution = proof.to_s.split(":", 2)
@@ -32,10 +36,65 @@ module Shugoi
32
36
  Utils.leading_zero_bits(digest) >= @difficulty
33
37
  end
34
38
 
39
+ # Valeur du cookie __sg_ok (HMAC serveur, 30 j).
40
+ def sg_ok_value
41
+ ts = Utils.now_sec
42
+ "#{ts}:#{Utils.hmac_hex(@secret, "sg_ok:#{ts}")}"
43
+ end
44
+
45
+ def sg_ok_valid?(cookie_val)
46
+ return false if @secret.empty? || cookie_val.to_s.empty?
47
+ ts_str, sig = cookie_val.to_s.split(":", 2)
48
+ return false if ts_str.nil? || sig.nil?
49
+
50
+ ts = ts_str.to_i
51
+ return false if ts.zero?
52
+ return false if Utils.now_ms - ts * 1000 > POW_OK_TTL_MS
53
+ return false if ts * 1000 > Utils.now_ms + 60_000
54
+
55
+ Utils.secure_equals(sig, Utils.hmac_hex(@secret, "sg_ok:#{ts_str}"))
56
+ end
57
+
58
+ # String Set-Cookie pour __sg_ok (posé par le middleware après une preuve valide).
59
+ # @return [String, nil] nil si la preuve n'est pas valide
60
+ def sg_ok_cookie(proof)
61
+ return nil unless valid?(proof)
62
+ secure = production? ? "; Secure" : ""
63
+ "__sg_ok=#{sg_ok_value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{POW_OK_TTL_MS / 1000}#{secure}"
64
+ end
65
+
66
+ # Cookie __sg_authorized posé par handleRender après un render réussi (grant valide).
67
+ def sg_authorized_value
68
+ ts = Utils.now_sec
69
+ "#{ts}:#{Utils.hmac_hex(@secret, "sg_authorized:#{ts}")}"
70
+ end
71
+
72
+ def sg_authorized_valid?(cookie_val)
73
+ return false if @secret.empty? || cookie_val.to_s.empty?
74
+ ts_str, sig = cookie_val.to_s.split(":", 2)
75
+ return false if ts_str.nil? || sig.nil?
76
+
77
+ ts = ts_str.to_i
78
+ return false if ts.zero?
79
+ return false if Utils.now_ms - ts * 1000 > AUTHORIZED_TTL_MS
80
+ return false if ts * 1000 > Utils.now_ms + 60_000
81
+
82
+ Utils.secure_equals(sig, Utils.hmac_hex(@secret, "sg_authorized:#{ts_str}"))
83
+ end
84
+
85
+ def sg_authorized_cookie
86
+ secure = production? ? "; Secure" : ""
87
+ "__sg_authorized=#{sg_authorized_value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=120#{secure}"
88
+ end
89
+
35
90
  private
36
91
 
37
92
  def salt(ts)
38
93
  Utils.hmac_hex(@secret, ts.to_s)
39
94
  end
95
+
96
+ def production?
97
+ ENV["NODE_ENV"] == "production" || ENV["RACK_ENV"] == "production"
98
+ end
40
99
  end
41
100
  end
@@ -16,33 +16,42 @@ module Shugoi
16
16
  @app = app
17
17
  @config = Config.new(options)
18
18
  @api_client = ApiClient.new(@config.base_url, debug: @config.debug)
19
+ # Config (whitelist + flags + skipPaths) et render : via internalUrl pour éviter
20
+ # le deadlock (appels serveur→serveur qui repasseraient par le middleware public).
21
+ @api_internal = ApiClient.new(@config.internal_url, debug: @config.debug)
19
22
  @guard_cache = GuardCache.new(@api_client)
20
- @config_cache = ConfigCache.new(@api_client)
23
+ @config_cache = ConfigCache.new(@api_internal)
21
24
  @token_signer = TokenSigner.new(@config.signing_secret)
22
25
  @html_store = HtmlStore.new(disk_path: options[:disk_path])
23
26
  @pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms)
24
27
  @skeleton = SkeletonGenerator.new(@config, @guard_cache, @config_cache, @token_signer)
25
- @render = RenderHandler.new(@config, @token_signer, @html_store, @config_cache)
28
+ @render = RenderHandler.new(@config, @token_signer, @html_store, @config_cache, @pow)
26
29
  @core = Core.new(@config, @pow, @api_client)
30
+ @csp = Csp.build(site_key: @config.site_key, api_origin: Csp.origin_of(@config.base_url), extra_directives: @config.extra_directives, split_render: @config.split_render)
27
31
  end
28
32
 
29
33
  def call(env)
30
34
  path = env["PATH_INFO"].to_s
31
35
  query = parse_query(env["QUERY_STRING"].to_s)
36
+ method = env["REQUEST_METHOD"].to_s.upcase
32
37
 
33
- # Render endpoint.
38
+ # Render endpoint — GET/HEAD uniquement (round 13, parité module Node).
34
39
  if path.end_with?("/__shugoi/render")
40
+ return method_not_allowed unless %w[GET HEAD].include?(method)
35
41
  return handle_render(env, query)
36
42
  end
37
43
 
38
- # CSP.
39
- headers = {}
40
- headers["content-security-policy"] = csp_header if @config.csp_enabled
44
+ # Challenge page — GET/HEAD uniquement.
45
+ if path == "/__sg_challenge" && !%w[GET HEAD].include?(method)
46
+ return method_not_allowed
47
+ end
41
48
 
42
49
  ctx = build_ctx(env, query)
43
50
  decision = @core.evaluate(ctx)
44
51
 
45
52
  if decision
53
+ headers = {}
54
+ headers["content-security-policy"] = @csp if @config.csp_enabled
46
55
  h = headers.merge(decision.headers || {})
47
56
  h["content-type"] = decision.content_type
48
57
  return [decision.status, h, [decision.body]]
@@ -61,8 +70,23 @@ module Shugoi
61
70
  # Rack 3 exige des noms de headers en minuscules → on normalise.
62
71
  resp_headers = resp_headers.each_with_object({}) { |(k, v), acc| acc[k.to_s.downcase] = v }
63
72
 
73
+ # CSP fusionnée avec celle éventuellement posée par l'app (parité middleware.ts).
74
+ if @config.csp_enabled
75
+ existing = resp_headers["content-security-policy"]
76
+ resp_headers["content-security-policy"] = Csp.merge(existing, @csp)
77
+ end
78
+
79
+ # PoW validé → pose le cookie __sg_ok (navigations suivantes sans challenge).
80
+ if (proof = query["sg_proof"]) && !decision
81
+ if (ok_cookie = @pow.sg_ok_cookie(proof))
82
+ resp_headers["set-cookie"] = ok_cookie
83
+ end
84
+ end
85
+
86
+ is_bot = @core.is_trusted_bot?(ctx[:ua].to_s, ctx[:ip].to_s)
87
+
64
88
  # L'allowlist skip le split-render aussi (parité Node : `!core.isAllowlisted(path)`).
65
- return [status, resp_headers, body] unless @config.auto_inject && @config.split_render && !@config.is_allowlisted?(path)
89
+ return [status, resp_headers, body] unless @config.auto_inject && @config.split_render && !is_bot && !@config.is_allowlisted?(path)
66
90
 
67
91
  html = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
68
92
  ct = resp_headers["content-type"].to_s
@@ -90,6 +114,7 @@ module Shugoi
90
114
  ua = env["HTTP_USER_AGENT"].to_s
91
115
  ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
92
116
  ip = env["REMOTE_ADDR"].to_s if ip.empty?
117
+ cookie = env["HTTP_COOKIE"].to_s
93
118
  {
94
119
  path: env["PATH_INFO"].to_s,
95
120
  ua: ua,
@@ -99,6 +124,8 @@ module Shugoi
99
124
  sec_fetch_dest: env["HTTP_SEC_FETCH_DEST"],
100
125
  sec_fetch_mode: env["HTTP_SEC_FETCH_MODE"],
101
126
  sg_proof: query["sg_proof"],
127
+ sg_ok: cookie.match(/(?:^|;\s*)__sg_ok=([^;]+)/) ? Regexp.last_match(1) : nil,
128
+ sg_authorized: cookie.match(/(?:^|;\s*)__sg_authorized=([^;]+)/) ? Regexp.last_match(1) : nil,
102
129
  forwarded_prefix: env["HTTP_X_FORWARDED_PREFIX"]
103
130
  }
104
131
  end
@@ -110,8 +137,20 @@ module Shugoi
110
137
  ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
111
138
  ip = env["REMOTE_ADDR"].to_s if ip.empty?
112
139
  data = @render.render_data(token, mid, grant, ip)
140
+ headers = {}
141
+ if data[:html]
142
+ # Anti-fuite du grant : strict-origin-when-cross-origin (jamais le grant dans
143
+ # le Referer cross-origin). Contenu protégé : jamais mis en cache (round 6).
144
+ data[:html] = RenderHandler.inject_referrer_policy(data[:html])
145
+ headers["referrer-policy"] = "strict-origin-when-cross-origin"
146
+ headers["cache-control"] = "no-store, no-cache, must-revalidate, no-transform"
147
+ headers["pragma"] = "no-cache"
148
+ # Cookie __sg_authorized : autorise ensuite le chargement des assets protégés.
149
+ headers["set-cookie"] = @pow.sg_authorized_cookie
150
+ end
151
+ headers["content-type"] = "application/json"
113
152
  body = JSON.generate(data)
114
- [200, { "content-type" => "application/json", "cache-control" => "no-store" }, [body]]
153
+ [200, headers, [body]]
115
154
  end
116
155
 
117
156
  def inject_guards(html, ctx)
@@ -139,12 +178,9 @@ module Shugoi
139
178
  @skeleton.generate(@config.site_key, signed, @config.base_url, render_url)
140
179
  end
141
180
 
142
- def csp_header
143
- base = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://shugoi.com; " \
144
- "connect-src 'self' https://shugoi.com; style-src 'self' 'unsafe-inline' https://shugoi.com; " \
145
- "font-src 'self' https://shugoi.com data:; img-src 'self' https://shugoi.com data: blob:; " \
146
- "frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
147
- base
181
+ def method_not_allowed
182
+ body = JSON.generate(error: "method_not_allowed")
183
+ [405, { "content-type" => "application/json" }, [body]]
148
184
  end
149
185
 
150
186
  def parse_query(qs)
@@ -30,8 +30,8 @@ module Shugoi
30
30
  @split_render = true
31
31
  @multi_process = false
32
32
  @verify_bots = true
33
- @pow_difficulty = 10
34
- @pow_ttl_ms = 120_000
33
+ @pow_difficulty = 14
34
+ @pow_ttl_ms = 60_000
35
35
  end
36
36
 
37
37
  def to_options
@@ -4,7 +4,7 @@ module Shugoi
4
4
  # Traite la requête /__shugoi/render : vérifie token + grant, sert le HTML stocké.
5
5
  # Parité avec renderResponseData + handleRender (render.ts).
6
6
  class RenderHandler
7
- def initialize(config, token_signer, html_store, config_cache)
7
+ def initialize(config, token_signer, html_store, config_cache, _pow = nil)
8
8
  @config = config
9
9
  @token_signer = token_signer
10
10
  @html_store = html_store
@@ -12,7 +12,7 @@ module Shugoi
12
12
  end
13
13
 
14
14
  # @param token [String] token render
15
- # @param mid [String] machineId
15
+ # @param mid [String] machineId (SHA-256 du fingerprint, 64 hex)
16
16
  # @param grant [String] render-grant
17
17
  # @param ip [String]
18
18
  # @return [Hash] { html: … } ou { error: "not_found" }
@@ -27,7 +27,7 @@ module Shugoi
27
27
  tok_ts = token.split(":")[1].to_i
28
28
  return { error: "not_found" } if !tok_ts.zero? && Utils.now_ms - tok_ts > HtmlStore::TOKEN_TTL_MS
29
29
 
30
- # Anti-bypass token-only : grant valide requis.
30
+ # Anti-bypass token-only : grant valide requis (lié au siteKey + mid hex-64 + TTL 60s).
31
31
  return { error: "not_found" } unless @token_signer.verify_render_grant(mid, grant, token, ip, @config.site_key)
32
32
 
33
33
  content_replace_on = content_replace_flag?(token)
@@ -45,12 +45,27 @@ module Shugoi
45
45
  { error: "not_found" }
46
46
  end
47
47
 
48
+ # Anti-fuite du grant (parité injectReferrerPolicy de render.ts) : strict-origin-when-
49
+ # cross-origin (PAS no-referrer — casserait les embeds YouTube 153). Injecté dans le
50
+ # HTML rendu AVANT document.write, le grant n'est plus dans l'URL de la page.
51
+ def self.inject_referrer_policy(html)
52
+ meta = '<meta name="referrer" content="strict-origin-when-cross-origin">'
53
+ if html.include?("<head>")
54
+ html.sub("<head>", "<head>#{meta}")
55
+ elsif (m = html.match(/<html[^>]*>/))
56
+ html.sub(m[0], "#{m[0]}#{meta}")
57
+ else
58
+ "#{meta}#{html}"
59
+ end
60
+ end
61
+
48
62
  private
49
63
 
50
- # Injecte la notice de consentement (parité handleRender : `if (data.html && mid)`).
64
+ # Injecte la notice de consentement (parité handleRender : `if (data.html && mid)`),
65
+ # avec la base URL injectée (window.__sg_baseUrl est nettoyé par _sgCl côté client).
51
66
  def inject_notice(html, mid)
52
67
  return html if mid.to_s.empty?
53
- Notice.inject(html, mid, @config.site_key)
68
+ Notice.inject(html, mid, @config.site_key, @config.base_url)
54
69
  end
55
70
 
56
71
  def content_replace_flag?(token)
@@ -12,16 +12,21 @@ module Shugoi
12
12
  end
13
13
 
14
14
  # @return [String] HTML du skeleton (<script>…eval([...])…</script>)
15
- def generate(site_key, token, base_url, render_url = "./__shugoi/render", locale = "en")
15
+ def generate(site_key, token, base_url, render_url = "./__shugoi/render", locale = nil)
16
+ locale ||= Locales.resolve_locale(@config.locale, nil)
16
17
  @guard_cache.ensure_ready(site_key, @config.signing_secret)
17
18
  cfg_data = @config_cache.fetch(site_key)
18
19
  flags = cfg_data[:flags]
19
20
  detect = @guard_cache.detect
21
+ msgs = Locales.messages(locale)
20
22
 
21
23
  fragments = []
22
24
  fragments << "window.__sg_siteKey=#{json(site_key)}"
23
25
  fragments << "window.__sg_baseUrl=#{json(base_url)}"
24
26
  fragments << "window.__sg_config=#{json(flags)}"
27
+ # Mode debug (audit #8) : piloté UNIQUEMENT par le serveur. En production ce flag
28
+ # est toujours false → le guard n'active jamais ses traces via ?sg_probe_debug=1.
29
+ fragments << "window.__sg_diagEnabled=#{production? ? 'false' : 'true'}"
25
30
  fragments << "try{if((location.search||'').indexOf('sg_proof=')>=0){var _qs=location.search.replace(/[?&]sg_proof=[^&]*/,'');var _cu=location.pathname+(_qs?_qs:'')+location.hash;history.replaceState(null,'',_cu)}}catch(e){}"
26
31
 
27
32
  pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms).challenge
@@ -33,8 +38,8 @@ module Shugoi
33
38
  fragments << "window.__sg_disableRestrictedAccess=true" unless @config.restricted_access
34
39
  fragments << "try{#{detect}}catch(e){window.__sg_blocked=true}" if detect
35
40
 
36
- # __sg_showBlock (page de blocage néobrutaliste)
37
- fragments << show_block_fragment
41
+ # __sg_showBlock (page de blocage néobrutaliste) — messages localisés interpolés.
42
+ fragments << show_block_fragment(msgs)
38
43
 
39
44
  fragments << "var t=\"#{token}\""
40
45
  fragments << "window.__sg_token=\"#{token}\""
@@ -43,21 +48,18 @@ module Shugoi
43
48
  fragments << "var r=\"#{render_url}\""
44
49
 
45
50
  # rd(p,n) : remplacement du document par le contenu rendu.
46
- fragments << rd_fragment
51
+ fragments << rd_fragment(msgs)
47
52
 
48
53
  fragments << "_gw(function(){rd(r+\"?token=\"+t,0);setTimeout(_sgCl,1500)})"
49
54
  fragments << cleanup_fragment
50
55
 
51
56
  combined = fragments.join(";")
52
57
  # Échappe `</script>` et `</style>` AVANT l'encodage unicode : sans ça, le HTML parser
53
- # du navigateur coupe le <script> dès qu'il rencontre `</script>` dans le bootcode
54
- # (ex. le guard-detect obfusqué contient des balises) → SyntaxError: Unexpected token '<'.
55
- # Parité avec escapeClosingTags (module Node).
58
+ # du navigateur coupe le <script> dès qu'il rencontre `</script>` dans le bootcode.
56
59
  combined = combined.gsub(%r{</(script|style)}i, "<\\/$1")
57
60
  enc = Utils.unicode_encode(combined)
58
61
  decoded_call = "[...'#{enc}'].map(x=>String.fromCodePoint(x.codePointAt(0)-917504)).join('')"
59
- # <meta charset=UTF-8> : garantit que le navigateur lit le bootcode unicode en UTF-8
60
- # (sans ça, interprété en Latin-1 → RangeError au décodage).
62
+ # <meta charset=UTF-8> : garantit que le navigateur lit le bootcode unicode en UTF-8.
61
63
  "<meta charset=\"UTF-8\"><script>eval(#{decoded_call})</script>"
62
64
  end
63
65
 
@@ -67,22 +69,35 @@ module Shugoi
67
69
  JSON.generate(obj)
68
70
  end
69
71
 
70
- def show_block_fragment
71
- css = "body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;display:flex;align-items:center;justify-content:center;padding:1.2rem}#c{max-width:460px;width:100%;background:#fff;border:4px solid #000;border-radius:28px 6px 32px 10px;box-shadow:12px 12px 0 #000;padding:3rem 2.4rem 2.8rem;text-align:center}#c .bdg{display:inline-block;border:2px solid #000;border-radius:10px 2px 14px 4px;padding:.3rem .9rem;font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#E87090;margin-bottom:1.4rem}#c h2{font-family:'Alex Brush',Georgia,'Times New Roman',serif;font-size:2.2rem;color:#E87090;font-weight:400;margin:0 auto .6rem}#c p.desc{font-size:.9rem;color:#555;line-height:1.8;max-width:380px;margin:0 auto}#c p.ft{font-size:.55rem;color:#E87090;margin-top:1.8rem}"
72
- "window.__sg_showBlock=function(msg,title,badge){var h=\"<head><meta charset=UTF-8><meta name=viewport content=width=device-width,initial-scale=1><style>#{css}</style></head><body><div id=c><img src=https://shugoi.com/favicon-block.png class=l><img src=https://shugoi.com/brand-block.png class=b><div class=bdg>\"+(badge||\"\")+\"</div><h2>\"+(title||\"\")+\"</h2><p class=desc>\"+(msg||\"\")+\"</p><p class=ft>\"+location.hostname+\" \\u00b7 Shugoi</p></div></body>\";document.documentElement.innerHTML=h}"
72
+ def production?
73
+ ENV["NODE_ENV"] == "production" || ENV["RACK_ENV"] == "production" || ENV["RAILS_ENV"] == "production"
73
74
  end
74
75
 
75
- def rd_fragment
76
+ # jsStr : chaîne JSON sans les guillemets externes + échappe `<` (parité render.ts).
77
+ def js_str(s)
78
+ JSON.generate(s.to_s).slice(1..-2).gsub("<", "\\x3c")
79
+ end
80
+
81
+ def show_block_fragment(msgs)
82
+ fb_badge = js_str(msgs[:blocked_badge])
83
+ fb_title = js_str(msgs[:blocked_title])
84
+ css = "html,body{height:100%;background:#fcf9f5}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;display:flex;align-items:center;justify-content:center;padding:1.2rem}#c{max-width:460px;width:100%;background:#fff;border:4px solid #000;border-radius:28px 6px 32px 10px;box-shadow:12px 12px 0 #000;padding:3rem 2.4rem 2.8rem;text-align:center}#c .bdg{display:inline-block;border:2px solid #000;border-radius:10px 2px 14px 4px;padding:.3rem .9rem;font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#E87090;margin-bottom:1.4rem}#c h2{font-family:'Alex Brush',Georgia,'Times New Roman',serif;font-size:2.2rem;color:#E87090;font-weight:400;margin:0 auto .6rem}#c p.desc{font-size:.9rem;color:#555;line-height:1.8;max-width:380px;margin:0 auto}#c p.ft{font-size:.55rem;color:#E87090;margin-top:1.8rem}"
85
+ "window.__sg_showBlock=function(msg,title,badge){var h=\"<head><meta charset=UTF-8><meta name=viewport content=width=device-width,initial-scale=1><style>@font-face{font-family:\\x27Alex Brush\\x27;src:url(https://shugoi.com/alex-brush.woff2?v=2) format(\\x27woff2\\x27);font-display:swap}*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}#{css}</style></head><body><div id=c><img src=https://shugoi.com/favicon-block.png class=l><img src=https://shugoi.com/brand-block.png class=b><div class=bdg>\"+(badge||\"#{fb_badge}\")+\"</div><h2>\"+(title||\"#{fb_title}\")+\"</h2><p class=desc>\"+(msg||\"\")+\"</p><p class=ft>\"+location.hostname+\" \\u00b7 Shugoi</p></div></body>\";document.documentElement.innerHTML=h}"
86
+ end
87
+
88
+ def rd_fragment(msgs)
89
+ devtools = js_str(msgs[:devtools_body])
90
+ tamper_title = js_str(msgs[:tamper_title])
76
91
  "var _gw=function(cb){if(window.__sg_guardsReady||window.__sg_blocked)cb();else setTimeout(function(){_gw(cb)},100)};" \
77
92
  "function rd(p,n){if(window.__sg_blocked)return;if(!document.body)return setTimeout(function(){rd(p,n)},50);" \
78
- "if(n>6){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"\",\"\",\"\");return}" \
93
+ "if(n>6){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"#{devtools}\",\"#{tamper_title}\");return}" \
79
94
  "var _g=(window.__sg_grant||\"\");if(_g){p=p+(\"&grant=\"+encodeURIComponent(_g))}" \
80
95
  "var _m=(window.__sg_detectMid||window.__sg_mid||\"\");if(_m){p=p+(\"&mid=\"+encodeURIComponent(_m))}" \
81
96
  "fetch(p).then(function(x){return x.json()}).then(function(d){if(window.__sg_blocked)return;" \
82
97
  "if(!document.body)return setTimeout(function(){rd(p,n+1)},50);" \
83
98
  "if(d.html){document.open(\"text/html\");document.write(d.html);document.close();window.scrollTo(0,0)}" \
84
99
  "if(d.blocked){window.__sg_showBlock&&window.__sg_showBlock(d.message,d.title)}" \
85
- "if(d.error){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"\",\"\",\"\")}" \
100
+ "if(d.error){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"#{devtools}\",\"#{tamper_title}\")}" \
86
101
  "else if(!d.html&&!d.blocked){setTimeout(function(){rd(p,n+1)},300)}})" \
87
102
  ".catch(function(){setTimeout(function(){rd(p,n+1)},300)})}"
88
103
  end
@@ -6,7 +6,8 @@ module Shugoi
6
6
  # token : siteKey:timestamp:nonce:sig (sig = HMAC(secret, "siteKey:ts:nonce"))
7
7
  # grant : base36(ts):HMAC(secret, "render-grant:siteKey:mid:token:ip:ts")
8
8
  class TokenSigner
9
- GRANT_TTL_MS = 120_000
9
+ # Parité module Node (render.ts) : TTL du render-grant réduit à 60 s (audit 2026-08-03).
10
+ GRANT_TTL_MS = 60_000
10
11
 
11
12
  def initialize(secret)
12
13
  @secret = secret.to_s
data/lib/shugoi/utils.rb CHANGED
@@ -39,7 +39,11 @@ module Shugoi
39
39
  str.to_s.to_i(36)
40
40
  end
41
41
 
42
- # Nombre de bits à zéro en tête du digest hex (parité avec la fonction JS `bits`).
42
+ # Nombre de bits à zéro en tête du digest hex (parité avec la fonction JS `bits`
43
+ # du module Node). Audit 2026-08-03 : comptage CORRIGÉ — l'ancienne version
44
+ # (nib.to_s(2).match(/^0*/)[0].length) sous-comptait les zéros internes du premier
45
+ # nibble non-nul (`3` → '11' → 0 au lieu de 2). Le comptage ci-dessous est exact et
46
+ # DOIT rester synchrone avec core.rb (isPowValid), le challenge JS et le guard.
43
47
  def leading_zero_bits(hex_digest)
44
48
  leading = 0
45
49
  hex_digest.each_char do |c|
@@ -48,7 +52,7 @@ module Shugoi
48
52
  leading += 4
49
53
  next
50
54
  end
51
- leading += nib.to_s(2).match(/^0*/)[0].length
55
+ leading += (nib & 8) != 0 ? 0 : (nib & 4) != 0 ? 1 : (nib & 2) != 0 ? 2 : 3
52
56
  break
53
57
  end
54
58
  leading
@@ -81,5 +85,15 @@ module Shugoi
81
85
  def escape_json_string(s)
82
86
  JSON.generate(s.to_s)[1..-2]
83
87
  end
88
+
89
+ # Échappement HTML (parité escapeHtml de core.ts).
90
+ def escape_html(s)
91
+ s.to_s
92
+ .gsub("&", "&amp;")
93
+ .gsub("<", "&lt;")
94
+ .gsub(">", "&gt;")
95
+ .gsub('"', "&quot;")
96
+ .gsub("'", "&#39;")
97
+ end
84
98
  end
85
99
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shugoi
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/shugoi.rb CHANGED
@@ -3,10 +3,13 @@
3
3
  require_relative "shugoi/version"
4
4
  require_relative "shugoi/errors"
5
5
  require_relative "shugoi/constants"
6
+ require_relative "shugoi/locales"
7
+ require_relative "shugoi/csp"
6
8
  require_relative "shugoi/config"
7
9
  require_relative "shugoi/utils"
8
10
  require_relative "shugoi/token_signer"
9
11
  require_relative "shugoi/pow"
12
+ require_relative "shugoi/bot_verifier"
10
13
  require_relative "shugoi/api_client"
11
14
  require_relative "shugoi/guard_cache"
12
15
  require_relative "shugoi/config_cache"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shugoi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugoi
@@ -62,13 +62,16 @@ files:
62
62
  - README.md
63
63
  - lib/shugoi.rb
64
64
  - lib/shugoi/api_client.rb
65
+ - lib/shugoi/bot_verifier.rb
65
66
  - lib/shugoi/config.rb
66
67
  - lib/shugoi/config_cache.rb
67
68
  - lib/shugoi/constants.rb
68
69
  - lib/shugoi/core.rb
70
+ - lib/shugoi/csp.rb
69
71
  - lib/shugoi/errors.rb
70
72
  - lib/shugoi/guard_cache.rb
71
73
  - lib/shugoi/html_store.rb
74
+ - lib/shugoi/locales.rb
72
75
  - lib/shugoi/notice.rb
73
76
  - lib/shugoi/pow.rb
74
77
  - lib/shugoi/rack/middleware.rb