shugoi 0.4.6 → 0.4.7

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.
data/lib/shugoi/core.rb CHANGED
@@ -1,107 +1,59 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
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.
6
- # Parité avec core.ts (module Node).
7
2
  class Core
8
- # Résultat de blocage renvoyé au middleware.
3
+ attr_reader :bot_policy
4
+
9
5
  Decision = Struct.new(:status, :content_type, :body, :headers, keyword_init: true)
10
6
 
11
7
  POW_OK_TTL_MS = 30 * 24 * 3600 * 1000
12
8
  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
-
18
9
  def initialize(config, pow, api_client, config_cache = nil)
19
10
  @config = config
20
11
  @pow = pow
21
12
  @api_client = api_client
22
13
  @config_cache = config_cache
23
- @validation_valid = false
24
- @validation_failed = false
25
- @validation_warned_at = 0
14
+ @key_validator = KeyValidator.new(config, api_client)
26
15
  @used_proofs = {}
27
- @challenge_limits = {}
28
16
  @mutex = Mutex.new
29
- @bot_verifier = config.verify_bots ? BotVerifier.new : nil
17
+ @challenge_limiter = ChallengeLimiter.new
18
+ @bot_policy = BotPolicy.new(config)
30
19
  end
31
20
 
32
- # @param ctx [Hash] { path:, ua:, ip:, host:, accept_language:, sec_fetch_dest:, sec_fetch_mode:, sg_proof:, sg_ok:, sg_authorized:, forwarded_prefix: }
33
- # @return [Decision, nil] nil = laisser passer
34
21
  def evaluate(ctx)
35
22
  path = ctx[:path].to_s
36
23
 
37
- # Protection des assets à contenu (/assets/*.js, *.css) — parité core.ts. Le bundle
38
- # SPA est téléchargeable publiquement sans ce verrou : on exige le cookie
39
- # __sg_authorized posé par handleRender après un render réussi (grant valide).
40
- # NB : vérifié AVANT l'allowlist (les assets sont allowlistés pour le split-render).
41
24
  if path.match?(%r{/assets/[^?#]+\.(js|css)(\?|$)})
42
25
  auth_ok = !ctx[:sg_authorized].to_s.empty? && @pow.sg_authorized_valid?(ctx[:sg_authorized])
43
26
  return Decision.new(status: 403, content_type: "text/plain", body: BLOCK_PAGE, headers: {}) unless auth_ok
44
27
  end
45
28
 
46
- ensure_validated
47
- warn_if_validation_failed
48
- return nil if @config.is_allowlisted?(path)
29
+ @key_validator.validate
30
+ @key_validator.warn_if_failed
31
+ return nil if @config.allowlisted?(path)
49
32
 
50
- # Route du challenge JS (le navigateur arrive ici après le 307).
51
33
  return challenge_page(ctx) if path == "/__sg_challenge"
52
34
 
53
- # Pre-flight PoW challenge (anti-curl/view-source).
54
- return nil if path.include?("/__shugoi/") || path.start_with?("/api/")
55
35
  ua = ctx[:ua].to_s
36
+ return nil if path.include?("/__shugoi/") || path.start_with?("/api/")
37
+ preflight = preflight_decision(ctx, ua)
38
+ return preflight if preflight
56
39
 
57
- if !@config.signing_secret.to_s.empty?
58
- proof = ctx[:sg_proof].to_s
59
- valid_proof = !proof.empty? && @pow.valid?(proof)
60
- # Re-audit (résidu #3) : un cookie __sg_ok valide (HMAC serveur, 30 j) saute le
61
- # pre-flight PoW. Posé APRÈS une première résolution réussie (middleware).
62
- valid_cookie = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok], ctx[:ip].to_s, ua)
63
- # Round 16 (R2) : la preuve est SINGLE-USE (par IP) — un rejeu → 307.
64
- proof_fresh = valid_proof ? consume_proof(proof) : false
65
- unless valid_cookie || proof_fresh
66
- return pow_challenge_307(ctx) if allow_challenge?(ctx[:ip].to_s)
67
-
68
- loc = Locales.resolve_locale(@config.locale, ctx[:accept_language])
69
- msgs = Locales.messages(loc)
70
- 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: {})
71
- end
72
- end
73
-
74
- # Flags de détection (whitelist + skipPaths), parité fetchConfigForSiteKey.
75
40
  flags = @config_cache ? @config_cache.fetch(@config.site_key)[:flags] : {}
