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