agentry 3.1.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- data/README.md +13 -1
- data/lib/browser/accept_language.rb +1 -0
- data/lib/browser/base.rb +3 -3
- data/lib/browser/bot.rb +1 -0
- data/lib/browser/browser.rb +4 -0
- data/lib/browser/cache.rb +11 -5
- data/lib/browser/client_hints.rb +2 -2
- data/lib/browser/cloudflare.rb +2 -2
- data/lib/browser/detect_version.rb +26 -1
- data/lib/browser/middleware.rb +1 -1
- data/lib/browser/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 712d74b9f09a7f5755ade4414301f82b2ee08445c8af1eca147fa8039ead5b92
|
|
4
|
+
data.tar.gz: bca61032c6cceef348582436594532a5144d71f2483300b151fa4dd053bd95d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c48381ca8074cef8ebe58d2a27aab5e5a6441b590525d3ee147bd38f950db5da93c0e0a251b70d842f56cb61cabb85cf183bc7117e498024e8dfe481abc0d204
|
|
7
|
+
data.tar.gz: 4db69cd9aab4db9689ead804daff544bd1caac63c56e0eb663421392aa7054960e8cbd201f8e4266e9da64a5ee6f913db16d1456cd2537ba31d052de4e0243bf
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.1.1] — 2026-11-07
|
|
6
|
+
|
|
7
|
+
Crafted request headers can no longer grow a process for good or make it raise.
|
|
8
|
+
|
|
9
|
+
**Fixed**
|
|
10
|
+
- Version checks no longer fill RubyGems' process-wide `Gem::Version` cache: 20,000 user agents with unique macOS
|
|
11
|
+
versions grew a process by 200 MB.
|
|
12
|
+
- An Accept-Language header listing more than 32 languages is parsed on each request instead of cached: 1,000 such
|
|
13
|
+
headers held 107 MB.
|
|
14
|
+
- Bytes that are not valid UTF-8 in the user agent or the Accept-Language header become U+FFFD instead of raising
|
|
15
|
+
`ArgumentError`, and such client hints and Cloudflare headers are ignored.
|
|
16
|
+
- `Browser.new` checks the size of the user agent before reading it, so an oversized one is rejected in microseconds.
|
|
17
|
+
|
|
5
18
|
## [3.1.0] — 2026-11-06
|
|
6
19
|
|
|
7
20
|
A Google Cloud VM no longer passes as a verified Googlebot, and a forged Googlebot can no longer stall requests on reverse DNS.
|
|
@@ -100,7 +113,8 @@ First release, forked from browser 6.2.0 (`dd88933`). Same API: `gem "agentry"`
|
|
|
100
113
|
- A corpus of 20,328 user agents, with snapshot and diff scripts proving answers match upstream, and a
|
|
101
114
|
request benchmark.
|
|
102
115
|
|
|
103
|
-
[Unreleased]: https://github.com/levups/agentry/compare/v3.1.
|
|
116
|
+
[Unreleased]: https://github.com/levups/agentry/compare/v3.1.1...HEAD
|
|
117
|
+
[3.1.1]: https://github.com/levups/agentry/compare/v3.1.0...v3.1.1
|
|
104
118
|
[3.1.0]: https://github.com/levups/agentry/compare/v3.0.0...v3.1.0
|
|
105
119
|
[3.0.0]: https://github.com/levups/agentry/compare/v2.1.0...v3.0.0
|
|
106
120
|
[2.1.0]: https://github.com/levups/agentry/compare/v2.0.0...v2.1.0
|
data/README.md
CHANGED
|
@@ -158,6 +158,11 @@ Browser::Bot::IPRanges.dns_timeout = 0.5 # seconds per query and nameserve
|
|
|
158
158
|
Browser::Bot::IPRanges.refresh # fetches the ranges again, in a long-lived process
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
+
The reverse lookup reads answers from a name server the requester chooses, and versions of the `resolv` gem before
|
|
162
|
+
0.7.2 (0.3.2 in the 0.3 series) can be made to use up memory or CPU by such answers (CVE-2026-80212,
|
|
163
|
+
CVE-2025-24294). Before turning `reverse_dns:` on, check `bundle info resolv`, or add `gem "resolv", ">= 0.7.2"`
|
|
164
|
+
to your Gemfile.
|
|
165
|
+
|
|
161
166
|
## Cloudflare and Client Hints
|
|
162
167
|
|
|
163
168
|
When the Rack env is passed as `headers:` (the Rails helper and the middleware do it for you), Agentry reads two
|
|
@@ -229,7 +234,14 @@ Browser.cache_size = 5_000 # 0 turns it off
|
|
|
229
234
|
```
|
|
230
235
|
|
|
231
236
|
A user agent or an `Accept-Language` header longer than 2048 bytes raises `Browser::Error`. Both limits can be
|
|
232
|
-
raised with `Browser.user_agent_size_limit = 4096` and `Browser.accept_language_size_limit = 4096`.
|
|
237
|
+
raised with `Browser.user_agent_size_limit = 4096` and `Browser.accept_language_size_limit = 4096`. An
|
|
238
|
+
`Accept-Language` header listing more than 32 languages is parsed on each request instead of cached, so that
|
|
239
|
+
crafted headers cannot fill the cache with thousands of languages each.
|
|
240
|
+
|
|
241
|
+
Headers whose bytes are not valid UTF-8, forged or read back from a log file, do not raise: Agentry reads the user
|
|
242
|
+
agent and the `Accept-Language` header with each invalid byte replaced by U+FFFD, and ignores such a Client Hint or
|
|
243
|
+
Cloudflare header. Version checks such as `browser.chrome?(">= 65")` leave the versions they read from a request
|
|
244
|
+
out of RubyGems' version cache, where those an attacker sends would pile up for the life of the process.
|
|
233
245
|
|
|
234
246
|
The lists and matchers are built on first use: the first request of each process takes about 13 ms more, the
|
|
235
247
|
following ones 0.2 ms. `Browser.eager_load!` builds them at boot, so that a server that loads the app before
|
|
@@ -9,6 +9,7 @@ module Browser
|
|
|
9
9
|
def self.parse(accept_language)
|
|
10
10
|
return [] unless accept_language
|
|
11
11
|
|
|
12
|
+
accept_language = accept_language.scrub unless accept_language.valid_encoding?
|
|
12
13
|
languages = accept_language.split(",")
|
|
13
14
|
languages.map! { |string| new(string.squeeze(" ").strip) }
|
|
14
15
|
languages.reject! { |al| al.quality.zero? }
|
data/lib/browser/base.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Browser
|
|
|
9
9
|
# headers: the Rack env (request.env), for the client hints and the
|
|
10
10
|
# Cloudflare headers.
|
|
11
11
|
def initialize(ua, accept_language: nil, headers: nil)
|
|
12
|
-
validate_size(:user_agent, ua.to_s)
|
|
12
|
+
Base.validate_size(:user_agent, ua.to_s)
|
|
13
13
|
|
|
14
14
|
@ua = ua
|
|
15
15
|
@accept_language_raw = accept_language.to_s
|
|
@@ -24,7 +24,7 @@ module Browser
|
|
|
24
24
|
# Return an array with all preferred languages that this browser accepts.
|
|
25
25
|
def accept_language
|
|
26
26
|
@accept_language ||= begin
|
|
27
|
-
validate_size(:accept_language, @accept_language_raw)
|
|
27
|
+
Base.validate_size(:accept_language, @accept_language_raw)
|
|
28
28
|
Cache.accept_language(@accept_language_raw)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -288,7 +288,7 @@ module Browser
|
|
|
288
288
|
@detection ||= Cache.detection(ua)
|
|
289
289
|
end
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
def self.validate_size(subject, input)
|
|
292
292
|
actual_bytesize = input.bytesize
|
|
293
293
|
size_limit =
|
|
294
294
|
if subject == :user_agent
|
data/lib/browser/bot.rb
CHANGED
|
@@ -117,6 +117,7 @@ module Browser
|
|
|
117
117
|
private_class_method :trie_source, :trie_node_source
|
|
118
118
|
|
|
119
119
|
def self.why?(ua)
|
|
120
|
+
ua = ua.scrub unless ua.valid_encoding?
|
|
120
121
|
ua = ua.downcase.strip
|
|
121
122
|
browser = Browser.new(ua)
|
|
122
123
|
matchers.find { |matcher| matcher.call(ua, browser) }
|
data/lib/browser/browser.rb
CHANGED
|
@@ -179,6 +179,10 @@ module Browser
|
|
|
179
179
|
|
|
180
180
|
def self.new(user_agent, **)
|
|
181
181
|
user_agent ||= EMPTY_STRING
|
|
182
|
+
# Before the first regexp: an oversized user agent then costs nothing, and
|
|
183
|
+
# a byte that is not UTF-8 (forged, or read from a log) would make it raise.
|
|
184
|
+
Base.validate_size(:user_agent, user_agent.to_s)
|
|
185
|
+
user_agent = user_agent.scrub unless user_agent.valid_encoding?
|
|
182
186
|
browser_class = Cache.cached_detection(user_agent)&.browser_class
|
|
183
187
|
return browser_class.new(user_agent, **) if browser_class
|
|
184
188
|
|
data/lib/browser/cache.rb
CHANGED
|
@@ -6,6 +6,10 @@ module Browser
|
|
|
6
6
|
# GVL a Hash read is atomic, and a thread that misses detects again. Writes
|
|
7
7
|
# do, for the Rubies without a GVL.
|
|
8
8
|
module Cache
|
|
9
|
+
# Far more than a browser sends. A longer list is parsed again each time: 2 KB of "a,a,a" makes a thousand
|
|
10
|
+
# objects, and a thousand such headers would hold 100 MB.
|
|
11
|
+
MAX_LANGUAGES_PER_CACHED_HEADER = 32
|
|
12
|
+
|
|
9
13
|
@mutex = Mutex.new
|
|
10
14
|
@detection_by_user_agent = {}
|
|
11
15
|
@languages_by_header = {}
|
|
@@ -28,11 +32,13 @@ module Browser
|
|
|
28
32
|
def self.accept_language(header)
|
|
29
33
|
return AcceptLanguage.parse(header) if Browser.cache_size.zero?
|
|
30
34
|
|
|
31
|
-
languages = @languages_by_header[header]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
languages.
|
|
35
|
+
languages = @languages_by_header[header]
|
|
36
|
+
return languages.dup if languages
|
|
37
|
+
|
|
38
|
+
languages = AcceptLanguage.parse(header)
|
|
39
|
+
return languages if languages.size > MAX_LANGUAGES_PER_CACHED_HEADER
|
|
40
|
+
|
|
41
|
+
@mutex.synchronize { store(@languages_by_header, -header, languages.freeze) }.dup
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
# Bumped when a matcher list, the bot list or its exceptions change: the
|
data/lib/browser/client_hints.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Browser
|
|
|
4
4
|
# The User-Agent Client Hints of Chromium browsers, read from a Rack env.
|
|
5
5
|
# Safari and Firefox send none: every answer is then nil.
|
|
6
6
|
class ClientHints
|
|
7
|
-
# A browser's hints are a few dozen bytes: longer values are ignored.
|
|
7
|
+
# A browser's hints are a few dozen bytes: longer values, and invalid UTF-8, are ignored.
|
|
8
8
|
HEADER_SIZE_LIMIT_BYTES = 512
|
|
9
9
|
PLATFORM_CLASS_BY_NAME = {
|
|
10
10
|
"Android" => Platform::Android,
|
|
@@ -54,7 +54,7 @@ module Browser
|
|
|
54
54
|
|
|
55
55
|
private def header(headers, name)
|
|
56
56
|
value = headers[name]
|
|
57
|
-
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
|
|
57
|
+
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES && value.valid_encoding?
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
private def parse_brands(value)
|
data/lib/browser/cloudflare.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Browser
|
|
|
5
5
|
# sets (docs/guide.html#cloudflare) and its own CF-IPCountry. Each site names
|
|
6
6
|
# its headers in its rule: Browser::Cloudflare.header_names= changes them.
|
|
7
7
|
class Cloudflare
|
|
8
|
-
# Cloudflare's values are a few bytes: a longer one is forged, and ignored.
|
|
8
|
+
# Cloudflare's values are a few bytes: a longer one, or invalid UTF-8, is forged, and ignored.
|
|
9
9
|
HEADER_SIZE_LIMIT_BYTES = 128
|
|
10
10
|
DEFAULT_HEADER_NAMES = {
|
|
11
11
|
verified_bot: "X-Bot-Verified",
|
|
@@ -61,7 +61,7 @@ module Browser
|
|
|
61
61
|
|
|
62
62
|
private def header(signal)
|
|
63
63
|
value = @headers&.[](self.class.env_key_by_signal[signal])
|
|
64
|
-
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
|
|
64
|
+
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES && value.valid_encoding?
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Browser
|
|
4
4
|
module DetectVersion
|
|
5
|
+
# Gem::Version.new and Gem::Version#release keep every version they build in class variables, for the life of
|
|
6
|
+
# the process: a version read from a header must not reach them, or each new one an attacker sends stays.
|
|
7
|
+
# A subclass skips the first; this #release is RubyGems' own without the second.
|
|
8
|
+
class UncachedVersion < Gem::Version
|
|
9
|
+
def release
|
|
10
|
+
return self unless prerelease?
|
|
11
|
+
|
|
12
|
+
self.class.new(segments.take_while { |segment| segment.is_a?(Integer) }.join("."))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A browser's version is a few bytes: a longer one is not kept, so that a cached platform stays small.
|
|
17
|
+
MAX_KEPT_VERSION_BYTES = 32
|
|
18
|
+
private_constant :UncachedVersion, :MAX_KEPT_VERSION_BYTES
|
|
19
|
+
|
|
5
20
|
private def detect_version?(actual_version, expected_version)
|
|
6
21
|
return true unless expected_version
|
|
7
22
|
return false unless actual_version
|
|
@@ -10,11 +25,21 @@ module Browser
|
|
|
10
25
|
actual_version = parse_version(actual_version)
|
|
11
26
|
|
|
12
27
|
Gem::Requirement.create(expected_version)
|
|
13
|
-
.satisfied_by?(
|
|
28
|
+
.satisfied_by?(gem_version(actual_version))
|
|
14
29
|
rescue ArgumentError
|
|
15
30
|
false
|
|
16
31
|
end
|
|
17
32
|
|
|
33
|
+
# The last one built: a cached platform asks for the same version on each request.
|
|
34
|
+
private def gem_version(version)
|
|
35
|
+
string, gem_version = @gem_version
|
|
36
|
+
return gem_version if string == version
|
|
37
|
+
|
|
38
|
+
gem_version = UncachedVersion.create(version)
|
|
39
|
+
@gem_version = [version, gem_version] if version.bytesize <= MAX_KEPT_VERSION_BYTES
|
|
40
|
+
gem_version
|
|
41
|
+
end
|
|
42
|
+
|
|
18
43
|
private def parse_version(version)
|
|
19
44
|
version.is_a?(Numeric) ? version.to_s : version
|
|
20
45
|
end
|
data/lib/browser/middleware.rb
CHANGED
data/lib/browser/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agentry
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.1.
|
|
4
|
+
version: 3.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nando Vieira
|
|
@@ -122,8 +122,10 @@ dependencies:
|
|
|
122
122
|
- - ">="
|
|
123
123
|
- !ruby/object:Gem::Version
|
|
124
124
|
version: '1.0'
|
|
125
|
-
description:
|
|
126
|
-
|
|
125
|
+
description: Agentry replaces the browser gem without changing a call site, with the
|
|
126
|
+
same Browser class, methods and require paths. It is faster, updates its bot list
|
|
127
|
+
every week, names the category of each bot and checks the IP addresses of known
|
|
128
|
+
crawlers.
|
|
127
129
|
executables: []
|
|
128
130
|
extensions: []
|
|
129
131
|
extra_rdoc_files: []
|
|
@@ -270,5 +272,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
270
272
|
requirements: []
|
|
271
273
|
rubygems_version: 4.0.20
|
|
272
274
|
specification_version: 4
|
|
273
|
-
summary:
|
|
275
|
+
summary: Detects the browser, platform, device and bot behind a User-Agent header
|
|
274
276
|
test_files: []
|