76
41
  headless_enabled = flags["enableHeadlessCheck"] != false
77
42
 
78
- # Rate limit check — activé uniquement si le flag est explicitement vrai.
79
43
  if flags["enableRateLimit"] == true
80
44
  rl = @api_client.check_rate_limit(@config.site_key, ctx[:ip].to_s, ctx[:ua].to_s)
81
45
  if rl && rl["allowed"] == false
82
46
  reset_at = rl["resetAt"].to_i
83
47
  reset_at = (reset_at / 1000.0).ceil if reset_at > 1_000_000_000_000
84
48
  remain = [0, reset_at - Utils.now_sec].max
85
- mins = remain / 60
86
- secs = remain % 60
87
- time_str = if mins.positive?
88
- "#{mins} min#{mins > 1 ? 's' : ''}#{secs.positive? ? " #{secs} s" : ''}"
89
- else
90
- "#{secs} seconde#{secs > 1 ? 's' : ''}"
91
- end
49
+ time_str = RateLimitFormatter.remaining(remain)
92
50
  loc = @config.locale || Locales.resolve_locale(nil, ctx[:accept_language])
93
51
  msgs = Locales.messages(loc)
94
- body = if @config.block_page.respond_to?(:call)
95
- @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)
96
- else
97
- shield_page(msgs[:rate_limit_title], msgs[:rate_limit_body].call(time_str), msgs[:rate_limit_badge], ctx[:host].to_s, remain, loc)
98
- end
99
- return Decision.new(status: 429, content_type: "text/html", body: body, headers: {})
52
+ return rate_limit_decision(ctx, msgs, loc, remain, time_str)
100
53
  end
101
54
  end
102
55
 
103
- # Blocage headless (UA). Actif par défaut, y compris sans configuration chargée.
104
- if headless_enabled && !ua.empty? && !is_trusted_bot?(ua, ctx[:ip].to_s) && @config.is_headless?(ua)
56
+ if headless_enabled && !ua.empty? && !@bot_policy.trusted?(ua, ctx[:ip].to_s) && @config.headless?(ua)
105
57
  @api_client.post_event(@config.site_key, "headless", "")
106
58
  return Decision.new(status: @config.block_status, content_type: "text/plain", body: BLOCK_PAGE, headers: {})
107
59
  end
@@ -109,118 +61,52 @@ module Shugoi
109
61
  nil
110
62
  end
111
63
 
112
- # Vrai si un bot whitelisté (UA) est authentifié par DNS inverse (parité isTrustedBot).
113
- def is_trusted_bot?(ua, ip)
114
- return false unless @config.is_whitelisted_bot?(ua)
115
- return true unless @bot_verifier
116
-
117
- verified = @bot_verifier.verify(ua, ip)
118
- verified.nil? ? false : verified
119
- end
120
-
121
- # Page challenge (tableau en commentaire + JS PoW inline).
122
64
  def challenge_page(ctx)
123
65
  loc = Locales.resolve_locale(@config.locale, ctx[:accept_language])
124
- if !allow_challenge?(ctx[:ip].to_s)
66
+ unless @challenge_limiter.allow?(ctx[:ip].to_s)
125
67
  msgs = Locales.messages(loc)
126
- 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: {})
68
+ return rate_limit_decision(ctx, msgs, loc, 60, "1 min")
127
69
  end
128
- js = <<~JS
129
- (function(){
130
- var P=new URLSearchParams(location.search);
131
- 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')||'/';
132
- // Open redirect (audit #5) : un //evil.com (protocole-relatif) ou un backslash
133
- // détourneraient le location.replace ci-dessous vers un domaine externe.
134
- if(path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf('\\\\')>=0)path='/';
135
- var enc=new TextEncoder();
136
- // Audit 2026-08-03 : comptage de bits CORRIGÉ (zéros internes du premier nibble
137
- // non-nul comptés) — DOIT rester synchrone avec Pow#valid? + guard + whitelist.
138
- 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}
139
- var n=0;
140
- function step(){
141
- crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){
142
- var h=Array.from(new Uint8Array(buf)).map(function(v){return v.toString(16).padStart(2,'0')}).join('');
143
- if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+nonce+':'+n.toString(16);location.replace(base+q)}
144
- else{n++;if(n<300000)step()}
145
- }).catch(function(){location.reload()});
146
- }
147
- step();
148
- })();
149
- JS
70
+ js = ChallengeScript.render(@config.pow_difficulty)
150
71
  html = "<!--\n#{BLOCK_PAGE}-->\n<script>#{js}</script>"
