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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca414103cb27f143a4872d36801bd26e012ff0950552ac96646497be9a2bb8f3
4
- data.tar.gz: 38d3bb2c5529a324160476b7c5874d645689bb81409234e14ba2ed7f01da4d61
3
+ metadata.gz: 712d74b9f09a7f5755ade4414301f82b2ee08445c8af1eca147fa8039ead5b92
4
+ data.tar.gz: bca61032c6cceef348582436594532a5144d71f2483300b151fa4dd053bd95d6
5
5
  SHA512:
6
- metadata.gz: 755ee15fab59790728e25954113018ca3d25fb1255933a6a029f6dabcdc2767d5781649592c88f0ba6f554f2772319d318df86b9a6009a31eba9926764f01bdf
7
- data.tar.gz: ce39ec99a84d2326c2575c3a9ae8c76c3e2205283f4cf51d8b7f7ea5e6b1429e603078e2aac99947970323c89d0ba86902f32aebf014d76936c7d08edadfe215
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.0...HEAD
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
- private def validate_size(subject, input)
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) }
@@ -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] || @mutex.synchronize do
32
- store(@languages_by_header, -header,
33
- AcceptLanguage.parse(header).freeze)
34
- end
35
- languages.dup
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
@@ -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)
@@ -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?(Gem::Version.create(actual_version))
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
@@ -60,7 +60,7 @@ module Browser
60
60
  end
61
61
 
62
62
  def html?(request)
63
- request.env["HTTP_ACCEPT"].to_s.match(ACCEPT_REGEX)
63
+ request.env["HTTP_ACCEPT"].to_s.scrub.match(ACCEPT_REGEX)
64
64
  end
65
65
 
66
66
  def assets?(request)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browser
4
- VERSION = "3.1.0"
4
+ VERSION = "3.1.1"
5
5
  end
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.0
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: 'A drop-in replacement for the browser gem: the same Browser API and
126
- require paths, answering nearly twice as fast with half the allocations.'
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: 'Browser detection for Ruby: the browser gem''s API, faster'
275
+ summary: Detects the browser, platform, device and bot behind a User-Agent header
274
276
  test_files: []