shugoi 0.4.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f5c8d8f2dbb96ef585e0da0079fffc4ccb21cb8d3c9c488ba2e3ba2f6dcccef
4
- data.tar.gz: 8dee0a01a95b9a27e34d0d131cfc24e268834f4992993d843302666c4b531b53
3
+ metadata.gz: 89733ca0ebe0286fdb6818ea06a1127dfd6731cabf8a107163fa7df853a4280f
4
+ data.tar.gz: 7b4cb8e61a095cf0ddf6cadd8fc9fd194a81405d697387c657320a6ebbd92df7
5
5
  SHA512:
6
- metadata.gz: db5513f04ab846d0a248b9dd2cb6b497c74f1f72e30cf9f78c7daaaf2194f4a7b23484f03155f4144e74a2ec51df8e1bd8a3b32e415b196cff3e755d12438de5
7
- data.tar.gz: b7a01c4200dda93eabe8b43adf26f6b493ff68f2fece74bb88353f78b8fc039e4c7dac3a6cf6bc40590d07c2eea0bc65d9f3ef891905ef7a45f2cde702d4c7d1
6
+ metadata.gz: 26fdbcfc3d51751c68e31a437539d024e036f5afc8569afd7e4174dec5f76b7939edc3236a83152d7f865e15d025e34efc03e9c664f261767499bf667fcb9db1
7
+ data.tar.gz: 930b00eb96a31c6400792d092c5af08c2e2a7b57f578ae0714e153241c73c07d316f30bc3183a504763597891def44da03cfc025f74077bfc00faa9644b186a9
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Maintenance
6
+ - Extracted challenge, guard, rate-limit and response responsibilities into focused modules.
7
+ - Added dedicated regression specs and finalized RuboCop validation.
8
+ - Documented the gem architecture and production metadata.
9
+
10
+ ### Security
11
+ - Removed dynamic JavaScript evaluation from the split-render bootstrap.
12
+ - Removed `unsafe-eval` from the default Content Security Policy.
13
+
14
+ ### Performance
15
+ - Removed Unicode-tag expansion from skeleton responses, reducing transfer size and parse work.
data/README.md CHANGED
@@ -6,7 +6,7 @@ Bloque les bots, scrapers et clients headless avec :
6
6
  - **Pre-flight PoW challenge** (307 + tableau ASCII pour curl/view-source, JS de résolution inline pour les navigateurs)
7
7
  - **Machine fingerprinting** (guard-detect) + whitelist
8
8
  - **Render-grant HMAC** (lié au siteKey + token + IP + TTL) — anti-bypass "token-only"
9
- - **Split-render** : skeleton unicode injecté dans la page, remplacé par le contenu réel après vérification
9
+ - **Split-render** : bootstrap JavaScript injecté dans la page, remplacé par le contenu réel après vérification
10
10
 
11
11
  ## Installation
12
12
 
@@ -59,12 +59,13 @@ run MyApp
59
59
  ```bash
60
60
  bundle install
61
61
  bundle exec rspec
62
+ bundle exec rake lint
62
63
  ```
63
64
 
64
65
  ## Parité avec le module Node
65
66
 
66
67
  Le flux réplique exactement `shugoi` 0.3.6 (npm) :
67
68
  - `core.evaluate` → challenge 307 / `/__sg_challenge` / blocage headless
68
- - `injectGuardScripts` → token signé + skeleton unicode
69
+ - `injectGuardScripts` → token signé + bootstrap sans évaluation dynamique
69
70
  - `renderResponseData` → vérification grant + token, service du HTML stocké
70
71
  - `verifyRenderGrant` → format `base36(ts):HMAC(secret, "render-grant:siteKey:mid:token:ip:ts")`
@@ -1,11 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "net/http"
4
2
  require "uri"
5
3
  require "json"
6
4
 
7
5
  module Shugoi
8
- # Client HTTP vers l'API Shugoi (parité avec fetch dans le module Node).
9
6
  class ApiClient
10
7
  TIMEOUT = 5.0
11
8
 
@@ -14,9 +11,6 @@ module Shugoi
14
11
  @debug = debug
15
12
  end
16
13
 
17
- # GET /whitelist?key=...&cb=...&sig=... → { detectionFlags, skipPaths }
18
- # La siteKey est publique ; la configuration serveur doit prouver la possession
19
- # du secret de signature (parité Node/PHP, F2/F3).
20
14
  def fetch_whitelist(site_key, secret = nil)
21
15
  cb = SecureRandom.hex(4)
22
16
  params = { key: site_key, cb: cb.to_s }
@@ -31,18 +25,15 @@ module Shugoi
31
25
  { whitelist: [], flags: {}, skip_paths: [] }
32
26
  end
33
27
 