151
72
  Decision.new(status: 200, content_type: "text/html", body: html, headers: {})
152
73
  end
153
74
 
154
75
  private
155
76
 
156
- # Page de blocage néobrutaliste (parité shieldPage de core.ts).
157
- def shield_page(title, msg, badge, host, remain_secs, locale)
158
- msgs = Locales.messages(locale)
159
- prefix = msg.to_s.sub(/Il reste \d+ seconde?s?.*$/, "").sub(/Retry in \d+s?.*$/, "").strip
160
- countdown_script = if remain_secs.positive?
161
- '<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>'
162
- else
163
- ""
164
- end
165
- desc = remain_secs.positive? ? "#{prefix} #{msgs[:retry_in_seconds].call(remain_secs)}" : msg.to_s
166
- html_title = Utils.escape_html(title.to_s.empty? ? msgs[:blocked_title] : title)
167
- html_badge = Utils.escape_html(badge.to_s.empty? ? msgs[:blocked_badge] : badge)
168
- html_host = Utils.escape_html((host.to_s.empty? ? "shugoi.com" : host).slice(0, 120))
169
- html_desc = Utils.escape_html(desc)
170
- html_lang = locale == "fr" ? "fr" : "en"
171
- '<!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>'
172
- end
77
+ def preflight_decision(ctx, ua)
78
+ return nil if @config.signing_secret.to_s.empty?
173
79
 
174
- # 307 vers le challenge : body = tableau ASCII seul (curl le voit tel quel).
175
- def pow_challenge_307(ctx)
176
- ts = Utils.now_sec
177
- nonce = SecureRandom.hex(8)
178
- salt = Utils.hmac_hex(@config.signing_secret, "#{ts}:#{nonce}")
179
- prefix = ctx[:forwarded_prefix].to_s
180
- prefix = "" if prefix == "/"
181
- prefix = prefix.sub(%r{/\z}, "") unless prefix.empty?
182
- path = safe_challenge_path(ctx[:path].to_s)
183
- chal_url = "#{prefix}/__sg_challenge?ts=#{ts}&salt=#{salt}&nonce=#{nonce}&diff=#{@config.pow_difficulty}&path=#{URI.encode_www_form_component(prefix + path)}"
184
- Decision.new(status: 307, content_type: "text/plain", body: BLOCK_PAGE, headers: { "location" => chal_url })
185
- end
80
+ proof = ctx[:sg_proof].to_s
81
+ valid_proof = !proof.empty? && @pow.valid?(proof)
82
+ valid_cookie = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok], ctx[:ip].to_s, ua)
83
+ proof_fresh = valid_proof ? consume_proof(proof) : false
84
+ return nil if valid_cookie || proof_fresh
85
+ return pow_challenge(ctx) if @challenge_limiter.allow?(ctx[:ip].to_s)
186
86
 
187
- # Sanitisation open redirect (audit #5) : n'accepte qu'un chemin relatif commençant par
188
- # UN SEUL '/', sans backslash ni caractères de contrôle.
189
- def safe_challenge_path(p)
190
- return "/" if p.to_s.empty?
191
- return "/" unless p.start_with?("/")
192
- return "/" if p.start_with?("//") || p.include?("\\")
193
- return "/" if p.each_codepoint.any? { |c| c < 0x20 || c == 0x7f }
194
-
195
- p
87
+ locale = Locales.resolve_locale(@config.locale, ctx[:accept_language])
88
+ rate_limit_decision(ctx, Locales.messages(locale), locale, 60, "1 min")
196
89
  end
197
90
 
