Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ Browser::Bot::IPRanges.dns_timeout = 0.5 # seconds per query and nameserve
Browser::Bot::IPRanges.refresh # fetches the ranges again, in a long-lived process
```

The reverse lookup reads answers from a name server the requester chooses, and versions of the `resolv` gem before
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,
CVE-2025-24294). Before turning `reverse_dns:` on, check `bundle info resolv`, or add `gem "resolv", ">= 0.7.2"`
to your Gemfile.

## Cloudflare and Client Hints

When the Rack env is passed as `headers:` (the Rails helper and the middleware do it for you), Agentry reads two
Expand Down Expand Up @@ -229,7 +234,14 @@ Browser.cache_size = 5_000 # 0 turns it off
```

A user agent or an `Accept-Language` header longer than 2048 bytes raises `Browser::Error`. Both limits can be
raised with `Browser.user_agent_size_limit = 4096` and `Browser.accept_language_size_limit = 4096`.
raised with `Browser.user_agent_size_limit = 4096` and `Browser.accept_language_size_limit = 4096`. An
`Accept-Language` header listing more than 32 languages is parsed on each request instead of cached, so that
crafted headers cannot fill the cache with thousands of languages each.

Headers whose bytes are not valid UTF-8, forged or read back from a log file, do not raise: Agentry reads the user
agent and the `Accept-Language` header with each invalid byte replaced by U+FFFD, and ignores such a Client Hint or
Cloudflare header. Version checks such as `browser.chrome?(">= 65")` leave the versions they read from a request
out of RubyGems' version cache, where those an attacker sends would pile up for the life of the process.

The lists and matchers are built on first use: the first request of each process takes about 13 ms more, the
following ones 0.2 ms. `Browser.eager_load!` builds them at boot, so that a server that loads the app before
Expand Down
7 changes: 4 additions & 3 deletions agentry.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ Gem::Specification.new do |s|
s.platform = Gem::Platform::RUBY
s.authors = ["Nando Vieira", "Level UP Solutions"]
s.homepage = "https://github.com/levups/agentry"
s.summary = "Browser detection for Ruby: the browser gem's API, faster"
s.description = "A drop-in replacement for the browser gem: the same Browser API and require paths, " \
"answering nearly twice as fast with half the allocations."
s.summary = "Detects the browser, platform, device and bot behind a User-Agent header"
s.description = "Agentry replaces the browser gem without changing a call site, with the same Browser class, " \
"methods and require paths. It is faster, updates its bot list every week, names the category " \
"of each bot and checks the IP addresses of known crawlers."
s.license = "MIT"
s.required_ruby_version = ">= 3.3"

Expand Down
4 changes: 3 additions & 1 deletion docs/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ <h2 id="options-title">Options</h2>
<tr><td><code>accept_language:</code></td><td><code>nil</code></td><td>The Accept-Language header, passed to <code>Browser.new</code>; parsed into <code>browser.accept_language</code>.</td></tr>
<tr><td><code>headers:</code></td><td><code>nil</code></td><td>The Rack env of the request, passed to <code>Browser.new</code>; read for the <a href="#client-hints">Client Hints</a> and the <a href="#cloudflare">Cloudflare</a> headers. The Rails helper and the middleware pass it.</td></tr>
<tr><td><code>Browser.user_agent_size_limit</code></td><td>2048</td><td>Largest user agent accepted, in bytes. A larger one raises <code>Browser::Error</code>.</td></tr>
<tr><td><code>Browser.accept_language_size_limit</code></td><td>2048</td><td>Largest Accept-Language header accepted, in bytes. A larger one raises <code>Browser::Error</code>.</td></tr>
<tr><td><code>Browser.accept_language_size_limit</code></td><td>2048</td><td>Largest Accept-Language header accepted, in bytes. A larger one raises <code>Browser::Error</code>. A header listing more than 32 languages is parsed on each request, never cached.</td></tr>
<tr><td><code>Browser.cache_size</code></td><td>1000</td><td>Distinct user agents (and Accept-Language headers) whose detection each process keeps, about 1 KB each; emptied when full or when a matcher list or the bot list changes. <code>0</code> turns it off.</td></tr>
<tr><td><code>Browser::Bot.matchers</code></td><td>three built-in matchers</td><td>Callables deciding whether a user agent is a bot. See <a href="bots.html#matchers">Bots</a>.</td></tr>
<tr><td><code>Browser::Bot.bots</code></td><td><code>bots.yml</code></td><td>Known bots, keyed by a lowercase substring of their user agent.</td></tr>
Expand All @@ -256,6 +256,7 @@ <h2 id="options-title">Options</h2>
<div class="prose">
<pre><code class="language-ruby"><span class="no">Browser</span><span class="p">.</span><span class="nf">user_agent_size_limit</span> <span class="o">=</span> <span class="mi">4096</span>
<span class="no">Browser</span><span class="p">.</span><span class="nf">accept_language_size_limit</span> <span class="o">=</span> <span class="mi">4096</span></code></pre>
<p>A user agent or an Accept-Language header whose bytes are not valid UTF-8 (forged, or read back from a log file) does not raise: each invalid byte is read as U+FFFD. A Client Hint or a Cloudflare header that is not valid UTF-8 is ignored.</p>
</div>
</section>