34
- # GET /guard-detect?key=...&raw=1&cb=...&sig=... → texte du guard
35
28
  def fetch_guard_detect(site_key, secret = nil)
36
29
  cb = Utils.now_ms
37
30
  query = { key: site_key, raw: 1, cb: cb.to_s }
38
31
  query[:sig] = Utils.hmac_hex(secret, cb.to_s) if secret && !secret.empty?
39
- body = get_raw("/guard-detect", query)
40
- body
32
+ get_raw("/guard-detect", query)
41
33
  rescue StandardError
42
34
  nil
43
35
  end
44
36
 
45
- # POST /event — enregistrement d'un événement (block, headless…)
46
37
  def post_event(site_key, reason, machine_id = "")
47
38
  payload = { siteKey: site_key, reason: reason, machineId: machine_id }
48
39
  post_json("/event", payload)
@@ -50,16 +41,13 @@ module Shugoi
50
41
  nil
51
42
  end
52
43
 
53
- # POST /validate-key — valide la clé
54
44
  def validate_key(site_key, secret)
55
45
  payload = { siteKey: site_key, secret: secret }
56
- json = post_json("/validate-key", payload)
57
- json
46
+ post_json("/validate-key", payload)
58
47
  rescue StandardError
59
48
  { "valid" => false, "reason" => "network" }
60
49
  end
61
50
 
62
- # POST /rate-limit-check — vérifie le quota edge par IP (parité core.ts).
63
51
  def check_rate_limit(site_key, ip, user_agent = "")
64
52
  payload = {
65
53
  siteKey: site_key,
@@ -77,7 +65,8 @@ module Shugoi
77
65
  def get(path, params)
78
66
  uri = build_uri(path, params)
79
67
  log("GET #{uri}")
80
- res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
68
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT,
69
+ read_timeout: TIMEOUT) do |http|
81
70
  http.get(uri.request_uri, "User-Agent" => "shugoi-ruby/#{VERSION}")
82
71
  end
83
72
  JSON.parse(res.body)
@@ -85,27 +74,27 @@ module Shugoi
85
74
 
86
75
  def get_raw(path, params)
87
76
  uri = build_uri(path, params)
88
- res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
77
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT,
78
+ read_timeout: TIMEOUT) do |http|
89
79
  http.get(uri.request_uri, "User-Agent" => "shugoi-ruby/#{VERSION}")
90
80
  end
91
- # Décodage UTF-8 avec remplacement (parité fetch().text() du module Node).
92
81
  res.body.to_s.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
93
82
  end
94
83
 
95
84
  def post_json(path, payload)
96
85
  uri = build_uri(path)
97
- req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "User-Agent" => "shugoi-ruby/#{VERSION}")
86
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json",
87
+ "User-Agent" => "shugoi-ruby/#{VERSION}")
98
88
  req.body = JSON.generate(payload)
99
- res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
89
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT,
90
+ read_timeout: TIMEOUT) do |http|
100
91
  http.request(req)
101
92
  end
102
93
  JSON.parse(res.body)
103
94
  end
104
95
 
105
96
  def build_uri(path, params = {})
106
- # Concatène le path RELATIF à base_url. URI.join avec un path absolu (`/guard-detect`)
107
- # remplacerait le préfixe /api/v1 → https://shugoi.com/guard-detect (catch-all SPA) !
108
- uri = URI(@base_url.sub(%r{/\z}, "") + "/" + path.sub(%r{\A/}, ""))
97
+ uri = URI("#{@base_url.sub(%r{/\z}, '')}/#{path.sub(%r{\A/}, '')}")
109
98
  uri.query = URI.encode_www_form(params) unless params.empty?
110
99
  uri
111
100
  end
@@ -0,0 +1,15 @@
1
+ module Shugoi
2
+ class BotPolicy
3
+ def initialize(config)
4
+ @config = config
5
+ @verifier = config.verify_bots ? BotVerifier.new : nil
6
+ end
7
+
8
+ def trusted?(user_agent, ip)
9
+ return false unless @config.whitelisted_bot?(user_agent)
10
+ return true unless @verifier
11
+
12
+ @verifier.verify(user_agent, ip) == true
13
+ end
14
+ end
15
+ end
@@ -1,10 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "resolv"
4
2
 
5
3
  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
4
  class BotVerifier