198
- # Anti-scraping : borne par IP l'émission de challenges (quota + backoff exponentiel).
199
- def allow_challenge?(ip)
200
- return true if ip.to_s.empty? || ip == "unknown"
91
+ def rate_limit_decision(ctx, messages, locale, remaining_seconds, remaining_text)
92
+ message = messages[:rate_limit_body].call(remaining_text)
93
+ body = if @config.block_page.respond_to?(:call)
94
+ @config.block_page.call(reason: "rate_limit", title: messages[:rate_limit_title],
95
+ message: message, badge: messages[:rate_limit_badge], host: ctx[:host].to_s,
96
+ remainingSeconds: remaining_seconds, locale: locale)
97
+ else
98
+ ShieldPage.render(title: messages[:rate_limit_title], message: message,
99
+ badge: messages[:rate_limit_badge], host: ctx[:host].to_s,
100
+ remaining_seconds: remaining_seconds, locale: locale)
101
+ end
102
+ Decision.new(status: 429, content_type: "text/html", body: body, headers: {})
103
+ end
201
104
 
202
- now = Utils.now_ms
203
- @mutex.synchronize do
204
- e = @challenge_limits[ip]
205
- if e.nil? || now - e[:window_start] >= CHALLENGE_WINDOW_MS
206
- @challenge_limits[ip] = { count: 1, window_start: now, blocked_until: 0 }
207
- return true
208
- end
209
- e[:count] += 1
210
- return false if e[:blocked_until] > now
211
- if e[:count] > CHALLENGE_LIMIT
212
- backoff = [60_000 * (2**[e[:count] - CHALLENGE_LIMIT, 10].min), CHALLENGE_MAX_BLOCK_MS].min
213
- e[:blocked_until] = now + backoff
214
- e[:count] = 0
215
- return false
216
- end
217
- true
218
- end
105
+ def pow_challenge(ctx)
106
+ location = ChallengeUrl.build(secret: @config.signing_secret, difficulty: @config.pow_difficulty, context: ctx)
107
+ Decision.new(status: 307, content_type: "text/plain", body: BLOCK_PAGE, headers: { "location" => location })
219
108
  end
220
109
 
221
- # Preuve PoW single-use GLOBAL (round 17) — clé = preuve seule (le nonce aléatoire
222
- # la rend unique) → rejeu depuis N'IMPORTE QUEL IP → 307 (round 16 : clé ip:proof
223
- # laissait le rejeu cross-IP → 200). Entrées purgées après POW_TTL_MS.
224
110
  def consume_proof(proof)
225
111
  @mutex.synchronize do
226
112
  return false if @used_proofs.key?(proof)
@@ -230,34 +116,5 @@ module Shugoi
230
116
  true
231
117
  end
232
118
  end
233
-
234
- # Validation de la clé (lazy) + avertissement périodique si elle échoue.
235
- def ensure_validated
236
- return unless @config.secret
237
- return if @validation_valid || @validation_failed
238
-
239
- result = @api_client.validate_key(@config.site_key, @config.secret)
240
- if result["valid"] == true
241
- @validation_valid = true
242
- else
243
- @validation_failed = true
244
- end
245
- rescue StandardError
246
- @validation_failed = true
247
- end
248
-
249
- def warn_if_validation_failed
250
- return unless @config.secret
251
- return unless @validation_failed
252
- return if Utils.now_ms - @validation_warned_at < VALIDATION_WARN_INTERVAL
253
-
254
- @validation_warned_at = Utils.now_ms
255
- warn(
256
- "[shugoi] La validation de la clé a échoué pour le siteKey #{@config.site_key}.\n" \
257
- "[shugoi] La protection reste active, mais cette installation n'est pas authentifiée.\n" \
258
- "[shugoi] Vérifiez `site_key` et `secret` : https://shugoi.com/docs#validation"
259
- )
260
- @api_client.post_event(@config.site_key, "validation_failed", "")
261
- end
262
119
  end
263
120
  end
data/lib/shugoi/csp.rb CHANGED
@@ -1,16 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "uri"
4
2
 
5
3
  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
4
  class Csp
11
5
  SHUGOI_ORIGIN = "https://shugoi.com".freeze
12
-
13
- # Retourne l'origine (schéma://host[:port]) d'une base URL, nil sinon.
14
6
  def self.origin_of(base_url)
15
7
  return nil if base_url.to_s.empty?
16
8
  u = URI.parse(base_url)
@@ -22,16 +14,13 @@ module Shugoi
22
14
  nil
23
15
  end
24
16
 
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
17
  def self.build(site_key:, api_origin: nil, extra_directives: {}, split_render: true)
18
+ _ = site_key
19
+ _ = split_render
31
20
  api = [SHUGOI_ORIGIN, api_origin].compact.uniq