Expand Down Expand Up @@ -317,6 +318,7 @@ <h2 id="verified-crawlers-title">Verified crawlers</h2>
<span class="no">Browser</span><span class="o">::</span><span class="no">Bot</span><span class="o">::</span><span class="no">IPRanges</span><span class="p">.</span><span class="nf">dns_cache_ttl</span> <span class="o">=</span> <span class="mi">3600</span> <span class="c1"># seconds a verdict holds (the default)</span>
<span class="no">Browser</span><span class="o">::</span><span class="no">Bot</span><span class="o">::</span><span class="no">IPRanges</span><span class="p">.</span><span class="nf">dns_timeout</span> <span class="o">=</span> <span class="mf">0.5</span> <span class="c1"># seconds per query and nameserver, 1 by default</span></code></pre>
<p>Each process holds its own cache. An attacker spreading over many IPs still costs one wait per IP: on a hot path, run the check in a background job and keep the answer in your own cache.</p>
<p>The answers come from a name server the requester chooses, and versions of the <code>resolv</code> gem before 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, CVE-2025-24294). Before turning <code>reverse_dns:</code> on, check <code>bundle info resolv</code>, or add <code>gem "resolv", "&gt;= 0.7.2"</code> to your Gemfile.</p>
<p><strong>Fresh ranges.</strong> The weekly bot sync rewrites <code>bot_ip_ranges.yml</code> from the owners’ JSON files, and each release ships them. A process that runs for weeks can fetch them again. Each process holds its own copy; a failed source raises <code>Browser::Error</code> and keeps the current ranges:</p>
<pre><code class="language-ruby"><span class="no">Browser</span><span class="o">::</span><span class="no">Bot</span><span class="o">::</span><span class="no">IPRanges</span><span class="p">.</span><span class="nf">refresh</span> <span class="c1"># fetches every owner's JSON, swaps the ranges at once</span>

Expand Down
1 change: 1 addition & 0 deletions lib/browser/accept_language.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def self.languages
def self.parse(accept_language)
return [] unless accept_language

accept_language = accept_language.scrub unless accept_language.valid_encoding?
languages = accept_language.split(",")
languages.map! { |string| new(string.squeeze(" ").strip) }
languages.reject! { |al| al.quality.zero? }
Expand Down
6 changes: 3 additions & 3 deletions lib/browser/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class Base
# headers: the Rack env (request.env), for the client hints and the
# Cloudflare headers.
def initialize(ua, accept_language: nil, headers: nil)
validate_size(:user_agent, ua.to_s)
Base.validate_size(:user_agent, ua.to_s)

@ua = ua
@accept_language_raw = accept_language.to_s
Expand All @@ -24,7 +24,7 @@ def meta
# Return an array with all preferred languages that this browser accepts.
def accept_language
@accept_language ||= begin
validate_size(:accept_language, @accept_language_raw)
Base.validate_size(:accept_language, @accept_language_raw)
Cache.accept_language(@accept_language_raw)
end
end
Expand Down Expand Up @@ -288,7 +288,7 @@ def epiphany?(expected_version = nil)
@detection ||= Cache.detection(ua)
end

private def validate_size(subject, input)
def self.validate_size(subject, input)
actual_bytesize = input.bytesize
size_limit =
if subject == :user_agent
Expand Down
1 change: 1 addition & 0 deletions lib/browser/bot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def self.trie_node_source(node)
private_class_method :trie_source, :trie_node_source

def self.why?(ua)
ua = ua.scrub unless ua.valid_encoding?
ua = ua.downcase.strip
browser = Browser.new(ua)
matchers.find { |matcher| matcher.call(ua, browser) }
Expand Down
4 changes: 4 additions & 0 deletions lib/browser/browser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ def self.matchers