9
5
  BOT_DOMAINS = [
10
6
  { pattern: /Googlebot|Google-InspectionTool|Storebot-Google/i, suffixes: [".googlebot.com", ".google.com"] },
@@ -23,9 +19,6 @@ module Shugoi
23
19
  @mutex = Mutex.new
24
20
  end
25
21
 
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
22
  def verify(ua, ip)
30
23
  entry = BOT_DOMAINS.find { |b| b[:pattern].match?(ua) }
31
24
  return nil unless entry
@@ -48,21 +41,19 @@ module Shugoi
48
41
 
49
42
  private
50
43
 
51
- # PTR de l'IP → nom se terminant par un suffixe attendu, puis forward vérifié.
52
44
  def reverse_forward_match?(ip, suffixes)
53
45
  names = Resolv.getnames(ip)
54
46
  name = names.find { |n| suffixes.any? { |s| n.downcase.end_with?(s) } }
55
47
  return false unless name
56
48
  addresses = Resolv.getaddresses(name)
57
49
  addresses.include?(ip)
58
- rescue Resolv::ResolvError, StandardError
50
+ rescue StandardError
59
51
  false
60
52
  end
61
53
 
62
54
  def prune
63
- if @cache.size >= MAX_ENTRIES
64
- @cache.delete(@cache.keys.min_by { |k| @cache[k][:at] })
65
- end
55
+ return unless @cache.size >= MAX_ENTRIES
56
+ @cache.delete(@cache.keys.min_by { |k| @cache[k][:at] })
66
57
  end
67
58
  end
68
59
  end
@@ -0,0 +1,38 @@
1
+ module Shugoi
2
+ class ChallengeLimiter
3
+ LIMIT = 60
4
+ WINDOW_MS = 60_000
5
+ MAX_BLOCK_MS = 15 * 60 * 1000
6
+
7
+ def initialize
8
+ @limits = {}
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def allow?(ip)
13
+ return true if ip.to_s.empty? || ip == "unknown"
14
+
15
+ now = Utils.now_ms
16
+ @mutex.synchronize do
17
+ entry = @limits[ip]
18
+ if entry.nil? || now - entry[:window_start] >= WINDOW_MS
19
+ @limits[ip] = { count: 1, window_start: now, blocked_until: 0 }
20
+ next true
21
+ end
22
+
23
+ entry[:count] += 1
24
+ next false if entry[:blocked_until] > now
25
+
26
+ if entry[:count] > LIMIT
27
+ exponent = [entry[:count] - LIMIT, 10].min
28
+ backoff = [60_000 * (2**exponent), MAX_BLOCK_MS].min
29
+ entry[:blocked_until] = now + backoff
30
+ entry[:count] = 0
31
+ next false
32
+ end
33
+
34
+ true
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ module Shugoi
2
+ class ChallengePath
3
+ def self.sanitize(path)
4
+ return '/' if path.to_s.empty?
5
+ return '/' unless path.start_with?('/')
6
+ return '/' if path.start_with?('//') || path.include?('\\')
7
+ return '/' if path.each_codepoint.any? { |codepoint| codepoint < 0x20 || codepoint == 0x7f }
8
+
9
+ path
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ module Shugoi
2
+ class ChallengeScript
3
+ def self.render(difficulty)
4
+ <<~JS
5
+ (function(){
6
+ var P=new URLSearchParams(location.search);
7
+ var salt=P.get('salt')||'', ts=P.get('ts')||'', nonce=P.get('nonce')||'', diff=parseInt(P.get('diff')||'#{difficulty}',10), path=P.get('path')||'/';
8
+ if(path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf('\\\\')>=0)path='/';
9
+ var enc=new TextEncoder();
10
+ 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}
11
+ var n=0;
12
+ function step(){
13
+ crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){
14
+ var h=Array.from(new Uint8Array(buf)).map(function(v){return v.toString(16).padStart(2,'0')}).join('');
15
+ if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+nonce+':'+n.toString(16);location.replace(base+q)}
16
+ else{n++;if(n<300000)step()}
17
+ }).catch(function(){location.reload()});
18
+ }
19
+ step();
20
+ })();
21
+ JS
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ module Shugoi
2
+ class ChallengeUrl
3
+ def self.build(secret:, difficulty:, context:)
4
+ timestamp = Utils.now_sec
5
+ nonce = SecureRandom.hex(8)
6
+ salt = Utils.hmac_hex(secret, "#{timestamp}:#{nonce}")
7
+ prefix = context[:forwarded_prefix].to_s
8
+ prefix = "" if prefix == "/"
9
+ prefix = prefix.sub(%r{/\z}, "") unless prefix.empty?
10
+ path = ChallengePath.sanitize(context[:path].to_s)
11
+ query = "ts=#{timestamp}&salt=#{salt}&nonce=#{nonce}&diff=#{difficulty}"
12
+ "#{prefix}/__sg_challenge?#{query}&path=#{URI.encode_www_form_component(prefix + path)}"
13
+ end
14
+ end
15
+ end
data/lib/shugoi/config.rb CHANGED
@@ -1,7 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
- # Options du middleware — parité avec ShugoiCoreOptions (module Node).
5
2
  class Config
