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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +3 -2
- data/lib/shugoi/api_client.rb +11 -22
- data/lib/shugoi/bot_policy.rb +15 -0
- data/lib/shugoi/bot_verifier.rb +3 -12
- data/lib/shugoi/challenge_limiter.rb +38 -0
- data/lib/shugoi/challenge_path.rb +12 -0
- data/lib/shugoi/challenge_script.rb +24 -0
- data/lib/shugoi/challenge_url.rb +15 -0
- data/lib/shugoi/config.rb +3 -7
- data/lib/shugoi/config_cache.rb +29 -14
- data/lib/shugoi/constants.rb +2 -4
- data/lib/shugoi/core.rb +43 -186
- data/lib/shugoi/csp.rb +3 -21
- data/lib/shugoi/errors.rb +0 -2
- data/lib/shugoi/guard_cache.rb +1 -8
- data/lib/shugoi/guard_injector.rb +31 -0
- data/lib/shugoi/html_store.rb +1 -5
- data/lib/shugoi/key_validator.rb +36 -0
- data/lib/shugoi/locales.rb +15 -9
- data/lib/shugoi/notice.rb +59 -87
- data/lib/shugoi/pow.rb +3 -24
- data/lib/shugoi/rack/middleware.rb +11 -118
- data/lib/shugoi/rails/configuration.rb +4 -7
- data/lib/shugoi/rails/railtie.rb +0 -10
- data/lib/shugoi/rate_limit_formatter.rb +13 -0
- data/lib/shugoi/render_endpoint.rb +24 -0
- data/lib/shugoi/render_handler.rb +0 -22
- data/lib/shugoi/response_processor.rb +36 -0
- data/lib/shugoi/shield_page.rb +82 -0
- data/lib/shugoi/skeleton_generator.rb +25 -30
- data/lib/shugoi/token_signer.rb +0 -15
- data/lib/shugoi/utils.rb +0 -25
- data/lib/shugoi/version.rb +1 -3
- data/lib/shugoi.rb +12 -3
- metadata +44 -13
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
var
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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),
|
|
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
|
|
35
|
+
return @render_endpoint.call(env, query)
|
|
45
36
|
end
|
|
46
37
|
|
|
47
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
15
|
-
@secret = ENV
|
|
16
|
-
@signing_secret = ENV["SHUGOI_SIGNING_SECRET"] || ENV
|
|
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
|
-
}.
|
|
57
|
+
}.compact
|
|
61
58
|
end
|
|
62
59
|
end
|
|
63
60
|
end
|
data/lib/shugoi/rails/railtie.rb
CHANGED
|
@@ -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
|