def self.new(user_agent, **)
user_agent ||= EMPTY_STRING
# Before the first regexp: an oversized user agent then costs nothing, and
# a byte that is not UTF-8 (forged, or read from a log) would make it raise.
Base.validate_size(:user_agent, user_agent.to_s)
user_agent = user_agent.scrub unless user_agent.valid_encoding?
browser_class = Cache.cached_detection(user_agent)&.browser_class
return browser_class.new(user_agent, **) if browser_class

Expand Down
16 changes: 11 additions & 5 deletions lib/browser/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ module Browser
# GVL a Hash read is atomic, and a thread that misses detects again. Writes
# do, for the Rubies without a GVL.
module Cache
# Far more than a browser sends. A longer list is parsed again each time: 2 KB of "a,a,a" makes a thousand
# objects, and a thousand such headers would hold 100 MB.
MAX_LANGUAGES_PER_CACHED_HEADER = 32

@mutex = Mutex.new
@detection_by_user_agent = {}
@languages_by_header = {}
Expand All @@ -28,11 +32,13 @@ def self.detection(ua)
def self.accept_language(header)
return AcceptLanguage.parse(header) if Browser.cache_size.zero?

languages = @languages_by_header[header] || @mutex.synchronize do
store(@languages_by_header, -header,
AcceptLanguage.parse(header).freeze)
end
languages.dup
languages = @languages_by_header[header]
return languages.dup if languages

languages = AcceptLanguage.parse(header)
return languages if languages.size > MAX_LANGUAGES_PER_CACHED_HEADER

@mutex.synchronize { store(@languages_by_header, -header, languages.freeze) }.dup
end