6
3
  DEFAULTS = {
7
4
  site_key: nil,
@@ -23,7 +20,6 @@ module Shugoi
23
20
  split_render: true,
24
21
  multi_process: false,
25
22
  verify_bots: true,
26
- # Parité module Node : difficulté PoW 14 par défaut (configurable), TTL 60 s.
27
23
  pow_difficulty: 14,
28
24
  pow_ttl_ms: 60_000
29
25
  }.freeze
@@ -57,15 +53,15 @@ module Shugoi
57
53
  def pow_difficulty = @options[:pow_difficulty]
58
54
  def pow_ttl_ms = @options[:pow_ttl_ms]
59
55
 
60
- def is_allowlisted?(path)
56
+ def allowlisted?(path)
61
57
  allowlist.any? { |p| path == p || path.start_with?("#{p}/") }
62
58
  end
63
59
 
64
- def is_whitelisted_bot?(ua)
60
+ def whitelisted_bot?(ua)
65
61
  bot_whitelist.any? { |p| p.match?(ua) }
66
62
  end
67
63
 
68
- def is_headless?(ua)
64
+ def headless?(ua)
69
65
  headless_patterns.any? { |p| p.match?(ua) }
70
66
  end
71
67
 
@@ -1,8 +1,4 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
- # Cache mémoire de la config (whitelist + flags + skipPaths).
5
- # Parité avec _configCache (render.ts) : TTL 30s + stale-refresh.
6
2
  class ConfigCache
7
3
  TTL_MS = 30_000
8
4
  STALE_MAX_MS = 600_000
@@ -15,16 +11,29 @@ module Shugoi
15
11
  @flags = {}
16
12
  @skip_paths = []
17
13
  @fetched_at = 0
14
+ @refreshing = false
18
15
  end
19
16
 
20
17
  def fetch(site_key)
18
+ now = Utils.now_ms
19
+ refresh_now = false
20
+ refresh_async = false
21
+
21
22
  @mutex.synchronize do
22
- if @fetched_at.zero? || (Utils.now_ms - @fetched_at > STALE_MAX_MS)
23
- refresh(site_key)
24
- elsif Utils.now_ms - @fetched_at > TTL_MS
25
- Thread.new { refresh(site_key) }.abort_on_exception = false
26
- end
27
- { whitelist: @whitelist, flags: @flags, skip_paths: @skip_paths }
23
+ age = now - @fetched_at
24
+ refresh_now = @fetched_at.zero? || age > STALE_MAX_MS
25
+ refresh_async = !refresh_now && age > TTL_MS && !@refreshing
26
+ @refreshing = true if refresh_now || refresh_async
27
+ end
28
+
29
+ if refresh_now
30
+ refresh(site_key)
31
+ elsif refresh_async
32
+ Thread.new { refresh(site_key) }.abort_on_exception = false
33
+ end
34
+
35
+ @mutex.synchronize do
36
+ { whitelist: @whitelist.dup, flags: @flags.dup, skip_paths: @skip_paths.dup }
28
37
  end
29
38
  end
30
39
 
@@ -32,10 +41,16 @@ module Shugoi
32
41
 
33
42
  def refresh(site_key)
34
43
  data = @api_client.fetch_whitelist(site_key, @signing_secret)
35
- @whitelist = data[:whitelist]
36
- @flags = data[:flags]
37
- @skip_paths = data[:skip_paths]
38
- @fetched_at = Utils.now_ms
44
+ @mutex.synchronize do
45
+ @whitelist = data[:whitelist]
46
+ @flags = data[:flags]
47
+ @skip_paths = data[:skip_paths]
48
+ @fetched_at = Utils.now_ms
49
+ @refreshing = false
50
+ end
51
+ rescue StandardError
52
+ @mutex.synchronize { @refreshing = false }
53
+ raise
39
54
  end
40
55
  end
41
56
  end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Shugoi
4
2
  BLOCK_PAGE = [
5
3
  "+---------------------------------------------+",
@@ -13,10 +11,10 @@ module Shugoi
13
11
  "| |",
14
12
  "| - web: https://shugoi.com - |",
15
13
  "+---------------------------------------------+"
16
- ].join("\n") + "\n"
14
+ ].join("\n") << "\n"
17
15
 
18
16
  DEFAULT_HEADLESS_PATTERNS = [
19
- /^curl/i, /^wget/i, /^python/i, /^Go-http-client/i, /^Java\//,
17
+ /^curl/i, /^wget/i, /^python/i, /^Go-http-client/i, %r{^Java/},
20
18
  /HTTPie/i, /^node-fetch/i, /axios/i, /^okhttp/i, /^scrapy/i,
21
19
  /PowerShell/i, /WinHttp/i
22
20
  ].freeze