32
21
  merged = {
33
22
  "default-src" => ["'self'"],
34
- "script-src" => ["'self'", "'unsafe-inline'", "'unsafe-eval'", *api],
23
+ "script-src" => ["'self'", "'unsafe-inline'", *api],
35
24
  "connect-src" => ["'self'", *api],
36
25
  "style-src" => ["'self'", "'unsafe-inline'", *api],
37
26
  "font-src" => ["'self'", *api, "data:"],
@@ -44,15 +33,9 @@ module Shugoi
44
33
  extra_directives.each do |key, values|
45
34
  merged[key] = ((merged[key] || []) + Array(values)).uniq
46
35
  end
47
- if split_render == false
48
- merged["script-src"] = (merged["script-src"] || []).reject { |v| v == "'unsafe-eval'" }
49
- end
50
36
  merged.map { |k, v| "#{k} #{v.join(' ')}" }.join("; ")
51
37
  end
52
38
 
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
39
  def self.merge(existing, added)
57
40
  return added if existing.to_s.empty?
58
41
  base = parse(existing)
@@ -65,7 +48,6 @@ module Shugoi
65
48
  base.map { |k, v| "#{k} #{v.to_a.join(' ')}" }.join("; ")
66
49
  end
67
50
 
68
- # Parse une CSP en Hash<String, Set<String>>.
69
51
  def self.parse(csp)
70
52
  out = {}
71
53
  csp.to_s.split(";").each do |part|
data/lib/shugoi/errors.rb CHANGED
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
2
  class Error < StandardError; end
5
3
  class ConfigError < Error; end
@@ -1,12 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "monitor"
4
2
 
5
3
  module Shugoi
6
- # Cache mémoire des guards (guard-detect) fetchés depuis l'API.
7
- # Parité avec _guardCaches (render.ts).
8
4
  class GuardCache
9
5
  TTL_MS = 300_000
6
+ attr_reader :detect
10
7
 
11
8
  def initialize(api_client)
12
9
  @api_client = api_client
@@ -15,10 +12,6 @@ module Shugoi
15
12
  @mutex = Mutex.new
16
13
  end
17
14
 
18
- def detect
19
- @detect
20
- end
21
-
22
15
  def ensure_ready(site_key, secret = nil)
23
16
  return if @detect && Utils.now_ms - @fetched_at < TTL_MS
24
17
 
@@ -0,0 +1,31 @@
1
+ module Shugoi
2
+ class GuardInjector
3
+ def initialize(config, token_signer, html_store, skeleton)
4
+ @config = config
5
+ @token_signer = token_signer
6
+ @html_store = html_store
7
+ @skeleton = skeleton
8
+ end
9
+
10
+ def call(html)
11
+ signed = @token_signer.sign(@config.site_key, Utils.now_ms)
12
+ config_script = @config.restricted_access ? "" : "<script>window.__sg_disableRestrictedAccess=true</script>"
13
+ injected = insert_config(html, config_script)
14
+ @html_store.store(signed, injected, @config.site_key)
15
+ @skeleton.generate(@config.site_key, signed, @config.base_url, "./__shugoi/render")
16
+ end
17
+
18
+ private
19
+
20
+ def insert_config(html, config_script)
21
+ if (index = html.index("</head>"))
22
+ html[0...index] + config_script + html[index..]
23
+ elsif (match = html.match(/<body[^>]*>/))
24
+ index = html.index(match[0]) + match[0].length
25
+ html[0...index] + config_script + html[index..]
26
+ else
27
+ config_script + html
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,8 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
- # Stockage mémoire (et disque optionnel) du HTML rendu, lié au token.
5
- # Parité avec _memoryStore/_siteCache (render.ts).
6
2
  class HtmlStore
7
3
  TOKEN_TTL_MS = 120_000
8
4
  MAX_ENTRIES = 5000
@@ -26,6 +22,7 @@ module Shugoi
26
22
  while (@entries.size >= MAX_ENTRIES || @total_bytes + size > MAX_TOTAL_BYTES) && !@entries.empty?
27
23
  drop_unlocked(@entries.keys.first)
28
24
  end
25
+ drop_unlocked(token)
29
26
  @entries[token] = Entry.new(html, Utils.now_ms + TOKEN_TTL_MS, 0)
30
27
  @site_cache[site_key] = html
31
28
  @total_bytes += size
@@ -33,7 +30,6 @@ module Shugoi
33
30
  end
34
31
  end
35
32
 
36
- # @return [String, nil] html si présent et lisible
37
33
  def read(token)
38
34
  @mutex.synchronize do
39
35
  entry = @entries[token]
@@ -0,0 +1,36 @@
1
+ module Shugoi
2
+ class KeyValidator
3
+ WARN_INTERVAL_MS = 3_600_000
4
+
5
+ def initialize(config, api_client)
6
+ @config = config
7
+ @api_client = api_client
8
+ @valid = false
9
+ @failed = false
10
+ @warned_at = 0
11
+ end
12
+
13
+ def validate
14
+ return unless @config.secret
15
+ return if @valid || @failed
16
+
17
+ @valid = @api_client.validate_key(@config.site_key, @config.secret)["valid"] == true
18
+ @failed = !@valid
19
+ rescue StandardError
20
+ @failed = true
21
+ end
22
+
23
+ def warn_if_failed
24
+ return unless @config.secret && @failed
25
+ return if Utils.now_ms - @warned_at < WARN_INTERVAL_MS
26
+
27
+ @warned_at = Utils.now_ms
28
+ warn(
29
+ "[shugoi] La validation de la clé a échoué pour le siteKey #{@config.site_key}.\n" \
30
+ "[shugoi] La protection reste active, mais cette installation n'est pas authentifiée.\n" \
31
+ "[shugoi] Vérifiez `site_key` et `secret` : https://shugoi.com/docs#validation"
32
+ )
33
+ @api_client.post_event(@config.site_key, "validation_failed", "")
34
+ end
35
+ end
36
+ end
@@ -1,29 +1,35 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
- # Messages localisés des pages de blocage — parité avec locales.ts (module Node).
5
2
  module Locales
6
3
  FR = {
7
4
  rate_limit_title: "Trop de requêtes",
8
- rate_limit_body: ->(t) { "Vous avez effectué trop de requêtes en peu de temps. Il reste #{t} avant de pouvoir réessayer." },
5
+ rate_limit_body: lambda { |t|
6
+ "Vous avez effectué trop de requêtes en peu de temps. Il reste #{t} avant de pouvoir réessayer."
7
+ },
9
8
  rate_limit_badge: "Rate Limit",
10
9
  blocked_title: "Accès bloqué",
11
10
  blocked_badge: "Blocage",
12
11
  tamper_title: "Remplacement de contenu client détecté",
13
- 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.",
14
- 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.",
12
+ tamper_body: "Nous avons remarqué que vous avez tenté de modifier manuellement le rendu client côté navigateur " \
13
+ "via les DevTools. Cette pratique est évidemment bloquée par nos services.",
14
+ devtools_body: "L'utilisation des DevTools pour remplacer le contenu ou modifier les requêtes réseau " \
15
+ "a été détectée. L'intégrité de la page est protégée et toute altération est " \
16
+ "immédiatement bloquée.",
15
17
  retry_in_seconds: ->(s) { "Il reste #{s}s avant de pouvoir réessayer." }
16
18
  }.freeze
17
19
 
18
20
  EN = {
19
21
  rate_limit_title: "Too Many Requests",
20
- rate_limit_body: ->(t) { "You have made too many requests in a short time. #{t} remaining before you can try again." },
22
+ rate_limit_body: lambda { |t|
23
+ "You have made too many requests in a short time. #{t} remaining before you can try again."
24
+ },
21
25
  rate_limit_badge: "Rate Limit",
22
26
  blocked_title: "Access Blocked",
23
27
  blocked_badge: "Blocked",
24
28
  tamper_title: "Client Content Replacement Detected",
25
- tamper_body: "We noticed you attempted to manually modify the client-side rendering via DevTools. This practice is obviously blocked by our services.",
26
- devtools_body: "Using DevTools to replace content or modify network requests has been detected. Page integrity is protected and any alteration is immediately blocked.",
29
+ tamper_body: "We noticed you attempted to manually modify the client-side rendering via DevTools. " \
30
+ "This practice is obviously blocked by our services.",
31
+ devtools_body: "Using DevTools to replace content or modify network requests has been detected. " \
32
+ "Page integrity is protected and any alteration is immediately blocked.",
27
33
  retry_in_seconds: ->(s) { "Retry in #{s}s." }
28
34
  }.freeze
29
35