# Bumped when a matcher list, the bot list or its exceptions change: the
Expand Down
4 changes: 2 additions & 2 deletions lib/browser/client_hints.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Browser
# The User-Agent Client Hints of Chromium browsers, read from a Rack env.
# Safari and Firefox send none: every answer is then nil.
class ClientHints
# A browser's hints are a few dozen bytes: longer values are ignored.
# A browser's hints are a few dozen bytes: longer values, and invalid UTF-8, are ignored.
HEADER_SIZE_LIMIT_BYTES = 512
PLATFORM_CLASS_BY_NAME = {
"Android" => Platform::Android,
Expand Down Expand Up @@ -54,7 +54,7 @@ def windows11?

private def header(headers, name)
value = headers[name]
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES && value.valid_encoding?
end

private def parse_brands(value)
Expand Down
4 changes: 2 additions & 2 deletions lib/browser/cloudflare.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Browser
# sets (docs/guide.html#cloudflare) and its own CF-IPCountry. Each site names
# its headers in its rule: Browser::Cloudflare.header_names= changes them.
class Cloudflare
# Cloudflare's values are a few bytes: a longer one is forged, and ignored.
# Cloudflare's values are a few bytes: a longer one, or invalid UTF-8, is forged, and ignored.
HEADER_SIZE_LIMIT_BYTES = 128
DEFAULT_HEADER_NAMES = {
verified_bot: "X-Bot-Verified",
Expand Down Expand Up @@ -61,7 +61,7 @@ def country

private def header(signal)
value = @headers&.[](self.class.env_key_by_signal[signal])
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES && value.valid_encoding?
end
end
end
27 changes: 26 additions & 1 deletion lib/browser/detect_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

module Browser
module DetectVersion
# Gem::Version.new and Gem::Version#release keep every version they build in class variables, for the life of
# the process: a version read from a header must not reach them, or each new one an attacker sends stays.
# A subclass skips the first; this #release is RubyGems' own without the second.
class UncachedVersion < Gem::Version
def release
return self unless prerelease?

self.class.new(segments.take_while { |segment| segment.is_a?(Integer) }.join("."))
end
end

# A browser's version is a few bytes: a longer one is not kept, so that a cached platform stays small.
MAX_KEPT_VERSION_BYTES = 32
private_constant :UncachedVersion, :MAX_KEPT_VERSION_BYTES

private def detect_version?(actual_version, expected_version)
return true unless expected_version
return false unless actual_version
Expand All @@ -10,11 +25,21 @@ module DetectVersion
actual_version = parse_version(actual_version)

Gem::Requirement.create(expected_version)
.satisfied_by?(Gem::Version.create(actual_version))
.satisfied_by?(gem_version(actual_version))
rescue ArgumentError
false
end

# The last one built: a cached platform asks for the same version on each request.
private def gem_version(version)
string, gem_version = @gem_version
return gem_version if string == version

gem_version = UncachedVersion.create(version)
@gem_version = [version, gem_version] if version.bytesize <= MAX_KEPT_VERSION_BYTES
gem_version
end

private def parse_version(version)
version.is_a?(Numeric) ? version.to_s : version
end
Expand Down
2 changes: 1 addition & 1 deletion lib/browser/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def process?(request)
end

def html?(request)
request.env["HTTP_ACCEPT"].to_s.match(ACCEPT_REGEX)
request.env["HTTP_ACCEPT"].to_s.scrub.match(ACCEPT_REGEX)
end

def assets?(request)
Expand Down
16 changes: 16 additions & 0 deletions test/request_headers_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,20 @@ def show

assert_equal [true, "FR"], seen
end

test "the middleware and the helper read headers that are not valid UTF-8" do
headers = HEADERS.merge("HTTP_ACCEPT_LANGUAGE" => "fr-FR")
.transform_values { |value| "#{value}\xFF".force_encoding(Encoding::UTF_8) }
middleware = Browser::Middleware.new(->(_env) { [200, {}, []] }) do
redirect_to "/upgrade" if browser.chrome? && browser.accept_language.first.code == "fr"
end

status, = middleware.call(Rack::MockRequest.env_for("/", headers))
# Rails itself raises on such an Accept header.
_status, _headers, body = SignalsController.action(:show)
.call(Rack::MockRequest.env_for("/", headers.merge("HTTP_ACCEPT" => "text/html")))

assert_equal 302, status
assert_equal "10.0 false ", body.each.to_a.join
end
end
12 changes: 12 additions & 0 deletions test/unit/cache_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ def cached_user_agents
Browser.new("", accept_language: "fr-FR,en;q=0.8").accept_language.first
end

test "does not cache an accept language listing more languages than a browser sends" do
header = Array.new(Browser::Cache::MAX_LANGUAGES_PER_CACHED_HEADER + 1) { |index| "x#{index}" }.join(",")
Browser::Cache.clear

languages = Browser.new("", accept_language: header).accept_language

assert_equal %w[x0 x1], languages.first(2).map(&:full)
assert_empty Browser::Cache.instance_variable_get(:@languages_by_header)
Browser.new("", accept_language: header.sub(/,x\d+\z/, "")).accept_language
assert_equal 1, Browser::Cache.instance_variable_get(:@languages_by_header).size
end

test "gives the same answers from concurrent threads" do
user_agents = Browser.user_agents.values
answers = ->(browser) do
Expand Down
11 changes: 11 additions & 0 deletions test/unit/client_hints_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ def hints(platform: nil, platform_version: nil, mobile: nil, brands: BRANDS)
assert_nil Browser.new(WINDOWS_CHROME).client_hints.platform
end

test "ignores a hint that is not valid UTF-8" do
invalid = hints(platform: "Windows\xFF", platform_version: "19.0.0\xFF", brands: "#{BRANDS}\xFF")
.transform_values { |value| value.dup.force_encoding(Encoding::UTF_8) }
client_hints = Browser::ClientHints.new(invalid)

assert_empty client_hints.version_by_brand
assert_nil client_hints.platform
assert_nil client_hints.platform_version
refute Browser.new(WINDOWS_CHROME, headers: invalid).platform.windows11?
end

test "tells Windows 11 from Windows 10" do
windows11 = Browser.new(WINDOWS_CHROME, headers: hints(platform: "Windows", platform_version: "19.0.0")).platform
windows10 = Browser.new(WINDOWS_CHROME, headers: hints(platform: "Windows", platform_version: "10.0.0")).platform
Expand Down
13 changes: 13 additions & 0 deletions test/unit/cloudflare_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ def cloudflare(headers)
refute cloudflare("HTTP_X_BOT_VERIFIED" => "true#{" " * 200}").verified_bot?
end

test "ignores a value that is not valid UTF-8" do
invalid = ->(value) { "#{value}\xFF".force_encoding(Encoding::UTF_8) }
cloudflare = cloudflare(
"HTTP_X_BOT_VERIFIED" => invalid.call("true"), "HTTP_X_BOT_CATEGORY" => invalid.call("Search Engine Crawler"),
"HTTP_X_BOT_SCORE" => invalid.call("1"), "HTTP_CF_IPCOUNTRY" => invalid.call("US")
)

refute cloudflare.verified_bot?
assert_nil cloudflare.bot_category
assert_nil cloudflare.bot_score
assert_nil cloudflare.country
end

test "reads the header names a site chose" do
Browser::Cloudflare.header_names = {verified_bot: "X-Edge-Good-Bot", bot_score: "X-Edge-Score"}

Expand Down
Loading
Loading