shugoi 0.4.6 → 0.4.8
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/obfuscator.rb +21 -0
- 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 +26 -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 +13 -3
- metadata +45 -13
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
return nil if @config.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
unless @challenge_limiter.allow?(ctx[:ip].to_s)
|
|
125
67
|
msgs = Locales.messages(loc)
|
|
126
|
-
return
|
|
68
|
+
return rate_limit_decision(ctx, msgs, loc, 60, "1 min")
|
|
127
69
|
end
|
|
128
|
-
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
@
|
|
204
|
-
|
|
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'",
|
|
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
data/lib/shugoi/guard_cache.rb
CHANGED
|
@@ -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
|
data/lib/shugoi/html_store.rb
CHANGED
|
@@ -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
|
data/lib/shugoi/locales.rb
CHANGED
|
@@ -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:
|
|
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
|
|
14
|
-
|
|
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:
|
|
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.
|
|
26
|
-
|
|
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
|
|