shugoi 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +70 -0
- data/lib/shugoi/api_client.rb +96 -0
- data/lib/shugoi/config.rb +77 -0
- data/lib/shugoi/config_cache.rb +40 -0
- data/lib/shugoi/constants.rb +29 -0
- data/lib/shugoi/core.rb +88 -0
- data/lib/shugoi/errors.rb +7 -0
- data/lib/shugoi/guard_cache.rb +33 -0
- data/lib/shugoi/html_store.rb +81 -0
- data/lib/shugoi/pow.rb +41 -0
- data/lib/shugoi/rack/middleware.rb +149 -0
- data/lib/shugoi/rails/configuration.rb +64 -0
- data/lib/shugoi/rails/railtie.rb +19 -0
- data/lib/shugoi/render_handler.rb +56 -0
- data/lib/shugoi/skeleton_generator.rb +87 -0
- data/lib/shugoi/token_signer.rb +64 -0
- data/lib/shugoi/utils.rb +74 -0
- data/lib/shugoi/version.rb +5 -0
- data/lib/shugoi.rb +35 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c7493ead28b354d7ba84019aaddae0419bf0481c4c12ac492218be2eed29d1fd
|
|
4
|
+
data.tar.gz: ea6965023bf27f8a66a039c794ab8137307b4662b77615ea1622f6b5d7548ee4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 59e8d7d5443fe2f6417753959365fc31650bc984c438905be06714cf491ab05f32c2fbc4b02cca82a83dabdfab32636bc976a3847781299f55c9eccb45d73df1
|
|
7
|
+
data.tar.gz: a8caed7dff122fe4da8909e19701447140f543659b0721b3325c869643657a667943159157a13bfc8a8cc3fdbbdb5fd33ba890e7f12bbe541aced4c3c1499712
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shugoi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Shugoi Ruby (Rails)
|
|
2
|
+
|
|
3
|
+
Protection anti-abus Shugoi pour Ruby on Rails — **parité complète avec le module Node `shugoi`**.
|
|
4
|
+
|
|
5
|
+
Bloque les bots, scrapers et clients headless avec :
|
|
6
|
+
- **Pre-flight PoW challenge** (307 + tableau ASCII pour curl/view-source, JS de résolution inline pour les navigateurs)
|
|
7
|
+
- **Machine fingerprinting** (guard-detect) + whitelist
|
|
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
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "shugoi"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configuration (Rails)
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# config/initializers/shugoi.rb
|
|
22
|
+
Shugoi.configure do |config|
|
|
23
|
+
config.site_key = "sg_sk_live_…"
|
|
24
|
+
config.secret = "…" # secret du site (signature des tokens)
|
|
25
|
+
config.debug = Rails.env.development?
|
|
26
|
+
# config.base_url = "https://shugoi.com/api/v1"
|
|
27
|
+
# config.allowlist = ["/api", "/legal"]
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Le railtie monte automatiquement le middleware sur l'app Rails.
|
|
32
|
+
|
|
33
|
+
## Usage Rack pur
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# config.ru
|
|
37
|
+
require "shugoi"
|
|
38
|
+
use Shugoi, site_key: "sg_sk_live_…", signing_secret: "…"
|
|
39
|
+
run MyApp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Option | Défaut | Description |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `site_key` | — | Votre siteKey Shugoi (requis) |
|
|
47
|
+
| `secret` | — | Secret du site |
|
|
48
|
+
| `signing_secret` | `secret` | Secret HMAC de signature |
|
|
49
|
+
| `base_url` | `https://shugoi.com/api/v1` | API Shugoi |
|
|
50
|
+
| `allowlist` | `["/api", "/legal"]` | Chemins sans protection |
|
|
51
|
+
| `auto_inject` | `true` | Injecte les guards dans le HTML |
|
|
52
|
+
| `split_render` | `true` | Skeleton + render |
|
|
53
|
+
| `restricted_access` | `false` | Page d'accès restreint |
|
|
54
|
+
| `pow_difficulty` | `10` | Bits du proof-of-work |
|
|
55
|
+
| `debug` | `false` | Logs console |
|
|
56
|
+
|
|
57
|
+
## Tests
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bundle install
|
|
61
|
+
bundle exec rspec
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Parité avec le module Node
|
|
65
|
+
|
|
66
|
+
Le flux réplique exactement `shugoi` 0.3.6 (npm) :
|
|
67
|
+
- `core.evaluate` → challenge 307 / `/__sg_challenge` / blocage headless
|
|
68
|
+
- `injectGuardScripts` → token signé + skeleton unicode
|
|
69
|
+
- `renderResponseData` → vérification grant + token, service du HTML stocké
|
|
70
|
+
- `verifyRenderGrant` → format `base36(ts):HMAC(secret, "render-grant:siteKey:mid:token:ip:ts")`
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Shugoi
|
|
8
|
+
# Client HTTP vers l'API Shugoi (parité avec fetch dans le module Node).
|
|
9
|
+
class ApiClient
|
|
10
|
+
TIMEOUT = 5.0
|
|
11
|
+
|
|
12
|
+
def initialize(base_url, debug: false)
|
|
13
|
+
@base_url = base_url.to_s
|
|
14
|
+
@debug = debug
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# GET /whitelist?key=... → { whitelistedMachines, detectionFlags, skipPaths }
|
|
18
|
+
def fetch_whitelist(site_key)
|
|
19
|
+
json = get("/whitelist", key: site_key)
|
|
20
|
+
{
|
|
21
|
+
whitelist: json["whitelistedMachines"] || [],
|
|
22
|
+
flags: json["detectionFlags"] || json["flags"] || {},
|
|
23
|
+
skip_paths: json["skipPaths"] || []
|
|
24
|
+
}
|
|
25
|
+
rescue StandardError
|
|
26
|
+
{ whitelist: [], flags: {}, skip_paths: [] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# GET /guard-detect?key=...&raw=1&cb=...&sig=... → texte du guard
|
|
30
|
+
def fetch_guard_detect(site_key, secret = nil)
|
|
31
|
+
cb = Utils.now_ms
|
|
32
|
+
query = { key: site_key, raw: 1, cb: cb.to_s }
|
|
33
|
+
query[:sig] = Utils.hmac_hex(secret, cb.to_s) if secret && !secret.empty?
|
|
34
|
+
body = get_raw("/guard-detect", query)
|
|
35
|
+
body
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# POST /event — enregistrement d'un événement (block, headless…)
|
|
41
|
+
def post_event(site_key, reason, machine_id = "")
|
|
42
|
+
payload = { siteKey: site_key, reason: reason, machineId: machine_id }
|
|
43
|
+
post_json("/event", payload)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# POST /validate-key — valide la clé
|
|
49
|
+
def validate_key(site_key, secret)
|
|
50
|
+
payload = { siteKey: site_key, secret: secret }
|
|
51
|
+
json = post_json("/validate-key", payload)
|
|
52
|
+
json
|
|
53
|
+
rescue StandardError
|
|
54
|
+
{ "valid" => false, "reason" => "network" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def get(path, params)
|
|
60
|
+
uri = build_uri(path, params)
|
|
61
|
+
log("GET #{uri}")
|
|
62
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
63
|
+
http.get(uri.request_uri, "User-Agent" => "shugoi-ruby/#{VERSION}")
|
|
64
|
+
end
|
|
65
|
+
JSON.parse(res.body)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def get_raw(path, params)
|
|
69
|
+
uri = build_uri(path, params)
|
|
70
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
71
|
+
http.get(uri.request_uri, "User-Agent" => "shugoi-ruby/#{VERSION}")
|
|
72
|
+
end
|
|
73
|
+
res.body.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def post_json(path, payload)
|
|
77
|
+
uri = build_uri(path)
|
|
78
|
+
req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "User-Agent" => "shugoi-ruby/#{VERSION}")
|
|
79
|
+
req.body = JSON.generate(payload)
|
|
80
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
81
|
+
http.request(req)
|
|
82
|
+
end
|
|
83
|
+
JSON.parse(res.body)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_uri(path, params = {})
|
|
87
|
+
uri = URI.join(@base_url, path)
|
|
88
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
89
|
+
uri
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def log(msg)
|
|
93
|
+
warn("[shugoi] #{msg}") if @debug
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Options du middleware — parité avec ShugoiCoreOptions (module Node).
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
site_key: nil,
|
|
8
|
+
secret: nil,
|
|
9
|
+
signing_secret: nil,
|
|
10
|
+
allowlist: ["/api", "/legal"],
|
|
11
|
+
headless_patterns: Shugoi::DEFAULT_HEADLESS_PATTERNS,
|
|
12
|
+
bot_whitelist: Shugoi::DEFAULT_BOT_WHITELIST,
|
|
13
|
+
base_url: "https://shugoi.com/api/v1",
|
|
14
|
+
internal_url: nil,
|
|
15
|
+
debug: false,
|
|
16
|
+
auto_inject: true,
|
|
17
|
+
restricted_access: false,
|
|
18
|
+
extra_directives: {},
|
|
19
|
+
csp: true,
|
|
20
|
+
block_status: 403,
|
|
21
|
+
locale: nil,
|
|
22
|
+
block_page: nil,
|
|
23
|
+
split_render: true,
|
|
24
|
+
multi_process: false,
|
|
25
|
+
verify_bots: true,
|
|
26
|
+
pow_difficulty: 10,
|
|
27
|
+
pow_ttl_ms: 120_000
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :options
|
|
31
|
+
|
|
32
|
+
def initialize(options = {})
|
|
33
|
+
@options = DEFAULTS.merge(symbolize(options))
|
|
34
|
+
raise ConfigError, "site_key is required" if @options[:site_key].nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def site_key = @options[:site_key]
|
|
38
|
+
def secret = @options[:secret]
|
|
39
|
+
def signing_secret = @options[:signing_secret] || @options[:secret]
|
|
40
|
+
def allowlist = @options[:allowlist]
|
|
41
|
+
def headless_patterns = @options[:headless_patterns]
|
|
42
|
+
def bot_whitelist = @options[:bot_whitelist]
|
|
43
|
+
def base_url = @options[:base_url]
|
|
44
|
+
def internal_url = @options[:internal_url] || @options[:base_url]
|
|
45
|
+
def debug = @options[:debug]
|
|
46
|
+
def auto_inject = @options[:auto_inject]
|
|
47
|
+
def restricted_access = @options[:restricted_access]
|
|
48
|
+
def extra_directives = @options[:extra_directives]
|
|
49
|
+
def csp_enabled = @options[:csp]
|
|
50
|
+
def block_status = @options[:block_status]
|
|
51
|
+
def locale = @options[:locale]
|
|
52
|
+
def block_page = @options[:block_page]
|
|
53
|
+
def split_render = @options[:split_render]
|
|
54
|
+
def multi_process = @options[:multi_process]
|
|
55
|
+
def verify_bots = @options[:verify_bots]
|
|
56
|
+
def pow_difficulty = @options[:pow_difficulty]
|
|
57
|
+
def pow_ttl_ms = @options[:pow_ttl_ms]
|
|
58
|
+
|
|
59
|
+
def is_allowlisted?(path)
|
|
60
|
+
allowlist.any? { |p| path == p || path.start_with?("#{p}/") }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def is_whitelisted_bot?(ua)
|
|
64
|
+
bot_whitelist.any? { |p| p.match?(ua) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def is_headless?(ua)
|
|
68
|
+
headless_patterns.any? { |p| p.match?(ua) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def symbolize(h)
|
|
74
|
+
h.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Cache mémoire de la config (whitelist + flags + skipPaths).
|
|
5
|
+
# Parité avec _configCache (render.ts) : TTL 30s + stale-refresh.
|
|
6
|
+
class ConfigCache
|
|
7
|
+
TTL_MS = 30_000
|
|
8
|
+
STALE_MAX_MS = 600_000
|
|
9
|
+
|
|
10
|
+
def initialize(api_client)
|
|
11
|
+
@api_client = api_client
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@whitelist = []
|
|
14
|
+
@flags = {}
|
|
15
|
+
@skip_paths = []
|
|
16
|
+
@fetched_at = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(site_key)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
if @fetched_at.zero? || (Utils.now_ms - @fetched_at > STALE_MAX_MS)
|
|
22
|
+
refresh(site_key)
|
|
23
|
+
elsif Utils.now_ms - @fetched_at > TTL_MS
|
|
24
|
+
Thread.new { refresh(site_key) }.abort_on_exception = false
|
|
25
|
+
end
|
|
26
|
+
{ whitelist: @whitelist, flags: @flags, skip_paths: @skip_paths }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def refresh(site_key)
|
|
33
|
+
data = @api_client.fetch_whitelist(site_key)
|
|
34
|
+
@whitelist = data[:whitelist]
|
|
35
|
+
@flags = data[:flags]
|
|
36
|
+
@skip_paths = data[:skip_paths]
|
|
37
|
+
@fetched_at = Utils.now_ms
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
BLOCK_PAGE = [
|
|
5
|
+
"+---------------------------------------------+",
|
|
6
|
+
"| BLOCKED BY SHUGOI |",
|
|
7
|
+
"+---------------------------------------------+",
|
|
8
|
+
"| Bots, scrapers and headless clients |",
|
|
9
|
+
"| are blocked by Shugoi protection. |",
|
|
10
|
+
"| |",
|
|
11
|
+
"| Use a standard browser to access |",
|
|
12
|
+
"| this site. |",
|
|
13
|
+
"| |",
|
|
14
|
+
"| - web: https://shugoi.com - |",
|
|
15
|
+
"+---------------------------------------------+"
|
|
16
|
+
].join("\n") + "\n"
|
|
17
|
+
|
|
18
|
+
DEFAULT_HEADLESS_PATTERNS = [
|
|
19
|
+
/^curl/i, /^wget/i, /^python/i, /^Go-http-client/i, /^Java\//,
|
|
20
|
+
/HTTPie/i, /^node-fetch/i, /axios/i, /^okhttp/i, /^scrapy/i,
|
|
21
|
+
/PowerShell/i, /WinHttp/i
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
DEFAULT_BOT_WHITELIST = [
|
|
25
|
+
/Googlebot/i, /Bingbot/i, /Slurp/i, /DuckDuckBot/i, /YandexBot/i, /Applebot/i,
|
|
26
|
+
/facebookexternalhit/i, /Twitterbot/i, /LinkedInBot/i, /Discordbot/i, /Slackbot/i,
|
|
27
|
+
/WhatsApp/i, /TelegramBot/i
|
|
28
|
+
].freeze
|
|
29
|
+
end
|
data/lib/shugoi/core.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Évaluation d'une requête : pre-flight PoW challenge, /__sg_challenge, blocage headless.
|
|
5
|
+
# Parité avec core.ts (module Node).
|
|
6
|
+
class Core
|
|
7
|
+
# Résultat de blocage renvoyé au middleware.
|
|
8
|
+
Decision = Struct.new(:status, :content_type, :body, :headers, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(config, pow, api_client)
|
|
11
|
+
@config = config
|
|
12
|
+
@pow = pow
|
|
13
|
+
@api_client = api_client
|
|
14
|
+
@validation_valid = false
|
|
15
|
+
@validation_failed = false
|
|
16
|
+
@validation_warned_at = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param ctx [Hash] { path:, ua:, ip:, host:, accept_language:, sec_fetch_dest:, sec_fetch_mode:, sg_proof:, forwarded_prefix: }
|
|
20
|
+
# @return [Decision, nil] nil = laisser passer
|
|
21
|
+
def evaluate(ctx)
|
|
22
|
+
path = ctx[:path].to_s
|
|
23
|
+
return nil if @config.is_allowlisted?(path)
|
|
24
|
+
|
|
25
|
+
# Route du challenge JS (le navigateur arrive ici après le 307).
|
|
26
|
+
if path == "/__sg_challenge"
|
|
27
|
+
return challenge_page
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Pre-flight PoW challenge (anti-curl/view-source).
|
|
31
|
+
return nil if path.include?("/__shugoi/") || path.start_with?("/api/")
|
|
32
|
+
ua = ctx[:ua].to_s
|
|
33
|
+
|
|
34
|
+
if ua.match?(/Mozilla/i) && !@config.signing_secret.to_s.empty?
|
|
35
|
+
proof = ctx[:sg_proof].to_s
|
|
36
|
+
unless @pow.valid?(proof)
|
|
37
|
+
return pow_challenge_307(ctx)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Blocage headless (UA).
|
|
42
|
+
if ua.match?(/Mozilla/i) == false && @config.is_headless?(ua)
|
|
43
|
+
@api_client.post_event(@config.site_key, "headless", "")
|
|
44
|
+
return Decision.new(status: @config.block_status, content_type: "text/plain", body: BLOCK_PAGE, headers: {})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Page challenge (tableau en commentaire + JS PoW inline).
|
|
51
|
+
def challenge_page
|
|
52
|
+
js = <<~JS
|
|
53
|
+
(function(){
|
|
54
|
+
var P=new URLSearchParams(location.search);
|
|
55
|
+
var salt=P.get('salt')||'', ts=P.get('ts')||'', diff=parseInt(P.get('diff')||'10',10), path=P.get('path')||'/';
|
|
56
|
+
var enc=new TextEncoder();
|
|
57
|
+
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}var s=b.toString(2),z=0;while(z<s.length&&s[z]==='0')z++;l+=z;break}return l}
|
|
58
|
+
var n=0;
|
|
59
|
+
function step(){
|
|
60
|
+
crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){
|
|
61
|
+
var h=Array.from(new Uint8Array(buf)).map(function(v){return v.toString(16).padStart(2,'0')}).join('');
|
|
62
|
+
if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+n.toString(16);location.replace(base+q)}
|
|
63
|
+
else{n++;if(n<300000)step()}
|
|
64
|
+
}).catch(function(){location.reload()});
|
|
65
|
+
}
|
|
66
|
+
step();
|
|
67
|
+
})();
|
|
68
|
+
JS
|
|
69
|
+
html = "<!--\n#{BLOCK_PAGE}-->\n<script>#{js}</script>"
|
|
70
|
+
Decision.new(status: 200, content_type: "text/html", body: html, headers: {})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# 307 vers le challenge : body = tableau ASCII seul (curl le voit tel quel).
|
|
76
|
+
def pow_challenge_307(ctx)
|
|
77
|
+
ts = Utils.now_sec
|
|
78
|
+
salt = Utils.hmac_hex(@config.signing_secret, ts.to_s)
|
|
79
|
+
prefix = ctx[:forwarded_prefix].to_s
|
|
80
|
+
prefix = "" if prefix == "/"
|
|
81
|
+
prefix = prefix.sub(%r{/\z}, "") unless prefix.empty?
|
|
82
|
+
path = ctx[:path].to_s
|
|
83
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
84
|
+
chal_url = "#{prefix}/__sg_challenge?ts=#{ts}&salt=#{salt}&diff=#{@config.pow_difficulty}&path=#{URI.encode_www_form_component(prefix + path)}"
|
|
85
|
+
Decision.new(status: 307, content_type: "text/plain", body: BLOCK_PAGE, headers: { "location" => chal_url })
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Shugoi
|
|
6
|
+
# Cache mémoire des guards (guard-detect) fetchés depuis l'API.
|
|
7
|
+
# Parité avec _guardCaches (render.ts).
|
|
8
|
+
class GuardCache
|
|
9
|
+
TTL_MS = 300_000
|
|
10
|
+
|
|
11
|
+
def initialize(api_client)
|
|
12
|
+
@api_client = api_client
|
|
13
|
+
@detect = nil
|
|
14
|
+
@fetched_at = 0
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def detect
|
|
19
|
+
@detect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ensure_ready(site_key, secret = nil)
|
|
23
|
+
return if @detect && Utils.now_ms - @fetched_at < TTL_MS
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
return if @detect && Utils.now_ms - @fetched_at < TTL_MS
|
|
27
|
+
code = @api_client.fetch_guard_detect(site_key, secret)
|
|
28
|
+
@detect = code && !code.empty? ? code : 'console.error("Shugoi guard-detect unavailable")'
|
|
29
|
+
@fetched_at = Utils.now_ms
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Stockage mémoire (et disque optionnel) du HTML rendu, lié au token.
|
|
5
|
+
# Parité avec _memoryStore/_siteCache (render.ts).
|
|
6
|
+
class HtmlStore
|
|
7
|
+
TOKEN_TTL_MS = 120_000
|
|
8
|
+
MAX_ENTRIES = 5000
|
|
9
|
+
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
|
10
|
+
MAX_TOKEN_READS = 1
|
|
11
|
+
|
|
12
|
+
Entry = Struct.new(:html, :expires_at, :reads)
|
|
13
|
+
|
|
14
|
+
def initialize(disk_path: nil)
|
|
15
|
+
@entries = {}
|
|
16
|
+
@site_cache = {}
|
|
17
|
+
@total_bytes = 0
|
|
18
|
+
@disk_path = disk_path
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def store(token, html, site_key)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
evict_expired
|
|
25
|
+
size = html.bytesize
|
|
26
|
+
while (@entries.size >= MAX_ENTRIES || @total_bytes + size > MAX_TOTAL_BYTES) && !@entries.empty?
|
|
27
|
+
drop(@entries.keys.first)
|
|
28
|
+
end
|
|
29
|
+
@entries[token] = Entry.new(html, Utils.now_ms + TOKEN_TTL_MS, 0)
|
|
30
|
+
@site_cache[site_key] = html
|
|
31
|
+
@total_bytes += size
|
|
32
|
+
write_disk(token, html)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] html si présent et lisible
|
|
37
|
+
def read(token)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
entry = @entries[token]
|
|
40
|
+
return nil if entry.nil?
|
|
41
|
+
return drop_and_nil(token) if Utils.now_ms > entry.expires_at
|
|
42
|
+
|
|
43
|
+
entry.reads += 1
|
|
44
|
+
entry.html
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def site_html(site_key)
|
|
49
|
+
@site_cache[site_key]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def drop(token)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
entry = @entries.delete(token)
|
|
55
|
+
@total_bytes -= entry.html.bytesize if entry
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def evict_expired
|
|
62
|
+
@entries.each_key do |token|
|
|
63
|
+
entry = @entries[token]
|
|
64
|
+
drop(token) if Utils.now_ms > entry.expires_at
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def drop_and_nil(token)
|
|
69
|
+
drop(token)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write_disk(token, html)
|
|
74
|
+
return unless @disk_path
|
|
75
|
+
path = File.join(@disk_path, Utils.sha256_hex(token))
|
|
76
|
+
File.write(path, html, mode: "w")
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/shugoi/pow.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Proof-of-work anti-curl.
|
|
5
|
+
# Parité avec core.ts (module Node) :
|
|
6
|
+
# salt = HMAC(secret, ts)
|
|
7
|
+
# proof = "ts:nonce" où SHA256(salt:nonce) a >= POW_DIFFICULTY bits à zéro en tête.
|
|
8
|
+
class Pow
|
|
9
|
+
def initialize(signing_secret, difficulty = 10, ttl_ms = 120_000)
|
|
10
|
+
@secret = signing_secret.to_s
|
|
11
|
+
@difficulty = difficulty
|
|
12
|
+
@ttl_ms = ttl_ms
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Génère le challenge à injecter (window.__sg_pow).
|
|
16
|
+
def challenge
|
|
17
|
+
ts = Utils.now_sec
|
|
18
|
+
{ ts: ts, salt: salt(ts), difficulty: @difficulty }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Vérifie un proof "ts:nonce".
|
|
22
|
+
def valid?(proof)
|
|
23
|
+
return false if proof.to_s.empty? || @secret.empty?
|
|
24
|
+
ts_str, solution = proof.to_s.split(":", 2)
|
|
25
|
+
return false if ts_str.nil? || solution.nil?
|
|
26
|
+
|
|
27
|
+
ts = ts_str.to_i
|
|
28
|
+
return false if ts.zero?
|
|
29
|
+
return false if (Utils.now_ms - ts * 1000).abs > @ttl_ms
|
|
30
|
+
|
|
31
|
+
digest = Utils.sha256_hex("#{salt(ts_str)}:#{solution}")
|
|
32
|
+
Utils.leading_zero_bits(digest) >= @difficulty
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def salt(ts)
|
|
38
|
+
Utils.hmac_hex(@secret, ts.to_s)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Shugoi
|
|
7
|
+
module Rack
|
|
8
|
+
# Middleware Rack principal — parité avec createShugoiMiddleware (module Node).
|
|
9
|
+
#
|
|
10
|
+
# use Shugoi::Rack::Middleware, site_key: "sg_sk_live_…", secret: "…"
|
|
11
|
+
#
|
|
12
|
+
# Ou via la gem :
|
|
13
|
+
# use Shugoi, site_key: "…"
|
|
14
|
+
class Middleware
|
|
15
|
+
def initialize(app, options = {})
|
|
16
|
+
@app = app
|
|
17
|
+
@config = Config.new(options)
|
|
18
|
+
@api_client = ApiClient.new(@config.base_url, debug: @config.debug)
|
|
19
|
+
@guard_cache = GuardCache.new(@api_client)
|
|
20
|
+
@config_cache = ConfigCache.new(@api_client)
|
|
21
|
+
@token_signer = TokenSigner.new(@config.signing_secret)
|
|
22
|
+
@html_store = HtmlStore.new(disk_path: options[:disk_path])
|
|
23
|
+
@pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms)
|
|
24
|
+
@skeleton = SkeletonGenerator.new(@config, @guard_cache, @config_cache, @token_signer)
|
|
25
|
+
@render = RenderHandler.new(@config, @token_signer, @html_store, @config_cache)
|
|
26
|
+
@core = Core.new(@config, @pow, @api_client)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(env)
|
|
30
|
+
path = env["PATH_INFO"].to_s
|
|
31
|
+
query = parse_query(env["QUERY_STRING"].to_s)
|
|
32
|
+
|
|
33
|
+
# Render endpoint.
|
|
34
|
+
if path.end_with?("/__shugoi/render")
|
|
35
|
+
return handle_render(env, query)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# CSP.
|
|
39
|
+
headers = {}
|
|
40
|
+
headers["content-security-policy"] = csp_header if @config.csp_enabled
|
|
41
|
+
|
|
42
|
+
ctx = build_ctx(env, query)
|
|
43
|
+
decision = @core.evaluate(ctx)
|
|
44
|
+
|
|
45
|
+
if decision
|
|
46
|
+
h = headers.merge(decision.headers || {})
|
|
47
|
+
h["content-type"] = decision.content_type
|
|
48
|
+
return [decision.status, h, [decision.body]]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# SkipPaths (SSR direct) : on laisse l'app servir la page sans challenge ni skeleton.
|
|
52
|
+
if @config.auto_inject && @config.site_key
|
|
53
|
+
cfg = @config_cache.fetch(@config.site_key)
|
|
54
|
+
if cfg[:skip_paths].include?(path)
|
|
55
|
+
return @app.call(env)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
status, resp_headers, body = @app.call(env)
|
|
60
|
+
|
|
61
|
+
# Rack 3 exige des noms de headers en minuscules → on normalise.
|
|
62
|
+
resp_headers = resp_headers.each_with_object({}) { |(k, v), acc| acc[k.to_s.downcase] = v }
|
|
63
|
+
|
|
64
|
+
# 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)
|
|
66
|
+
|
|
67
|
+
html = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
|
|
68
|
+
ct = resp_headers["content-type"].to_s
|
|
69
|
+
if html.include?("<html") && (ct.include?("text/html") || ct.empty?)
|
|
70
|
+
begin
|
|
71
|
+
skeleton = inject_guards(html, ctx)
|
|
72
|
+
body = [skeleton]
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
warn("[shugoi] inject error: #{e.message}") if @config.debug
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
[status, resp_headers, body]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def build_ctx(env, query)
|
|
84
|
+
ua = env["HTTP_USER_AGENT"].to_s
|
|
85
|
+
ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
|
|
86
|
+
ip = env["REMOTE_ADDR"].to_s if ip.empty?
|
|
87
|
+
{
|
|
88
|
+
path: env["PATH_INFO"].to_s,
|
|
89
|
+
ua: ua,
|
|
90
|
+
ip: ip,
|
|
91
|
+
host: env["HTTP_HOST"].to_s,
|
|
92
|
+
accept_language: env["HTTP_ACCEPT_LANGUAGE"],
|
|
93
|
+
sec_fetch_dest: env["HTTP_SEC_FETCH_DEST"],
|
|
94
|
+
sec_fetch_mode: env["HTTP_SEC_FETCH_MODE"],
|
|
95
|
+
sg_proof: query["sg_proof"],
|
|
96
|
+
forwarded_prefix: env["HTTP_X_FORWARDED_PREFIX"]
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_render(env, query)
|
|
101
|
+
token = query["token"].to_s
|
|
102
|
+
mid = query["mid"].to_s
|
|
103
|
+
grant = query["grant"].to_s
|
|
104
|
+
ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
|
|
105
|
+
ip = env["REMOTE_ADDR"].to_s if ip.empty?
|
|
106
|
+
data = @render.render_data(token, mid, grant, ip)
|
|
107
|
+
body = JSON.generate(data)
|
|
108
|
+
[200, { "content-type" => "application/json", "cache-control" => "no-store" }, [body]]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def inject_guards(html, ctx)
|
|
112
|
+
ts = Utils.now_ms
|
|
113
|
+
signed = @token_signer.sign(@config.site_key, ts)
|
|
114
|
+
render_url = "./__shugoi/render"
|
|
115
|
+
|
|
116
|
+
# Injecte window.__sg_disableRestrictedAccess si nécessaire.
|
|
117
|
+
config_script = ""
|
|
118
|
+
unless @config.restricted_access
|
|
119
|
+
config_script = "<script>window.__sg_disableRestrictedAccess=true</script>"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
injected = html
|
|
123
|
+
if (i = injected.index("</head>"))
|
|
124
|
+
injected = injected[0...i] + config_script + injected[i..]
|
|
125
|
+
elsif (m = injected.match(/<body[^>]*>/))
|
|
126
|
+
at = injected.index(m[0]) + m[0].length
|
|
127
|
+
injected = injected[0...at] + config_script + injected[at..]
|
|
128
|
+
else
|
|
129
|
+
injected = config_script + injected
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
@html_store.store(signed, injected, @config.site_key)
|
|
133
|
+
@skeleton.generate(@config.site_key, signed, @config.base_url, render_url)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def csp_header
|
|
137
|
+
base = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://shugoi.com; " \
|
|
138
|
+
"connect-src 'self' https://shugoi.com; style-src 'self' 'unsafe-inline' https://shugoi.com; " \
|
|
139
|
+
"font-src 'self' https://shugoi.com data:; img-src 'self' https://shugoi.com data: blob:; " \
|
|
140
|
+
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
|
|
141
|
+
base
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse_query(qs)
|
|
145
|
+
::Rack::Utils.parse_nested_query(qs)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
module Rails
|
|
5
|
+
# Configuration globale pour Rails (équivalent de ShugoiCoreOptions).
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :site_key, :secret, :signing_secret, :allowlist,
|
|
8
|
+
:headless_patterns, :bot_whitelist, :base_url, :internal_url,
|
|
9
|
+
:debug, :auto_inject, :restricted_access, :extra_directives,
|
|
10
|
+
:csp, :block_status, :locale, :block_page, :split_render,
|
|
11
|
+
:multi_process, :verify_bots, :pow_difficulty, :pow_ttl_ms
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@site_key = ENV["SHUGOI_SITE_KEY"]
|
|
15
|
+
@secret = ENV["SHUGOI_SECRET"]
|
|
16
|
+
@signing_secret = ENV["SHUGOI_SIGNING_SECRET"] || ENV["SHUGOKI_SIGNING_SECRET"]
|
|
17
|
+
@allowlist = ["/api", "/legal"]
|
|
18
|
+
@headless_patterns = Shugoi::DEFAULT_HEADLESS_PATTERNS
|
|
19
|
+
@bot_whitelist = Shugoi::DEFAULT_BOT_WHITELIST
|
|
20
|
+
@base_url = ENV["SHUGOI_BASE_URL"] || "https://shugoi.com/api/v1"
|
|
21
|
+
@internal_url = nil
|
|
22
|
+
@debug = false
|
|
23
|
+
@auto_inject = true
|
|
24
|
+
@restricted_access = false
|
|
25
|
+
@extra_directives = {}
|
|
26
|
+
@csp = true
|
|
27
|
+
@block_status = 403
|
|
28
|
+
@locale = nil
|
|
29
|
+
@block_page = nil
|
|
30
|
+
@split_render = true
|
|
31
|
+
@multi_process = false
|
|
32
|
+
@verify_bots = true
|
|
33
|
+
@pow_difficulty = 10
|
|
34
|
+
@pow_ttl_ms = 120_000
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_options
|
|
38
|
+
{
|
|
39
|
+
site_key: @site_key,
|
|
40
|
+
secret: @secret,
|
|
41
|
+
signing_secret: @signing_secret,
|
|
42
|
+
allowlist: @allowlist,
|
|
43
|
+
headless_patterns: @headless_patterns,
|
|
44
|
+
bot_whitelist: @bot_whitelist,
|
|
45
|
+
base_url: @base_url,
|
|
46
|
+
internal_url: @internal_url,
|
|
47
|
+
debug: @debug,
|
|
48
|
+
auto_inject: @auto_inject,
|
|
49
|
+
restricted_access: @restricted_access,
|
|
50
|
+
extra_directives: @extra_directives,
|
|
51
|
+
csp: @csp,
|
|
52
|
+
block_status: @block_status,
|
|
53
|
+
locale: @locale,
|
|
54
|
+
block_page: @block_page,
|
|
55
|
+
split_render: @split_render,
|
|
56
|
+
multi_process: @multi_process,
|
|
57
|
+
verify_bots: @verify_bots,
|
|
58
|
+
pow_difficulty: @pow_difficulty,
|
|
59
|
+
pow_ttl_ms: @pow_ttl_ms
|
|
60
|
+
}.reject { |_, v| v.nil? }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
module Rails
|
|
5
|
+
# Intégration Rails : monte le middleware et expose la configuration.
|
|
6
|
+
#
|
|
7
|
+
# # config/initializers/shugoi.rb
|
|
8
|
+
# Shugoi.configure do |config|
|
|
9
|
+
# config.site_key = "sg_sk_live_…"
|
|
10
|
+
# config.secret = "…"
|
|
11
|
+
# config.debug = Rails.env.development?
|
|
12
|
+
# end
|
|
13
|
+
class Railtie < ::Rails::Railtie
|
|
14
|
+
initializer "shugoi.middleware" do |app|
|
|
15
|
+
app.middleware.use Shugoi::Rack::Middleware, Shugoi.config.to_options
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Traite la requête /__shugoi/render : vérifie token + grant, sert le HTML stocké.
|
|
5
|
+
# Parité avec renderResponseData + handleRender (render.ts).
|
|
6
|
+
class RenderHandler
|
|
7
|
+
def initialize(config, token_signer, html_store, config_cache)
|
|
8
|
+
@config = config
|
|
9
|
+
@token_signer = token_signer
|
|
10
|
+
@html_store = html_store
|
|
11
|
+
@config_cache = config_cache
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param token [String] token render
|
|
15
|
+
# @param mid [String] machineId
|
|
16
|
+
# @param grant [String] render-grant
|
|
17
|
+
# @param ip [String]
|
|
18
|
+
# @return [Hash] { html: … } ou { error: "not_found" }
|
|
19
|
+
def render_data(token, mid, grant, ip)
|
|
20
|
+
return { error: "not_found" } if token.to_s.empty? || token.length < 16 || token.length > 300
|
|
21
|
+
|
|
22
|
+
# Le token doit appartenir à CE siteKey (CRITIQUE 1 §7bis).
|
|
23
|
+
tok_site_key = token.split(":")[0]
|
|
24
|
+
return { error: "not_found" } if tok_site_key != @config.site_key
|
|
25
|
+
|
|
26
|
+
# Expiration du token.
|
|
27
|
+
tok_ts = token.split(":")[1].to_i
|
|
28
|
+
return { error: "not_found" } if !tok_ts.zero? && Utils.now_ms - tok_ts > HtmlStore::TOKEN_TTL_MS
|
|
29
|
+
|
|
30
|
+
# Anti-bypass token-only : grant valide requis.
|
|
31
|
+
return { error: "not_found" } unless @token_signer.verify_render_grant(mid, grant, token, ip, @config.site_key)
|
|
32
|
+
|
|
33
|
+
content_replace_on = content_replace_flag?(token)
|
|
34
|
+
html = @html_store.read(token)
|
|
35
|
+
return { html: html } if html
|
|
36
|
+
|
|
37
|
+
# Fallback content-replace OFF : renvoie le HTML du site.
|
|
38
|
+
unless content_replace_on
|
|
39
|
+
site_html = @html_store.site_html(tok_site_key)
|
|
40
|
+
return { html: site_html } if site_html
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return { error: "not_found" } unless @token_signer.verify_token(token)
|
|
44
|
+
|
|
45
|
+
{ error: "not_found" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def content_replace_flag?(token)
|
|
51
|
+
site_key = token.split(":")[0]
|
|
52
|
+
flags = @config_cache.fetch(site_key)[:flags]
|
|
53
|
+
flags["enableContentReplacementCheck"] == true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Génère le skeleton HTML (bootcode unicode) injecté dans la page.
|
|
5
|
+
# Parité avec generateSkeleton (render.ts) — mêmes fragments JS, même encodage unicode.
|
|
6
|
+
class SkeletonGenerator
|
|
7
|
+
def initialize(config, guard_cache, config_cache, token_signer)
|
|
8
|
+
@config = config
|
|
9
|
+
@guard_cache = guard_cache
|
|
10
|
+
@config_cache = config_cache
|
|
11
|
+
@token_signer = token_signer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [String] HTML du skeleton (<script>…eval([...])…</script>)
|
|
15
|
+
def generate(site_key, token, base_url, render_url = "./__shugoi/render", locale = "en")
|
|
16
|
+
@guard_cache.ensure_ready(site_key, @config.signing_secret)
|
|
17
|
+
cfg_data = @config_cache.fetch(site_key)
|
|
18
|
+
flags = cfg_data[:flags]
|
|
19
|
+
detect = @guard_cache.detect
|
|
20
|
+
|
|
21
|
+
fragments = []
|
|
22
|
+
fragments << "window.__sg_siteKey=#{json(site_key)}"
|
|
23
|
+
fragments << "window.__sg_baseUrl=#{json(base_url)}"
|
|
24
|
+
fragments << "window.__sg_config=#{json(flags)}"
|
|
25
|
+
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
|
+
|
|
27
|
+
pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms).challenge
|
|
28
|
+
fragments << "window.__sg_pow=#{json(pow)}"
|
|
29
|
+
fragments << "window.__sg_serverTime=#{Utils.now_ms}"
|
|
30
|
+
fragments << "window.__sg_clockts=#{Utils.now_ms}"
|
|
31
|
+
fragments << "window.__sg_disableRestrictedAccess=true" unless @config.restricted_access
|
|
32
|
+
fragments << "try{#{detect}}catch(e){window.__sg_blocked=true}" if detect
|
|
33
|
+
|
|
34
|
+
# __sg_showBlock (page de blocage néobrutaliste)
|
|
35
|
+
fragments << show_block_fragment
|
|
36
|
+
|
|
37
|
+
fragments << "var t=\"#{token}\""
|
|
38
|
+
fragments << "window.__sg_token=\"#{token}\""
|
|
39
|
+
fragments << "var k=\"#{site_key}\""
|
|
40
|
+
fragments << "var b=\"#{base_url}\""
|
|
41
|
+
fragments << "var r=\"#{render_url}\""
|
|
42
|
+
|
|
43
|
+
# rd(p,n) : remplacement du document par le contenu rendu.
|
|
44
|
+
fragments << rd_fragment
|
|
45
|
+
|
|
46
|
+
fragments << "_gw(function(){rd(r+\"?token=\"+t,0);setTimeout(_sgCl,1500)})"
|
|
47
|
+
fragments << cleanup_fragment
|
|
48
|
+
|
|
49
|
+
combined = fragments.join(";")
|
|
50
|
+
enc = Utils.unicode_encode(combined)
|
|
51
|
+
decoded_call = "[...'#{enc}'].map(x=>String.fromCodePoint(x.codePointAt(0)-917504)).join('')"
|
|
52
|
+
"<script>eval(#{decoded_call})</script>"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def json(obj)
|
|
58
|
+
JSON.generate(obj)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def show_block_fragment
|
|
62
|
+
css = "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}"
|
|
63
|
+
"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}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rd_fragment
|
|
67
|
+
"var _gw=function(cb){if(window.__sg_guardsReady||window.__sg_blocked)cb();else setTimeout(function(){_gw(cb)},100)};" \
|
|
68
|
+
"function rd(p,n){if(window.__sg_blocked)return;if(!document.body)return setTimeout(function(){rd(p,n)},50);" \
|
|
69
|
+
"if(n>6){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"\",\"\",\"\");return}" \
|
|
70
|
+
"var _g=(window.__sg_grant||\"\");if(_g){p=p+(\"&grant=\"+encodeURIComponent(_g))}" \
|
|
71
|
+
"var _m=(window.__sg_detectMid||window.__sg_mid||\"\");if(_m){p=p+(\"&mid=\"+encodeURIComponent(_m))}" \
|
|
72
|
+
"fetch(p).then(function(x){return x.json()}).then(function(d){if(window.__sg_blocked)return;" \
|
|
73
|
+
"if(!document.body)return setTimeout(function(){rd(p,n+1)},50);" \
|
|
74
|
+
"if(d.html){document.open(\"text/html\");document.write(d.html);document.close();window.scrollTo(0,0)}" \
|
|
75
|
+
"if(d.blocked){window.__sg_showBlock&&window.__sg_showBlock(d.message,d.title)}" \
|
|
76
|
+
"if(d.error){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"\",\"\",\"\")}" \
|
|
77
|
+
"else if(!d.html&&!d.blocked){setTimeout(function(){rd(p,n+1)},300)}})" \
|
|
78
|
+
".catch(function(){setTimeout(function(){rd(p,n+1)},300)})}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cleanup_fragment
|
|
82
|
+
"function _sgCl(){try{for(var _i in window){if(_i.indexOf(\"__sg\")===0){window[_i]=null;delete window[_i]}}" \
|
|
83
|
+
"window._sgLogCP=function(){};window.midHex=function(){};window.rd=function(){};window._gw=function(){};" \
|
|
84
|
+
"window.applyDecision=function(){};window._D=function(){};window.z=function(f){return f()}}catch(_e){}}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shugoi
|
|
4
|
+
# Signature des tokens render + vérification des render-grants.
|
|
5
|
+
# Parité exacte avec le module Node :
|
|
6
|
+
# token : siteKey:timestamp:nonce:sig (sig = HMAC(secret, "siteKey:ts:nonce"))
|
|
7
|
+
# grant : base36(ts):HMAC(secret, "render-grant:siteKey:mid:token:ip:ts")
|
|
8
|
+
class TokenSigner
|
|
9
|
+
GRANT_TTL_MS = 120_000
|
|
10
|
+
|
|
11
|
+
def initialize(secret)
|
|
12
|
+
@secret = secret.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [String] token signé, ou "" si pas de secret
|
|
16
|
+
def sign(site_key, timestamp = Utils.now_ms)
|
|
17
|
+
return "" if @secret.empty?
|
|
18
|
+
nonce = SecureRandom.hex(8)
|
|
19
|
+
payload = [site_key, timestamp.to_i, nonce].join(":")
|
|
20
|
+
sig = Utils.hmac_hex(@secret, payload)
|
|
21
|
+
"#{payload}:#{sig}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Vérifie un render-grant émis par le wlc serveur.
|
|
25
|
+
# @param mid [String] machineId (64 hex)
|
|
26
|
+
# @param grant [String] grant brut "base36ts:sig"
|
|
27
|
+
# @param token [String] token render lié
|
|
28
|
+
# @param ip [String] IP du client
|
|
29
|
+
# @param expected_site_key [String] siteKey attendu (celui du middleware)
|
|
30
|
+
def verify_render_grant(mid, grant, token, ip, expected_site_key)
|
|
31
|
+
return true if @secret.empty? # fail-safe : pas de secret → pas de vérification
|
|
32
|
+
return false if grant.to_s.empty? || mid.to_s.empty?
|
|
33
|
+
return false unless mid.to_s.match?(/\A[a-f0-9]{64}\z/)
|
|
34
|
+
|
|
35
|
+
ts_str, sig = grant.to_s.split(":", 2)
|
|
36
|
+
return false if ts_str.nil? || sig.nil?
|
|
37
|
+
|
|
38
|
+
ts = Utils.base36_decode(ts_str)
|
|
39
|
+
return false if ts.zero? && ts_str != "0"
|
|
40
|
+
return false if Utils.now_ms - (ts * 1000) > GRANT_TTL_MS
|
|
41
|
+
return false if expected_site_key.to_s.empty?
|
|
42
|
+
|
|
43
|
+
payload = "render-grant:#{[expected_site_key, mid, token.to_s, ip.to_s, ts_str].join(':')}"
|
|
44
|
+
expected = Utils.hmac_hex(@secret, payload)
|
|
45
|
+
Utils.secure_equals(sig, expected)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Vérifie un token render complet (siteKey:ts:nonce:sig).
|
|
49
|
+
def verify_token(token)
|
|
50
|
+
parts = token.to_s.split(":")
|
|
51
|
+
return false unless parts.length == 4 && parts[3].length == 64
|
|
52
|
+
return false if @secret.empty?
|
|
53
|
+
|
|
54
|
+
site_key, timestamp, nonce, sig = parts
|
|
55
|
+
ts = timestamp.to_i
|
|
56
|
+
return false if ts.zero?
|
|
57
|
+
return false if Utils.now_ms - ts > 120_000 # TOKEN_TTL
|
|
58
|
+
|
|
59
|
+
payload = [site_key, timestamp, nonce].join(":")
|
|
60
|
+
expected = Utils.hmac_hex(@secret, payload)
|
|
61
|
+
Utils.secure_equals(sig, expected)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/shugoi/utils.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Shugoi
|
|
8
|
+
module Utils
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Comparaison constant-time de deux hex strings.
|
|
12
|
+
def secure_equals(a, b)
|
|
13
|
+
return false unless a.is_a?(String) && b.is_a?(String)
|
|
14
|
+
return false unless a.bytesize == b.bytesize
|
|
15
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def hmac_hex(secret, payload)
|
|
19
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, payload.to_s)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sha256_hex(str)
|
|
23
|
+
OpenSSL::Digest::SHA256.hexdigest(str.to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def base36_encode(num)
|
|
27
|
+
return "0" if num.zero?
|
|
28
|
+
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
29
|
+
out = +""
|
|
30
|
+
n = num
|
|
31
|
+
while n.positive?
|
|
32
|
+
n, r = n.divmod(36)
|
|
33
|
+
out.prepend(alphabet[r])
|
|
34
|
+
end
|
|
35
|
+
out
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def base36_decode(str)
|
|
39
|
+
str.to_s.to_i(36)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Nombre de bits à zéro en tête du digest hex (parité avec la fonction JS `bits`).
|
|
43
|
+
def leading_zero_bits(hex_digest)
|
|
44
|
+
leading = 0
|
|
45
|
+
hex_digest.each_char do |c|
|
|
46
|
+
nib = c.to_i(16)
|
|
47
|
+
if nib.zero?
|
|
48
|
+
leading += 4
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
leading += nib.to_s(2).match(/^0*/)[0].length
|
|
52
|
+
break
|
|
53
|
+
end
|
|
54
|
+
leading
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def now_ms
|
|
58
|
+
(Time.now.to_f * 1000).to_i
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def now_sec
|
|
62
|
+
Time.now.to_i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Encodage unicode (parité Node : chaque caractère décalé de +917504).
|
|
66
|
+
def unicode_encode(code)
|
|
67
|
+
code.each_codepoint.map { |cp| (917504 + cp).chr(Encoding::UTF_8) }.join
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def escape_json_string(s)
|
|
71
|
+
JSON.generate(s.to_s)[1..-2]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/shugoi.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shugoi/version"
|
|
4
|
+
require_relative "shugoi/errors"
|
|
5
|
+
require_relative "shugoi/constants"
|
|
6
|
+
require_relative "shugoi/config"
|
|
7
|
+
require_relative "shugoi/utils"
|
|
8
|
+
require_relative "shugoi/token_signer"
|
|
9
|
+
require_relative "shugoi/pow"
|
|
10
|
+
require_relative "shugoi/api_client"
|
|
11
|
+
require_relative "shugoi/guard_cache"
|
|
12
|
+
require_relative "shugoi/config_cache"
|
|
13
|
+
require_relative "shugoi/html_store"
|
|
14
|
+
require_relative "shugoi/skeleton_generator"
|
|
15
|
+
require_relative "shugoi/render_handler"
|
|
16
|
+
require_relative "shugoi/core"
|
|
17
|
+
require_relative "shugoi/rack/middleware"
|
|
18
|
+
require_relative "shugoi/rails/configuration"
|
|
19
|
+
require_relative "shugoi/rails/railtie" if defined?(::Rails::Railtie)
|
|
20
|
+
|
|
21
|
+
module Shugoi
|
|
22
|
+
def self.new(app, options = {})
|
|
23
|
+
Shugoi::Rack::Middleware.new(app, options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def config
|
|
28
|
+
@config ||= Shugoi::Rails::Configuration.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
yield(config)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shugoi
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shugoi
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '6.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: concurrent-ruby
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.1'
|
|
54
|
+
description: Rack middleware + Rails integration for Shugoi. Blocks bots, scrapers
|
|
55
|
+
and headless clients with a proof-of-work challenge, machine fingerprinting and
|
|
56
|
+
split-render, mirroring the Node.js module.
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/shugoi.rb
|
|
64
|
+
- lib/shugoi/api_client.rb
|
|
65
|
+
- lib/shugoi/config.rb
|
|
66
|
+
- lib/shugoi/config_cache.rb
|
|
67
|
+
- lib/shugoi/constants.rb
|
|
68
|
+
- lib/shugoi/core.rb
|
|
69
|
+
- lib/shugoi/errors.rb
|
|
70
|
+
- lib/shugoi/guard_cache.rb
|
|
71
|
+
- lib/shugoi/html_store.rb
|
|
72
|
+
- lib/shugoi/pow.rb
|
|
73
|
+
- lib/shugoi/rack/middleware.rb
|
|
74
|
+
- lib/shugoi/rails/configuration.rb
|
|
75
|
+
- lib/shugoi/rails/railtie.rb
|
|
76
|
+
- lib/shugoi/render_handler.rb
|
|
77
|
+
- lib/shugoi/skeleton_generator.rb
|
|
78
|
+
- lib/shugoi/token_signer.rb
|
|
79
|
+
- lib/shugoi/utils.rb
|
|
80
|
+
- lib/shugoi/version.rb
|
|
81
|
+
homepage: https://shugoi.com
|
|
82
|
+
licenses:
|
|
83
|
+
- MIT
|
|
84
|
+
metadata:
|
|
85
|
+
homepage_uri: https://shugoi.com
|
|
86
|
+
source_code_uri: https://github.com/RoxasYTB/shugoi-npm
|
|
87
|
+
rdoc_options: []
|
|
88
|
+
require_paths:
|
|
89
|
+
- lib
|
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '3.0'
|
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0'
|
|
100
|
+
requirements: []
|
|
101
|
+
rubygems_version: 3.6.7
|
|
102
|
+
specification_version: 4
|
|
103
|
+
summary: Shugoi anti-abuse protection for Ruby on Rails
|
|
104
|
+
test_files: []
|