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/notice.rb CHANGED
@@ -1,97 +1,69 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
- # Injection de la notice de consentement dans le HTML rendu.
5
- # Parité avec injectNoticeScript (render.ts du module Node) : la notice est injectée
6
- # dans le HTML APRÈS le split-render (elle ne disparaît pas quand le render remplace
7
- # le document). L'ack est décidé côté SERVEUR via /notice (machineId), pas de cookie.
8
2
  class Notice
9
- # Script renforcé — PARITÉ EXACTE avec NOTICE_SCRIPT de render.ts (module Node) :
10
- # - overlay bloquant z-index max (position:fixed;inset:0;pointer-events:auto)
11
- # - MutationObserver anti-bypass ciblé + garde _applying et mo.takeRecords()
12
- # (anti-boucle infinie : nos propres ré-applications ne re-déclenchent pas le MO)
13
- # - PAS de setInterval, PAS de piège clavier (version légère du module Node)
14
- # - seul OK ferme (ack serveur via POST /notice, lié au machineId)
15
- # Les placeholders `mid` et `sk` (et `base`) sont remplacés à l'injection.
16
- SCRIPT = <<~'JS'
17
- <script>
18
- (function(){
19
- var mid=window.__sg_mid||'';
20
- var sk=window.__sg_siteKey||'';
21
- if(!mid||!sk||window.__sg_noticeEnabled===false)return;
22
- var base=window.__sg_baseUrl||'';
23
- var origin=base.replace(/\/api\/v1\/?$/,'');
24
- var _OVERLAY_CSS='position:fixed!important;inset:0!important;z-index:2147483647!important;background:rgba(0,0,0,.6)!important;display:flex!important;align-items:center!important;justify-content:center!important;padding:1.2rem!important;pointer-events:auto!important';
25
- var _CARD_CSS='background:#fff!important;border:4px solid #000!important;border-radius:28px 6px 32px 10px!important;box-shadow:14px 14px 0 #000!important;padding:0!important;max-width:720px!important;width:100%!important;text-align:center!important;font-family:Arial,sans-serif!important;display:flex!important;overflow:hidden!important';
26
- var _CARD_HTML='<div style="flex:0 0 320px;display:flex;align-items:center;justify-content:center;padding:1.5rem 1rem 1.5rem 3rem;overflow:hidden"><img src="'+origin+'/favicon.png" alt="" style="width:100%;height:auto;max-width:220px;pointer-events:none"></div><div style="flex:1;padding:1.6rem 1.8rem;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center"><img src="'+origin+'/brand-block.png" alt="Shugoi" style="display:block;margin:0 0 .3rem;pointer-events:none;max-width:100%;height:auto;max-height:40px"><div style="border:2px solid #000;display:inline-block;border-radius:8px 2px 12px 4px;padding:.2rem .6rem;font-size:.5rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#E87090;margin-bottom:.6rem">Protection anti-abus</div><p style="font-size:.8rem;color:#555;line-height:1.7;margin:0 .4rem .6rem;max-width:280px">Ce site utilise Shugoi pour se prot\\u00e9ger contre les abus et la fraude. Des caract\\u00e9ristiques techniques de votre navigateur sont analys\\u00e9es pour d\\u00e9tecter les scripts automatis\\u00e9s, Tor, les VPN et les environnements virtuels. Aucune donn\\u00e9e personnelle n\\'est collect\\u00e9e.</p><div style="margin-top:.5rem"><button id="__sg_ok" style="background:#E87090;color:#fff;border:3px solid #000;border-radius:12px 3px 14px 5px;padding:.4rem 2rem;font-size:.8rem;font-weight:700;cursor:pointer">OK</button></div><div style="margin-top:.5rem;font-size:.5rem;color:#ccc"><a href="'+origin+'/legal/shugoi-notice" target="_blank" style="color:#E87090;text-decoration:underline">En savoir plus \\u00b7 shugoi.com</a></div></div>';
27
- var _closed=false;
28
- var mo=null;
29
- var _applying=false;
30
- function ack(){try{fetch(base+'/notice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({machineId:mid,siteKey:sk}),keepalive:true,signal:AbortSignal.timeout(4000)}).catch(function(){})}catch(e){}}
31
- function buildOverlay(){
32
- var o=document.createElement('div');o.id='__sg_o';o.style.cssText=_OVERLAY_CSS;
33
- var c=document.createElement('div');c.id='__sg_cd';c.style.cssText=_CARD_CSS;
34
- c.innerHTML=_CARD_HTML;
35
- o.appendChild(c);return o;
36
- }
37
- function close(){_closed=true;try{if(mo)mo.disconnect()}catch(e){}var el=document.getElementById('__sg_o');if(el&&el.parentNode)el.parentNode.removeChild(el);document.body.style.overflow='';document.documentElement.style.overflow='';}
38
- function okHandler(){ack();close();}
39
- function rebind(){var b=document.getElementById('__sg_ok');if(b)b.onclick=okHandler;}
40
- // Anti-bypass 100% MUTATION OBSERVER (aucun setInterval).
41
- // CRITIQUE anti-freeze : le flag _applying + mo.takeRecords() cassent la boucle MO —
42
- // nos propres modifications (style/innerHTML re-appliqués) ne re-déclenchent PAS le MO
43
- // (le navigateur normalise cssText/innerHTML, donc la comparaison échoue toujours et
44
- // on ré-appliquerait à l'infini). takeRecords() vide la file des mutations que NOS
45
- // changements ont générée → une seule passe par altération réelle, jamais de gel.
46
- function enforce(){
47
- if(_closed||_applying)return;
48
- _applying=true;
49
- try{
50
- var o=document.getElementById('__sg_o');
51
- if(!o){o=buildOverlay();document.documentElement.appendChild(o);}
52
- if(o.style.cssText!==_OVERLAY_CSS)o.style.cssText=_OVERLAY_CSS;
53
- var c=document.getElementById('__sg_cd');
54
- if(!c){o.innerHTML='';o.appendChild(buildOverlay().firstChild);}
55
- else{
56
- if(c.style.cssText!==_CARD_CSS)c.style.cssText=_CARD_CSS;
57
- if(c.innerHTML!==_CARD_HTML)c.innerHTML=_CARD_HTML;
3
+ SCRIPT = <<~'JS'.freeze
4
+ <script>
5
+ (function(){
6
+ var mid=window.__sg_mid||'';
7
+ var sk=window.__sg_siteKey||'';
8
+ if(!mid||!sk||window.__sg_noticeEnabled===false)return;
9
+ var base=window.__sg_baseUrl||'';
10
+ var origin=base.replace(/\/api\/v1\/?$/,'');
11
+ var _OVERLAY_CSS='position:fixed!important;inset:0!important;z-index:2147483647!important;background:rgba(0,0,0,.6)!important;display:flex!important;align-items:center!important;justify-content:center!important;padding:1.2rem!important;pointer-events:auto!important';
12
+ var _CARD_CSS='background:#fff!important;border:4px solid #000!important;border-radius:28px 6px 32px 10px!important;box-shadow:14px 14px 0 #000!important;padding:0!important;max-width:720px!important;width:100%!important;text-align:center!important;font-family:Arial,sans-serif!important;display:flex!important;overflow:hidden!important';
13
+ var _CARD_HTML='<div style="flex:0 0 320px;display:flex;align-items:center;justify-content:center;padding:1.5rem 1rem 1.5rem 3rem;overflow:hidden"><img src="'+origin+'/favicon.png" alt="" style="width:100%;height:auto;max-width:220px;pointer-events:none"></div><div style="flex:1;padding:1.6rem 1.8rem;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center"><img src="'+origin+'/brand-block.png" alt="Shugoi" style="display:block;margin:0 0 .3rem;pointer-events:none;max-width:100%;height:auto;max-height:40px"><div style="border:2px solid #000;display:inline-block;border-radius:8px 2px 12px 4px;padding:.2rem .6rem;font-size:.5rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#E87090;margin-bottom:.6rem">Protection anti-abus</div><p style="font-size:.8rem;color:#555;line-height:1.7;margin:0 .4rem .6rem;max-width:280px">Ce site utilise Shugoi pour se prot\\u00e9ger contre les abus et la fraude. Des caract\\u00e9ristiques techniques de votre navigateur sont analys\\u00e9es pour d\\u00e9tecter les scripts automatis\\u00e9s, Tor, les VPN et les environnements virtuels. Aucune donn\\u00e9e personnelle n\\'est collect\\u00e9e.</p><div style="margin-top:.5rem"><button id="__sg_ok" style="background:#E87090;color:#fff;border:3px solid #000;border-radius:12px 3px 14px 5px;padding:.4rem 2rem;font-size:.8rem;font-weight:700;cursor:pointer">OK</button></div><div style="margin-top:.5rem;font-size:.5rem;color:#ccc"><a href="'+origin+'/legal/shugoi-notice" target="_blank" style="color:#E87090;text-decoration:underline">En savoir plus \\u00b7 shugoi.com</a></div></div>';
14
+ var _closed=false;
15
+ var mo=null;
16
+ var _applying=false;
17
+ function ack(){try{fetch(base+'/notice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({machineId:mid,siteKey:sk}),keepalive:true,signal:AbortSignal.timeout(4000)}).catch(function(){})}catch(e){}}
18
+ function buildOverlay(){
19
+ var o=document.createElement('div');o.id='__sg_o';o.style.cssText=_OVERLAY_CSS;
20
+ var c=document.createElement('div');c.id='__sg_cd';c.style.cssText=_CARD_CSS;
21
+ c.innerHTML=_CARD_HTML;
22
+ o.appendChild(c);return o;
23
+ }
24
+ function close(){_closed=true;try{if(mo)mo.disconnect()}catch(e){}var el=document.getElementById('__sg_o');if(el&&el.parentNode)el.parentNode.removeChild(el);document.body.style.overflow='';document.documentElement.style.overflow='';}
25
+ function okHandler(){ack();close();}
26
+ function rebind(){var b=document.getElementById('__sg_ok');if(b)b.onclick=okHandler;}
27
+ function enforce(){
28
+ if(_closed||_applying)return;
29
+ _applying=true;
30
+ try{
31
+ var o=document.getElementById('__sg_o');
32
+ if(!o){o=buildOverlay();document.documentElement.appendChild(o);}
33
+ if(o.style.cssText!==_OVERLAY_CSS)o.style.cssText=_OVERLAY_CSS;
34
+ var c=document.getElementById('__sg_cd');
35
+ if(!c){o.innerHTML='';o.appendChild(buildOverlay().firstChild);}
36
+ else{
37
+ if(c.style.cssText!==_CARD_CSS)c.style.cssText=_CARD_CSS;
38
+ if(c.innerHTML!==_CARD_HTML)c.innerHTML=_CARD_HTML;
39
+ }
40
+ rebind();
41
+ if(document.body.style.overflow!=='hidden')document.body.style.overflow='hidden';
42
+ if(document.documentElement.style.overflow!=='hidden')document.documentElement.style.overflow='hidden';
43
+ try{
44
+ if(o&&!o.__sgObserved){o.__sgObserved=true;mo&&mo.observe(o,{childList:true,subtree:true,attributes:true,characterData:true,attributeFilter:['style','class','id']});}
45
+ }catch(e){}
46
+ }finally{
47
+ _applying=false;
48
+ try{if(mo)mo.takeRecords();}catch(e){}
58
49
  }
50
+ }
51
+ function show(){
52
+ _closed=false;
53
+ var o=buildOverlay();document.documentElement.appendChild(o);
54
+ document.body.style.overflow='hidden';document.documentElement.style.overflow='hidden';
59
55
  rebind();
60
- if(document.body.style.overflow!=='hidden')document.body.style.overflow='hidden';
61
- if(document.documentElement.style.overflow!=='hidden')document.documentElement.style.overflow='hidden';
62
56
  try{
63
- if(o&&!o.__sgObserved){o.__sgObserved=true;mo&&mo.observe(o,{childList:true,subtree:true,attributes:true,characterData:true,attributeFilter:['style','class','id']});}
57
+ mo=new MutationObserver(function(){enforce();});
58
+ mo.observe(document.documentElement,{childList:true});
59
+ try{o.__sgObserved=true;mo.observe(o,{childList:true,subtree:true,attributes:true,characterData:true,attributeFilter:['style','class','id']});}catch(e){}
64
60
  }catch(e){}
65
- }finally{
66
- _applying=false;
67
- try{if(mo)mo.takeRecords();}catch(e){}
68
61
  }
69
- }
70
- function show(){
71
- _closed=false;
72
- var o=buildOverlay();document.documentElement.appendChild(o);
73
- document.body.style.overflow='hidden';document.documentElement.style.overflow='hidden';
74
- rebind();
75
- try{
76
- mo=new MutationObserver(function(){enforce();});
77
- mo.observe(document.documentElement,{childList:true});
78
- try{o.__sgObserved=true;mo.observe(o,{childList:true,subtree:true,attributes:true,characterData:true,attributeFilter:['style','class','id']});}catch(e){}
79
- }catch(e){}
80
- }
81
- function init(){if(document.body)show();else if(document.addEventListener)document.addEventListener('DOMContentLoaded',show);else setTimeout(init,50)}
82
- fetch(base+'/notice?machineId='+encodeURIComponent(mid)+'&siteKey='+encodeURIComponent(sk),{signal:AbortSignal.timeout(4000)}).then(function(r){return r.json()}).then(function(d){if(!d.acknowledged)init()}).catch(function(){init()});
83
- })();
84
- </script>
85
- JS
86
-
87
- # Injecte la notice dans le HTML rendu (avant </body>).
88
- # @param html [String] HTML rendu
89
- # @param mid [String] machineId du client
90
- # @param site_key [String] siteKey
91
- # @param base_url [String] base URL de l'API (injectée : window.__sg_baseUrl est
92
- # nettoyé par _sgCl côté client après ~1,5 s — sinon la notice appellerait /notice
93
- # relatif et l'ack ne passerait jamais)
94
- # @return [String] HTML avec la notice injectée
62
+ function init(){if(document.body)show();else if(document.addEventListener)document.addEventListener('DOMContentLoaded',show);else setTimeout(init,50)}
63
+ fetch(base+'/notice?machineId='+encodeURIComponent(mid)+'&siteKey='+encodeURIComponent(sk),{signal:AbortSignal.timeout(4000)}).then(function(r){return r.json()}).then(function(d){if(!d.acknowledged)init()}).catch(function(){init()});
64
+ })();
65
+ </script>
66
+ JS
95
67
  def self.inject(html, mid, site_key, base_url = "")
96
68
  script = SCRIPT
97
69
  .gsub("var mid=window.__sg_mid||'';", "var mid=#{JSON.generate(mid)}||'';")
data/lib/shugoi/pow.rb CHANGED
@@ -1,15 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "securerandom"
4
2
 
5
3
  module Shugoi
6
- # Proof-of-work anti-curl + cookies HMAC — parité avec core.ts (module Node) :
7
- # salt = HMAC(secret, ts + ':' + nonce) (nonce 64 bits ALEATOIRE par challenge)
8
- # proof = "ts:nonce:solution" où SHA256(salt:solution) a >= POW_DIFFICULTY bits à zéro.
9
- # __sg_ok : ts:ipBucket:uaFp:HMAC(secret, "sg_ok:ts:ipBucket:uaFp") — 30 jours,
10
- # lié au bucket IP + empreinte UA (non rejouable depuis une autre IP),
11
- # saute le pre-flight PoW.
12
- # __sg_authorized: ts:HMAC(secret, "sg_authorized:ts") — 120 s, protège les assets /assets/*
13
4
  class Pow
14
5
  POW_OK_TTL_MS = 30 * 24 * 3600 * 1000
15
6
  AUTHORIZED_TTL_MS = 120_000
@@ -20,13 +11,11 @@ module Shugoi
20
11
  @ttl_ms = ttl_ms
21
12
  end
22
13
 
23
- # Génère le challenge à injecter (window.__sg_pow).
24
14
  def challenge
25
15
  ts = Utils.now_sec
26
16
  { ts: ts, nonce: nonce, salt: salt(ts, nonce), difficulty: @difficulty }
27
17
  end
28
18
 
29
- # Vérifie un proof "ts:nonce:solution" (fenêtre @ttl_ms, comptage de bits CORRIGÉ).
30
19
  def valid?(proof)
31
20
  return false if proof.to_s.empty? || @secret.empty?
32
21
  ts_str, nonce, solution = proof.to_s.split(":", 3)
@@ -35,13 +24,12 @@ module Shugoi
35
24
 
36
25
  ts = ts_str.to_i
37
26
  return false if ts.zero?
38
- return false if (Utils.now_ms - ts * 1000).abs > @ttl_ms
27
+ return false if (Utils.now_ms - (ts * 1000)).abs > @ttl_ms
39
28
 
40
29
  digest = Utils.sha256_hex("#{salt(ts_str, nonce)}:#{solution}")
41
30
  Utils.leading_zero_bits(digest) >= @difficulty
42
31
  end
43
32
 
44
- # Valeur du cookie __sg_ok (HMAC serveur, 30 j) — lié au bucket IP + empreinte UA.
45
33
  def sg_ok_value(ip = "", ua = "")
46
34
  ts = Utils.now_sec
47
35
  bucket = ip_bucket(ip)
@@ -56,23 +44,19 @@ module Shugoi
56
44
 
57
45
  ts = ts_str.to_i
58
46
  return false if ts.zero?
59
- return false if Utils.now_ms - ts * 1000 > POW_OK_TTL_MS
47
+ return false if Utils.now_ms - (ts * 1000) > POW_OK_TTL_MS
60
48
  return false if ts * 1000 > Utils.now_ms + 60_000
61
- # Lier au bucket IP + UA courants : un cookie d'une autre IP/UA → invalide.
62
49
  return false unless bucket == ip_bucket(ip) && fp == ua_fp(ua)
63
50
 
64
51
  Utils.secure_equals(sig, Utils.hmac_hex(@secret, "sg_ok:#{ts_str}:#{bucket}:#{fp}"))
65
52
  end
66
53
 
67
- # String Set-Cookie pour __sg_ok (posé par le middleware après une preuve valide).
68
- # @return [String, nil] nil si la preuve n'est pas valide
69
54
  def sg_ok_cookie(proof, ip = "", ua = "")
70
55
  return nil unless valid?(proof)
71
56
  secure = production? ? "; Secure" : ""
72
57
  "__sg_ok=#{sg_ok_value(ip, ua)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{POW_OK_TTL_MS / 1000}#{secure}"
73
58
  end
74
59
 
75
- # Cookie __sg_authorized posé par handleRender après un render réussi (grant valide).
76
60
  def sg_authorized_value
77
61
  ts = Utils.now_sec
78
62
  "#{ts}:#{Utils.hmac_hex(@secret, "sg_authorized:#{ts}")}"
@@ -85,7 +69,7 @@ module Shugoi
85
69
 
86
70
  ts = ts_str.to_i
87
71
  return false if ts.zero?
88
- return false if Utils.now_ms - ts * 1000 > AUTHORIZED_TTL_MS
72
+ return false if Utils.now_ms - (ts * 1000) > AUTHORIZED_TTL_MS
89
73
  return false if ts * 1000 > Utils.now_ms + 60_000
90
74
 
91
75
  Utils.secure_equals(sig, Utils.hmac_hex(@secret, "sg_authorized:#{ts_str}"))
@@ -98,8 +82,6 @@ module Shugoi
98
82
 
99
83
  private
100
84
 
101
- # Nonce 64 bits ALEATOIRE par challenge (plus de sel déterministe par seconde →
102
- # précomputation par lots impossible). Parité core.ts sgNonce().
103
85
  def nonce
104
86
  SecureRandom.hex(8)
105
87
  end
@@ -108,8 +90,6 @@ module Shugoi
108
90
  Utils.hmac_hex(@secret, "#{ts}:#{nonce}")
109
91
  end
110
92
 
111
- # Bucket d'IP (sans ':' pour rester parseable dans le cookie). IPv4 → /24,
112
- # IPv6 → 4 hextets. Parité core.ts ipBucket().
113
93
  def ip_bucket(ip)
114
94
  ip = ip.to_s
115
95
  return "0" if ip.empty? || ip == "unknown"
@@ -122,7 +102,6 @@ module Shugoi
122
102
  segs.first(4).join(".").empty? ? "0" : segs.first(4).join(".")
123
103
  end
124
104
 
125
- # Empreinte UA (16 hex). Parité core.ts uaFp().
126
105
  def ua_fp(ua)
127
106
  Utils.sha256_hex(ua.to_s).slice(0, 16)
128
107
  end
@@ -1,36 +1,28 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "rack"
4
2
  require "json"
5
3
 
6
4
  module Shugoi
7
5
  module Rack
8
- # Middleware Rack principal — parité avec createShugoiMiddleware (module Node).
9
- #
10
- # use Shugoi::Rack::Middleware, site_key: "sg_sk_live_…", secret: "…"
11
- #
12
- # Ou via la gem :
13
- # use Shugoi, site_key: "…"
14
6
  class Middleware
15
7
  def initialize(app, options = {})
16
8
  @app = app
17
9
  @config = Config.new(options)
18
10
  @disk_path_option = options[:disk_path]
19
11
  @api_client = ApiClient.new(@config.base_url, debug: @config.debug)
20
- # Config (whitelist + flags + skipPaths) et render : via internalUrl pour éviter
21
- # le deadlock (appels serveur→serveur qui repasseraient par le middleware public).
22
12
  @api_internal = ApiClient.new(@config.internal_url, debug: @config.debug)
23
13
  @guard_cache = GuardCache.new(@api_client)
24
14
  @config_cache = ConfigCache.new(@api_internal, @config.signing_secret)
25
15
  @token_signer = TokenSigner.new(@config.signing_secret)
26
- # multi_process → stockage disque du HTML (nécessaire en cluster PM2, parité
27
- # enableDiskStore(true) du module Node).
28
16
  @html_store = HtmlStore.new(disk_path: disk_path)
29
17
  @pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms)
30
18
  @skeleton = SkeletonGenerator.new(@config, @guard_cache, @config_cache, @token_signer)
19
+ @guard_injector = GuardInjector.new(@config, @token_signer, @html_store, @skeleton)
31
20
  @render = RenderHandler.new(@config, @token_signer, @html_store, @config_cache, @pow)
21
+ @render_endpoint = RenderEndpoint.new(@render, @pow)
32
22
  @core = Core.new(@config, @pow, @api_client, @config_cache)
33
- @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)
23
+ @csp = Csp.build(site_key: @config.site_key, api_origin: Csp.origin_of(@config.base_url),
24
+ extra_directives: @config.extra_directives, split_render: @config.split_render)
25
+ @response_processor = ResponseProcessor.new(@config, @csp, @pow, @core.bot_policy, @guard_injector)
34
26
  end
35
27
 
36
28
  def call(env)
@@ -38,25 +30,16 @@ module Shugoi
38
30
  query = parse_query(env["QUERY_STRING"].to_s)
39
31
  method = env["REQUEST_METHOD"].to_s.upcase
40
32
 
41
- # Render endpoint — GET/HEAD uniquement (round 13, parité module Node).
42
33
  if path.end_with?("/__shugoi/render")
43
34
  return method_not_allowed unless %w[GET HEAD].include?(method)
44
- return handle_render(env, query)
35
+ return @render_endpoint.call(env, query)
45
36
  end
46
37
 
47
- # Challenge page GET/HEAD uniquement.
48
- if path == "/__sg_challenge" && !%w[GET HEAD].include?(method)
49
- return method_not_allowed
50
- end
38
+ return method_not_allowed if path == "/__sg_challenge" && !%w[GET HEAD].include?(method)
51
39
 
52
- # SkipPaths (SSR direct) : on laisse l'app servir la page sans challenge ni skeleton.
53
- # Parité middleware.ts : le check est AVANT core.evaluate (un skipPath hors allowlist
54
- # ne doit PAS passer par le challenge PoW ni la protection des assets).
55
40
  if @config.auto_inject && @config.site_key
56
41
  cfg = @config_cache.fetch(@config.site_key)
57
- if cfg[:skip_paths].include?(path)
58
- return @app.call(env)
59
- end
42
+ return @app.call(env) if cfg[:skip_paths].include?(path)
60
43
  end
61
44
 
62
45
  ctx = build_ctx(env, query)
@@ -70,47 +53,8 @@ module Shugoi
70
53
  return [decision.status, h, [decision.body]]
71
54
  end
72
55
 
73
- status, resp_headers, body = @app.call(env)
74
-
75
- # Rack 3 exige des noms de headers en minuscules → on normalise.
76
- resp_headers = resp_headers.each_with_object({}) { |(k, v), acc| acc[k.to_s.downcase] = v }
77
-
78
- # CSP fusionnée avec celle éventuellement posée par l'app (parité middleware.ts).
79
- if @config.csp_enabled
80
- existing = resp_headers["content-security-policy"]
81
- resp_headers["content-security-policy"] = Csp.merge(existing, @csp)
82
- end
83
-
84
- # PoW validé → pose le cookie __sg_ok (navigations suivantes sans challenge).
85
- if (proof = query["sg_proof"]) && !decision
86
- if (ok_cookie = @pow.sg_ok_cookie(proof, ctx[:ip].to_s, ctx[:ua].to_s))
87
- resp_headers["set-cookie"] = ok_cookie
88
- end
89
- end
90
-
91
- is_bot = @core.is_trusted_bot?(ctx[:ua].to_s, ctx[:ip].to_s)
92
-
93
- # L'allowlist skip le split-render aussi (parité Node : `!core.isAllowlisted(path)`).
94
- return [status, resp_headers, body] unless @config.auto_inject && @config.split_render && !is_bot && !@config.is_allowlisted?(path)
95
-
96
- html = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
97
- ct = resp_headers["content-type"].to_s
98
- if html.include?("<html") && (ct.include?("text/html") || ct.empty?)
99
- begin
100
- skeleton = inject_guards(html, ctx)
101
- body = [skeleton]
102
- # CRITIQUE : le bootcode unicode (code points > 917504) s'encode en UTF-8 sur
103
- # 4 octets commençant par 0xF3. Sans `charset=utf-8`, le navigateur interprète
104
- # le body en Latin-1 → les octets 0xF3 deviennent 'ó' → le décodage
105
- # `codePointAt(0)-917504` produit un code point négatif → RangeError.
106
- # Forcer UTF-8 est indispensable pour que le skeleton se décode correctement.
107
- resp_headers["content-type"] = "text/html; charset=utf-8"
108
- rescue StandardError => e
109
- warn("[shugoi] inject error: #{e.message}") if @config.debug
110
- end
111
- end
112
-
113
- [status, resp_headers, body]
56
+ status, response_headers, body = @app.call(env)
57
+ @response_processor.call(status, response_headers, body, ctx.merge(path: path), query)
114
58
  end
115
59
 
116
60
  private
@@ -135,54 +79,6 @@ module Shugoi
135
79
  }
136
80
  end
137
81
 
138
- def handle_render(env, query)
139
- token = query["token"].to_s
140
- mid = query["mid"].to_s
141
- grant = query["grant"].to_s
142
- ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
143
- ip = env["REMOTE_ADDR"].to_s if ip.empty?
144
- data = @render.render_data(token, mid, grant, ip)
145
- headers = {}
146
- if data[:html]
147
- # Anti-fuite du grant : strict-origin-when-cross-origin (jamais le grant dans
148
- # le Referer cross-origin). Contenu protégé : jamais mis en cache (round 6).
149
- data[:html] = RenderHandler.inject_referrer_policy(data[:html])
150
- headers["referrer-policy"] = "strict-origin-when-cross-origin"
151
- headers["cache-control"] = "no-store, no-cache, must-revalidate, no-transform"
152
- headers["pragma"] = "no-cache"
153
- # Cookie __sg_authorized : autorise ensuite le chargement des assets protégés.
154
- headers["set-cookie"] = @pow.sg_authorized_cookie
155
- end
156
- headers["content-type"] = "application/json"
157
- body = JSON.generate(data)
158
- [200, headers, [body]]
159
- end
160
-
161
- def inject_guards(html, ctx)
162
- ts = Utils.now_ms
163
- signed = @token_signer.sign(@config.site_key, ts)
164
- render_url = "./__shugoi/render"
165
-
166
- # Injecte window.__sg_disableRestrictedAccess si nécessaire.
167
- config_script = ""
168
- unless @config.restricted_access
169
- config_script = "<script>window.__sg_disableRestrictedAccess=true</script>"
170
- end
171
-
172
- injected = html
173
- if (i = injected.index("</head>"))
174
- injected = injected[0...i] + config_script + injected[i..]
175
- elsif (m = injected.match(/<body[^>]*>/))
176
- at = injected.index(m[0]) + m[0].length
177
- injected = injected[0...at] + config_script + injected[at..]
178
- else
179
- injected = config_script + injected
180
- end
181
-
182
- @html_store.store(signed, injected, @config.site_key)
183
- @skeleton.generate(@config.site_key, signed, @config.base_url, render_url)
184
- end
185
-
186
82
  def method_not_allowed
187
83
  body = JSON.generate(error: "method_not_allowed")
188
84
  [405, { "content-type" => "application/json" }, [body]]
@@ -192,16 +88,13 @@ module Shugoi
192
88
  ::Rack::Utils.parse_nested_query(qs)
193
89
  end
194
90
 
195
- # Chemin du stockage disque (parité enableDiskStore du module Node).
196
- # multi_process → répertoire tmp partagé (cluster PM2) ; sinon l'option disk_path
197
- # explicite éventuelle.
198
91
  def disk_path
199
92
  return @disk_path_option if @disk_path_option
200
93
  return nil unless @config.multi_process
201
94
 
202
95
  dir = File.join(Dir.tmpdir, "shugoi-render-#{Process.uid}")
203
96
  begin
204
- Dir.mkdir(dir, 0o700) unless Dir.exist?(dir)
97
+ FileUtils.mkdir_p(dir, mode: 0o700)
205
98
  File.chmod(0o700, dir)
206
99
  rescue StandardError
207
100
  return nil
@@ -1,8 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
2
  module Rails
5
- # Configuration globale pour Rails (équivalent de ShugoiCoreOptions).
6
3
  class Configuration
7
4
  attr_accessor :site_key, :secret, :signing_secret, :allowlist,
8
5
  :headless_patterns, :bot_whitelist, :base_url, :internal_url,
@@ -11,9 +8,9 @@ module Shugoi
11
8
  :multi_process, :verify_bots, :pow_difficulty, :pow_ttl_ms
12
9
 
13
10
  def initialize
14
- @site_key = ENV["SHUGOI_SITE_KEY"]
15
- @secret = ENV["SHUGOI_SECRET"]
16
- @signing_secret = ENV["SHUGOI_SIGNING_SECRET"] || ENV["SHUGOKI_SIGNING_SECRET"]
11
+ @site_key = ENV.fetch("SHUGOI_SITE_KEY", nil)
12
+ @secret = ENV.fetch("SHUGOI_SECRET", nil)
13
+ @signing_secret = ENV["SHUGOI_SIGNING_SECRET"] || ENV.fetch("SHUGOKI_SIGNING_SECRET", nil)
17
14
  @allowlist = ["/api", "/legal"]
18
15
  @headless_patterns = Shugoi::DEFAULT_HEADLESS_PATTERNS
19
16
  @bot_whitelist = Shugoi::DEFAULT_BOT_WHITELIST
@@ -57,7 +54,7 @@ module Shugoi
57
54
  verify_bots: @verify_bots,
58
55
  pow_difficulty: @pow_difficulty,
59
56
  pow_ttl_ms: @pow_ttl_ms
60
- }.reject { |_, v| v.nil? }
57
+ }.compact
61
58
  end
62
59
  end
63
60
  end
@@ -1,15 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
2
  module Rails
5
- # Intégration Rails : monte le middleware et expose la configuration.
6
- #
7
- # # config/initializers/shugoi.rb
8
- # Shugoi.configure do |config|
9
- # config.site_key = "sg_sk_live_…"
10
- # config.secret = "…"
11
- # config.debug = Rails.env.development?
12
- # end
13
3
  class Railtie < ::Rails::Railtie
14
4
  initializer "shugoi.middleware" do |app|
15
5
  app.middleware.use Shugoi::Rack::Middleware, Shugoi.config.to_options
@@ -0,0 +1,13 @@
1
+ module Shugoi
2
+ class RateLimitFormatter
3
+ def self.remaining(seconds)
4
+ minutes = seconds / 60
5
+ seconds_part = seconds % 60
6
+ if minutes.positive?
7
+ "#{minutes} min#{'s' if minutes > 1}#{" #{seconds_part} s" if seconds_part.positive?}"
8
+ else
9
+ "#{seconds_part} seconde#{'s' if seconds_part > 1}"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ require "json"
2
+
3
+ module Shugoi
4
+ class RenderEndpoint
5
+ def initialize(renderer, pow)
6
+ @renderer = renderer
7
+ @pow = pow
8
+ end
9
+
10
+ def call(env, query)
11
+ ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
12
+ ip = env["REMOTE_ADDR"].to_s if ip.empty?
13
+ data = @renderer.render_data(query["token"].to_s, query["mid"].to_s, query["grant"].to_s, ip)
14
+ headers = { "content-type" => "application/json" }
15
+ if data[:html]
16
+ data[:html] = RenderHandler.inject_referrer_policy(data[:html])
17
+ headers.merge!("referrer-policy" => "strict-origin-when-cross-origin",
18
+ "cache-control" => "no-store, no-cache, must-revalidate, no-transform",
19
+ "pragma" => "no-cache", "set-cookie" => @pow.sg_authorized_cookie)
20
+ end
21
+ [200, headers, [JSON.generate(data)]]
22
+ end
23
+ end
24
+ end
@@ -1,8 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
- # Traite la requête /__shugoi/render : vérifie token + grant, sert le HTML stocké.
5
- # Parité avec renderResponseData + handleRender (render.ts).
6
2
  class RenderHandler
7
3
  def initialize(config, token_signer, html_store, config_cache, _pow = nil)
8
4
  @config = config
@@ -11,30 +7,17 @@ module Shugoi
11
7
  @config_cache = config_cache
12
8
  end
13
9
 
14
- # @param token [String] token render
15
- # @param mid [String] machineId (SHA-256 du fingerprint, 64 hex)
16
- # @param grant [String] render-grant
17
- # @param ip [String]
18
- # @return [Hash] { html: … } ou { error: "not_found" }
19
10
  def render_data(token, mid, grant, ip)
20
11
  return { error: "not_found" } if token.to_s.empty? || token.length < 16 || token.length > 300
21
-
22
- # Le token doit appartenir à CE siteKey (CRITIQUE 1 §7bis).
23
12
  tok_site_key = token.split(":")[0]
24
13
  return { error: "not_found" } if tok_site_key != @config.site_key
25
-
26
- # Expiration du token.
27
14
  tok_ts = token.split(":")[1].to_i
28
15
  return { error: "not_found" } if !tok_ts.zero? && Utils.now_ms - tok_ts > HtmlStore::TOKEN_TTL_MS
29
-
30
- # Anti-bypass token-only : grant valide requis (lié au siteKey + mid hex-64 + TTL 60s).
31
16
  return { error: "not_found" } unless @token_signer.verify_render_grant(mid, grant, token, ip, @config.site_key)
32
17
 
33
18
  content_replace_on = content_replace_flag?(token)
34
19
  html = @html_store.read(token)
35
20
  return { html: inject_notice(html, mid) } if html
36
-
37
- # Fallback content-replace OFF : renvoie le HTML du site.
38
21
  unless content_replace_on
39
22
  site_html = @html_store.site_html(tok_site_key)
40
23
  return { html: inject_notice(site_html, mid) } if site_html
@@ -45,9 +28,6 @@ module Shugoi
45
28
  { error: "not_found" }
46
29
  end
47
30
 
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
31
  def self.inject_referrer_policy(html)
52
32
  meta = '<meta name="referrer" content="strict-origin-when-cross-origin">'
53
33
  if html.include?("<head>")
@@ -61,8 +41,6 @@ module Shugoi
61
41
 
62
42
  private
63
43
 
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).
66
44
  def inject_notice(html, mid)
67
45
  return html if mid.to_s.empty?
68
46
  Notice.inject(html, mid, @config.site_key, @config.base_url)
@@ -0,0 +1,36 @@
1
+ module Shugoi
2
+ class ResponseProcessor
3
+ def initialize(config, csp, pow, bot_policy, guard_injector)
4
+ @config = config
5
+ @csp = csp
6
+ @pow = pow
7
+ @bot_policy = bot_policy
8
+ @guard_injector = guard_injector
9
+ end
10
+
11
+ def call(status, headers, body, context, query)
12
+ headers = headers.each_with_object({}) { |(key, value), result| result[key.to_s.downcase] = value }
13
+ headers["content-security-policy"] = Csp.merge(headers["content-security-policy"], @csp) if @config.csp_enabled
14
+ proof = query["sg_proof"]
15
+ headers["set-cookie"] = @pow.sg_ok_cookie(proof, context[:ip].to_s, context[:ua].to_s) if proof
16
+ return [status, headers, body] unless @config.auto_inject && @config.split_render
17
+ return [status, headers, body] if @bot_policy.trusted?(context[:ua].to_s, context[:ip].to_s)
18
+ return [status, headers, body] if @config.allowlisted?(context[:path].to_s)
19
+
20
+ html = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
21
+ return [status, headers, body] unless html.include?("<html")
22
+ unless headers["content-type"].to_s.empty? || headers["content-type"].to_s.include?("text/html")
23
+ return [status, headers,
24
+ body]
25
+ end
26
+
27
+ begin
28
+ body = [@guard_injector.call(html)]
29
+ headers["content-type"] = "text/html; charset=utf-8"
30
+ rescue StandardError => e
31
+ warn("[shugoi] inject error: #{e.message}") if @config.debug
32
+ end
33
+ [status, headers, body]
34
+ end
35
+ end
36
+ end