agentry 3.0.0 → 3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a57ed8305d43ff3e55dce1770f63f0bf63c712e084ba3013fe2f2240fcc27f6
4
- data.tar.gz: 145e9e764e8cbc76e3f40b985d37181b3d801a620998bb34ecbcae152739a15b
3
+ metadata.gz: ca414103cb27f143a4872d36801bd26e012ff0950552ac96646497be9a2bb8f3
4
+ data.tar.gz: 38d3bb2c5529a324160476b7c5874d645689bb81409234e14ba2ed7f01da4d61
5
5
  SHA512:
6
- metadata.gz: 0a7b34836efd8a5cff0af7eac8e723d0027670a34bde7f1fd3e58763ba4a8f917094a76d19b4e0cb5e7ced62af3245a55ffc516310ac2d4002ea1fa421c4812d
7
- data.tar.gz: ee6ba51155056619fada17e77f0997849fb1e686ca9af9424339ab788d74018e3e9d71097259e6286c5fbed478c7f84b560943a26495602169b3593b30f46e77
6
+ metadata.gz: 755ee15fab59790728e25954113018ca3d25fb1255933a6a029f6dabcdc2767d5781649592c88f0ba6f554f2772319d318df86b9a6009a31eba9926764f01bdf
7
+ data.tar.gz: ce39ec99a84d2326c2575c3a9ae8c76c3e2205283f4cf51d8b7f7ea5e6b1429e603078e2aac99947970323c89d0ba86902f32aebf014d76936c7d08edadfe215
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.1.0] — 2026-11-06
6
+
7
+ A Google Cloud VM no longer passes as a verified Googlebot, and a forged Googlebot can no longer stall requests on reverse DNS.
8
+
9
+ **New**
10
+ - `Browser::Bot::IPRanges.dns_cache_size` caches each reverse DNS verdict per owner and IP, failures included, for `dns_cache_ttl` seconds (3,600 by default), so a forged Googlebot pays its DNS wait once per TTL instead of on every request. It is 0, off, by default.
11
+ - `dns_timeout` sets the timeout per query and per nameserver, as `Resolv` applies it. Invalid values raise `ArgumentError`.
12
+
13
+ **Fixed**
14
+ - Google's reverse DNS check accepts only `googlebot.com` and `google.com`. Any Google Cloud VM, named under `googleusercontent.com`, passed as a verified Googlebot; Google's user-triggered fetchers are covered by the IP ranges it publishes.
15
+ - The DNS timeout drops from 2 seconds to 1, and the forward lookup uses an absolute name, so the resolver never retries it under the search domains of `/etc/resolv.conf`.
16
+ - `IPRanges.refresh` and the weekly sync raise `Browser::Error` on anything implausible, and keep the current ranges: a prefix broader than /12 (IPv4) or /24 (IPv6), a private, loopback, link-local, multicast or unspecified range, a malformed document, or a redirect that leaves HTTPS or the owner's host.
17
+ - Cloudflare header values over 128 bytes are ignored.
18
+
5
19
  ## [3.0.0] — 2026-11-05
6
20
 
7
21
  The gem is renamed Agentry and lives at levups/agentry; the API is untouched.
@@ -86,7 +100,8 @@ First release, forked from browser 6.2.0 (`dd88933`). Same API: `gem "agentry"`
86
100
  - A corpus of 20,328 user agents, with snapshot and diff scripts proving answers match upstream, and a
87
101
  request benchmark.
88
102
 
89
- [Unreleased]: https://github.com/levups/agentry/compare/v3.0.0...HEAD
103
+ [Unreleased]: https://github.com/levups/agentry/compare/v3.1.0...HEAD
104
+ [3.1.0]: https://github.com/levups/agentry/compare/v3.0.0...v3.1.0
90
105
  [3.0.0]: https://github.com/levups/agentry/compare/v2.1.0...v3.0.0
91
106
  [2.1.0]: https://github.com/levups/agentry/compare/v2.0.0...v2.1.0
92
107
  [2.0.0]: https://github.com/levups/agentry/compare/v1.0.0...v2.0.0
data/README.md CHANGED
@@ -1,432 +1,211 @@
1
1
  # Agentry
2
2
 
3
- Browser detection for Ruby, with ActionController integration: the API of the
4
- [browser](https://rubygems.org/gems/browser) gem, faster, and a bot list that stays fresh. Put `gem "agentry"` in
5
- your Gemfile instead of `gem "browser"`, and every call site keeps working — same `Browser` namespace, same
6
- `require "browser"`, same predicates.
7
-
8
- - **Faster**: 25 µs per request instead of 126 µs with browser 6.2.0, 2 µs once a user agent was seen (Ruby 4.0.9,
9
- YJIT, `bench/request.rb`).
10
- - **Fresher bots**: synced every week from [crawler-user-agents](https://github.com/monperrus/crawler-user-agents)
11
- and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt), with categories (`bot.category`, `bot.ai?`).
12
- Of the 882 crawler user agents added to crawler-user-agents since 2024 (test drivers and scanners left out),
13
- browser misses 353, Agentry 11.
14
- - **Verified signals**: `bot.verified?(ip)` checks a crawler against the IP ranges Google, Bing, OpenAI, Apple,
15
- Perplexity and DuckDuckGo publish, `browser.cloudflare` reads the headers of a Cloudflare Transform Rule, and
16
- Chromium's Client Hints tell Windows 11 from Windows 10.
17
-
18
- Site: https://levups.github.io/agentry/
3
+ Agentry is a Ruby gem that tells you who sent an HTTP request: which browser and version, on which operating system
4
+ and device, or which bot. It reads the `User-Agent` header, a line of text every browser and crawler sends with each
5
+ request, such as `Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) … Version/18.1 Mobile/15E148
6
+ Safari/604.1`. Each browser and crawler writes it in its own way, and Agentry knows the formats so that your code
7
+ can ask plain questions such as `browser.bot?` or `browser.device.mobile?`.
8
+
9
+ Apps use the answers to leave bots out of their analytics, to turn crawlers away, to adapt a page to phones, or to
10
+ warn visitors on an old browser. Agentry works in any Ruby program, and gives Rails controllers and views a
11
+ `browser` helper for the current request.
12
+
13
+ Website and guide: https://levups.github.io/agentry/
14
+
15
+ ## Why Agentry
16
+
17
+ Agentry is a fork of [browser](https://github.com/fnando/browser), the gem Nando Vieira wrote, which has not changed
18
+ since June 2025. Code written for browser runs on Agentry as it is: same `Browser` class, same `require "browser"`,
19
+ same methods. Here is what is different:
20
+
21
+ - It answers in 25 µs per request instead of 126 µs with browser 6.2.0, and in 2 µs for a user agent the process
22
+ has seen before (Ruby 4.0.9 with YJIT, see [Performance](#performance)).
23
+ - Its list of bots is updated every week. Of the 882 crawler user agents added to
24
+ [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) since 2024 (test drivers and scanners
25
+ left out), browser misses 353 and Agentry 11.
26
+ - It says what kind of bot sent the request: an AI crawler, a search engine, an assistant fetching a page for a
27
+ person, a vulnerability scanner.
28
+ - It can check that a request claiming to be Googlebot, Bingbot or GPTBot comes from the IP addresses Google,
29
+ Microsoft or OpenAI publish for their crawlers.
30
+ - It reads the headers Cloudflare can add to a request, and the Client Hints Chromium browsers send, which tell
31
+ Windows 11 from Windows 10.
19
32
 
20
33
  ## Installation
21
34
 
22
- ```ruby
23
- gem "agentry"
24
- ```
25
-
26
- ## Usage
35
+ Agentry needs Ruby 3.3 or later. It is tested on Rails 8.0 and 8.1, and works without Rails. Add it to your Gemfile:
27
36
 
28
37
  ```ruby
29
- require "browser"
30
-
31
- browser = Browser.new("Some User Agent", accept_language: "en-us")
32
-
33
- # General info
34
- browser.bot?
35
- browser.chrome?
36
- browser.chromium_based?
37
- browser.core_media?
38
- browser.duck_duck_go?
39
- browser.edge? # Newest MS browser
40
- browser.electron? # Electron Framework
41
- browser.firefox?
42
- browser.full_version
43
- browser.ie?
44
- browser.ie?(6) # detect specific IE version
45
- browser.ie?([">8", "<10"]) # detect specific IE (IE9).
46
- browser.known? # has the browser been successfully detected?
47
- browser.unknown? # the browser wasn't detected.
48
- browser.meta # an array with several attributes
49
- browser.name # readable browser name
50
- browser.nokia?
51
- browser.opera?
52
- browser.opera_mini?
53
- browser.phantom_js?
54
- browser.quicktime?
55
- browser.safari?
56
- browser.safari_webapp_mode?
57
- browser.samsung_browser?
58
- browser.to_s # the meta info joined by space
59
- browser.uc_browser?
60
- browser.version # major version number
61
- browser.webkit?
62
- browser.webkit_full_version
63
- browser.yandex?
64
- browser.wechat?
65
- browser.qq?
66
- browser.weibo?
67
- browser.sputnik?
68
- browser.sougou_browser?
69
- browser.epiphany?
70
-
71
- # Get bot info
72
- browser.bot.name
73
- browser.bot.search_engine?
74
- browser.bot.category # :ai_crawler, :search_engine_crawler, :scanner… (nil for a browser)
75
- browser.bot.ai?
76
- browser.bot.user_triggered? # an AI agent acting for a person right now (ChatGPT-User, Claude-User)
77
- browser.bot?
78
- browser.bot.why? # shows which matcher detected this user agent as a bot.
79
- Browser::Bot.why?(ua)
80
-
81
- # Get device info
82
- browser.device
83
- browser.device.id
84
- browser.device.name
85
- browser.device.unknown?
86
- browser.device.blackberry_playbook?
87
- browser.device.console?
88
- browser.device.ipad?
89
- browser.device.iphone?
90
- browser.device.ipod_touch?
91
- browser.device.kindle?
92
- browser.device.kindle_fire?
93
- browser.device.mobile?
94
- browser.device.nintendo?
95
- browser.device.playstation?
96
- browser.device.ps3?
97
- browser.device.ps4?
98
- browser.device.psp?
99
- browser.device.silk?
100
- browser.device.surface?
101
- browser.device.tablet?
102
- browser.device.tv?
103
- browser.device.vita?
104
- browser.device.wii?
105
- browser.device.wiiu?
106
- browser.device.samsung?
107
- browser.device.switch?
108
- browser.device.xbox?
109
- browser.device.xbox_360?
110
- browser.device.xbox_one?
111
-
112
- # Get platform info
113
- browser.platform
114
- browser.platform.id
115
- browser.platform.name
116
- browser.platform.version # e.g. 9 (for iOS9)
117
- browser.platform.adobe_air?
118
- browser.platform.android?
119
- browser.platform.android?(4.2) # detect Android Jelly Bean 4.2
120
- browser.platform.android_app? # detect webview in an Android app
121
- browser.platform.android_webview? # alias for android_app?
122
- browser.platform.blackberry?
123
- browser.platform.blackberry?(10) # detect specific BlackBerry version
124
- browser.platform.chrome_os?
125
- browser.platform.firefox_os?
126
- browser.platform.ios? # detect iOS
127
- browser.platform.ios?(9) # detect specific iOS version
128
- browser.platform.ios_app? # detect webview in an iOS app
129
- browser.platform.ios_webview? # alias for ios_app?
130
- browser.platform.linux?
131
- browser.platform.mac?
132
- browser.platform.unknown?
133
- browser.platform.windows10?
134
- browser.platform.windows7?
135
- browser.platform.windows8?
136
- browser.platform.windows8_1?
137
- browser.platform.windows?
138
- browser.platform.windows_mobile?
139
- browser.platform.windows_phone?
140
- browser.platform.windows_rt?
141
- browser.platform.windows_touchscreen_desktop?
142
- browser.platform.windows_vista?
143
- browser.platform.windows_wow64?
144
- browser.platform.windows_x64?
145
- browser.platform.windows_x64_inclusive?
146
- browser.platform.windows_xp?
147
- browser.platform.kai_os?
38
+ gem "agentry"
148
39
  ```
149
40
 
150
- ### Aliases
41
+ If the Gemfile already has `gem "browser"`, replace that line: both gems define the `Browser` class, so Agentry
42
+ refuses to load next to browser. Nothing else in your code changes, though some answers do (see
43
+ [Switching from browser](#switching-from-browser)).
151
44
 
152
- To add aliases like `mobile?` and `tablet?` to the base object (e.g
153
- `browser.mobile?`), require the `browser/aliases` file and extend the
154
- Browser::Base object like the following:
45
+ ## A first example
155
46
 
156
47
  ```ruby
157
- require "browser/aliases"
158
- Browser::Base.include(Browser::Aliases)
159
-
160
- browser = Browser.new("Some user agent")
161
- browser.mobile? #=> false
162
- ```
163
-
164
- ### What's being detected?
165
-
166
- - For a list of platform detections, check
167
- [lib/browser/platform.rb](lib/browser/platform.rb)
168
- - For a list of device detections, check
169
- [lib/browser/device.rb](lib/browser/device.rb)
170
- - For a list of bot detections, check
171
- [bots.yml](bots.yml)
172
-
173
- ### Detecting modern browsers
174
-
175
- To detect whether a browser can be considered as modern or not, create a method
176
- that abstracts your versioning constraints. The following example will consider
177
- any of the following browsers as a modern:
48
+ require "browser"
178
49
 
179
- ```ruby
180
- # Expects an Browser instance,
181
- # like in `Browser.new(user_agent, accept_language: language)`.
182
- def modern_browser?(browser)
183
- [
184
- browser.chrome?(">= 65"),
185
- browser.safari?(">= 10"),
186
- browser.firefox?(">= 52"),
187
- browser.ie?(">= 11") && !browser.compatibility_view?,
188
- browser.edge?(">= 15"),
189
- browser.opera?(">= 50"),
190
- browser.facebook?
191
- && browser.safari_webapp_mode?
192
- && browser.webkit_full_version.to_i >= 602
193
- ].any?
194
- end
50
+ user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 " \
51
+ "(KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1"
52
+ browser = Browser.new(user_agent, accept_language: "fr-FR,fr;q=0.9,en;q=0.8")
53
+
54
+ browser.name # => "Safari"
55
+ browser.version # => "18"
56
+ browser.full_version # => "18.1"
57
+ browser.platform.name # => "iOS (iPhone)"
58
+ browser.device.mobile? # => true
59
+ browser.bot? # => false
60
+ browser.accept_language.first.full # => "fr-FR"
195
61
  ```
196
62
 
197
- ### Migrating from browser
198
-
199
- Replace `gem "browser"` with `gem "agentry"` in the Gemfile, nothing else: `require "browser"`, the `Browser`
200
- namespace and every predicate stay. Both gems define `Browser`, so Agentry refuses to load next to `browser`,
201
- with an error saying so, rather than letting one silently override the other. Agentry requires Ruby 3.3 or later
202
- and is tested on Rails 8.0 and 8.1.
203
-
204
- What changes on its own: the bots of the weekly sync are bots from the day you switch (600 user agents of the
205
- corpus that browser took for humans), so analytics that leave bots out count fewer visitors from that day; 792
206
- user agents named `Generic Bot` get a name; no browser, platform or device answer moves, and test drivers stay out
207
- of `bot?`. To roll back, put `gem "browser"` back, after removing the calls to what browser lacks
208
- (`Browser.cache_size=`, `bot.category`, `bot.ai?`, `bot.user_triggered?`). The
209
- [migration guide](https://levups.github.io/agentry/guide.html#migrating) has the details.
210
-
211
- ### Rails integration
63
+ The file to require is `browser` and the class is `Browser`, the names browser used. `accept_language:` is optional:
64
+ it takes the `Accept-Language` header, the languages the visitor's browser asks for.
212
65
 
213
- Just add it to the Gemfile.
214
-
215
- ```ruby
216
- gem "agentry"
217
- ```
218
-
219
- This adds a helper method called `browser`, that inspects your current user
220
- agent.
66
+ In Rails there is nothing to build. The `browser` helper reads the current request's headers:
221
67
 
222
68
  ```erb
223
- <% if browser.ie?(6) %>
224
- <p class="disclaimer">You're running an older IE version. Please update it!</p>
69
+ <% if browser.device.mobile? %>
70
+ <%= render "mobile_menu" %>
225
71
  <% end %>
226
72
  ```
227
73
 
228
- If you want to use Browser on your Rails app but don't want to taint your
229
- controller, use the following line on your Gemfile:
74
+ Outside Rails, pass the Rack env as `headers:` so that Agentry can read Client Hints and Cloudflare headers too:
75
+ `Browser.new(user_agent, accept_language: language, headers: env)`.
230
76
 
231
- ```ruby
232
- gem "agentry", require: "browser/browser"
233
- ```
77
+ The [guide](https://levups.github.io/agentry/guide.html#usage) lists every method: browsers from Chrome and Safari
78
+ to WeChat and Yandex, devices such as the iPad, Kindle, PlayStation and Xbox, and platforms down to each version of
79
+ Windows. Version checks take a number or constraints, such as `browser.chrome?(">= 65")` or
80
+ `browser.platform.ios?(9)`.
234
81
 
235
- ### Accept Language
236
-
237
- Parses the accept-language header from an HTTP request and produces an array of
238
- language objects sorted by quality.
82
+ ## Bots
239
83
 
240
84
  ```ruby
241
- browser = Browser.new("Some User Agent", accept_language: "en-us")
242
-
243
- browser.accept_language.class
244
- #=> Array
85
+ browser = Browser.new("Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)")
245
86
 
246
- language = browser.accept_language.first
247
-
248
- language.code
249
- #=> "en"
250
-
251
- language.region
252
- #=> "US"
253
-
254
- language.full
255
- #=> "en-US"
256
-
257
- language.quality
258
- #=> 1.0
259
-
260
- language.name
261
- #=> "English/United States"
87
+ browser.bot? # => true
88
+ browser.bot.name # => "ChatGPT"
89
+ browser.bot.category # => :ai_crawler
90
+ browser.bot.ai? # => true
91
+ browser.bot.user_triggered? # => false
262
92
  ```
263
93
 
264
- Result is always sorted in quality order from highest to lowest. As per the HTTP
265
- spec:
94
+ `browser.bot?` is true for an empty user agent, for one that matches `/crawl|fetch|search|monitoring|spider|bot/`,
95
+ and for one that contains a name from [bots.yml](bots.yml). The net is wide on purpose, as it was in browser.
266
96
 
267
- - omitting the quality value implies 1.0.
268
- - quality value equal to zero means that is not accepted by the client.
97
+ Every Monday, a workflow adds the bots of [crawler-user-agents](https://github.com/monperrus/crawler-user-agents)
98
+ and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (both MIT) to `bots.yml` and files each under a
99
+ category. It opens a pull request that lists every answer the change moves, and a maintainer merges it. The
100
+ [Bots page](https://levups.github.io/agentry/bots.html#coverage) counts, per category, how many of those bots
101
+ browser and Agentry detect.
269
102
 
270
- ### Internet Explorer
103
+ ### Categories
271
104
 
272
- Internet Explorer has a compatibility view mode that allows newer versions
273
- (IE8+) to run as an older version. Browser will always return the navigator
274
- version, ignoring the compatibility view version, when defined. If you need to
275
- get the engine's version, you have to use `Browser#msie_version` and
276
- `Browser#msie_full_version`.
105
+ `browser.bot.category` is what the bot says it is, named after Cloudflare's verified bot categories where one fits:
106
+ `search_engine_crawler`, `ai_crawler`, `ai_search`, `ai_assistant`, `page_preview`, `feed_fetcher`,
107
+ `monitoring_and_analytics`, `advertising_and_marketing`, `archiver`, `security` and a few more. Agentry adds
108
+ `ai_agent`, `http_library`, `scanner` and `browser_automation`. `bot.ai?` is true for the four AI categories, and
109
+ `bot.user_triggered?` for `ai_assistant` and `ai_agent`: a person is driving them at that moment, as with
110
+ ChatGPT-User or Claude-User. The category is `nil` for a browser, for a bot exception, and for a bot no list names.
277
111
 
278
- So, let's say an user activates compatibility view in a IE11 browser. This is
279
- what you'll get:
112
+ HTTP libraries (`curl`, `python-requests`) and scanners (sqlmap, Nikto) are bots. Test drivers (headless Chrome,
113
+ Playwright, Puppeteer, Selenium) are not: your own system tests drive a real browser through them, and would
114
+ otherwise count as bots. They get the `browser_automation` category, so production code can still catch them with
115
+ `browser.bot? || browser.bot.category == :browser_automation`. PhantomJS stays a bot, as in browser.
280
116
 
281
- ```ruby
282
- browser.version
283
- #=> 11
284
-
285
- browser.full_version
286
- #=> 11.0
117
+ ### Your own rules
287
118
 
288
- browser.msie_version
289
- #=> 7
119
+ A matcher is anything that responds to `call(ua, browser)`. This one makes every user agent containing
120
+ `externalhit` a bot:
290
121
 
291
- browser.msie_full_version
292
- #=> 7.0
293
-
294
- browser.compatibility_view?
295
- #=> true
122
+ ```ruby
123
+ Browser::Bot.matchers << ->(ua, _browser) { ua.match?(/externalhit/i) }
296
124
  ```
297
125
 
298
- This behavior changed in browser v1.0.0; previously there wasn't a way of getting the
299
- real browser version.
126
+ `browser.bot.why?` returns the matcher that fired. The lists can change at runtime too
127
+ (`Browser::Bot.bots.merge!(new_bots_hash)`), and the
128
+ [Bots page](https://levups.github.io/agentry/bots.html#matchers) shows how to remove the built-in matchers or go
129
+ back to the detection of browser 2.x.
300
130
 
301
- ### Safari
131
+ ### Verified crawlers
302
132
 
303
- iOS webviews and web apps aren't detected as Safari anymore, so be aware of that
304
- if that's your case. You can use a combination of platform and webkit detection
305
- to do whatever you want.
133
+ Anyone can send `Googlebot` as their user agent. Google, Bing, OpenAI, Apple, Perplexity and DuckDuckGo publish the
134
+ IP ranges their crawlers use, and Agentry ships them, refreshed by the weekly sync. `bot.verified?(ip)` says whether
135
+ a request comes from the ranges of the company its user agent names:
306
136
 
307
137
  ```ruby
308
- # iPad's Safari running as web app mode.
309
- browser = Browser.new("Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405")
310
-
311
- browser.safari?
312
- #=> false
138
+ browser.bot.owner # => :google for a user agent claiming to be Googlebot
139
+ browser.bot.verified?(request.remote_ip) # => true when the IP is in Google's ranges
313
140
 
314
- browser.webkit?
315
- #=> true
316
-
317
- browser.platform.ios?
318
- #=> true
141
+ if browser.bot.owner && !browser.bot.verified?(request.remote_ip)
142
+ head :forbidden # claims to be a known crawler, comes from somewhere else
143
+ end
319
144
  ```
320
145
 
321
- ### Bots
322
-
323
- The bot detection is quite aggressive. Anything that matches at least one of the
324
- following requirements will be considered a bot.
325
-
326
- - Empty user agent string
327
- - User agent that matches `/crawl|fetch|search|monitoring|spider|bot/`
328
- - Any known bot listed under
329
- [bots.yml](bots.yml)
330
-
331
- Every week, `script/sync_bots.rb` adds the bots of
332
- [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) and
333
- [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (both MIT) to the end of `bots.yml`, and
334
- categorizes them in `bot_categories.yml`, through a pull request that lists the answers that change. How many of
335
- those lists' bots browser and Agentry detect, per category, is on the
336
- [Bots page](https://levups.github.io/agentry/bots.html#coverage).
337
-
338
- `browser.bot.category` names what the bot declares it is, with the names of Cloudflare's verified bot categories
339
- where one fits (`search_engine_crawler`, `ai_crawler`, `ai_search`, `ai_assistant`, `page_preview`,
340
- `feed_fetcher`, `monitoring_and_analytics`, `advertising_and_marketing`, `archiver`, `security`…), plus
341
- `ai_agent`, `http_library`, `scanner` and `browser_automation`. `bot.ai?` is true for the four AI categories,
342
- `bot.user_triggered?` for `ai_assistant` and `ai_agent`. The category is `nil` for a browser, a bot exception
343
- and a bot no list names.
344
-
345
- HTTP libraries (`curl`, `got`, `python-requests`) and scanners (sqlmap, Nikto, Censys) are bots, as `curl` and
346
- `python-requests` already were in browser. Test drivers (headless Chrome, Playwright, Puppeteer, Selenium) are
347
- not: an app's system tests drive a real browser through them, and the headless Chrome of Playwright and Selenium
348
- sends `HeadlessChrome`. They get the `browser_automation` category instead, so an app can tell them apart in
349
- production (`browser.bot? || browser.bot.category == :browser_automation`). PhantomJS stays a bot, as in
350
- browser.
351
-
352
- To add custom matchers, you can add a callable object to
353
- `Browser::Bot.matchers`. The following example matches everything that has a
354
- `externalhit` substring on it. The bot name will always be `General Bot`.
146
+ The check searches the ranges in memory, with no network call, and an IP that does not parse answers `false`. Pass
147
+ the IP as your proxies resolve it: `request.remote_ip` in Rails, with `trusted_proxies` set.
355
148
 
356
- ```ruby
357
- Browser::Bot.matchers << ->(ua, _browser) { ua.match?(/externalhit/i) }
358
- ```
359
-
360
- To clear all matchers, including the ones that are bundled, use
361
- `Browser::Bot.matchers.clear`. You can re-add built-in matchers by doing the
362
- following:
149
+ `verified?(ip, reverse_dns: true)` adds the DNS check Google, Bing and Apple document, at the cost of two DNS
150
+ queries for an IP outside the ranges. A fake Googlebot controls its own reverse DNS and can make that lookup wait
151
+ for the timeout (1 second per nameserver of `/etc/resolv.conf`), so a cache, off by default, can remember each
152
+ answer, failures included:
363
153
 
364
154
  ```ruby
365
- Browser::Bot.matchers += Browser::Bot.default_matchers
155
+ Browser::Bot::IPRanges.dns_cache_size = 10_000 # 0, the default, caches nothing
156
+ Browser::Bot::IPRanges.dns_cache_ttl = 3600 # seconds
157
+ Browser::Bot::IPRanges.dns_timeout = 0.5 # seconds per query and nameserver
158
+ Browser::Bot::IPRanges.refresh # fetches the ranges again, in a long-lived process
366
159
  ```
367
160
 
368
- To restore v2's bot detection, remove the following matchers:
161
+ ## Cloudflare and Client Hints
369
162
 
370
- ```ruby
371
- Browser::Bot.matchers.delete(Browser::Bot::KeywordMatcher)
372
- Browser::Bot.matchers.delete(Browser::Bot::EmptyUserAgentMatcher)
373
- ```
163
+ When the Rack env is passed as `headers:` (the Rails helper and the middleware do it for you), Agentry reads two
164
+ more sources.
374
165
 
375
- To extend the bot list, you can manipulate the methods below:
166
+ Behind Cloudflare, a Transform Rule can copy what Cloudflare knows about a request into headers, and
167
+ `browser.cloudflare` reads them:
376
168
 
377
169
  ```ruby
378
- Browser::Bot.bots.merge!(new_bots_hash)
379
- Browser::Bot.bot_exceptions += new_exceptions
380
- Browser::Bot.search_engines.merge!(new_search_engines_hash)
381
- Browser::Bot.category_by_keyword["faraday"] = :http_library
170
+ browser.cloudflare.verified_bot? # => true for a bot Cloudflare verified
171
+ browser.cloudflare.bot_category # => :search_engine_crawler, with the names of bot.category
172
+ browser.cloudflare.bot_score # => 1 (automated) to 99 (human), with Bot Management only
173
+ browser.cloudflare.country # => "FR", from CF-IPCountry
382
174
  ```
383
175
 
384
- ### Verified crawlers
385
-
386
- Google, Bing, OpenAI, Apple, Perplexity and DuckDuckGo publish their crawlers' IP ranges. They ship in
387
- `bot_ip_ranges.yml` (as JSON in the gem), rewritten by the weekly sync (`script/sync_ip_ranges.rb`). The lookup is a binary search, no
388
- network call; an IP that does not parse answers `false`.
176
+ The header names can be changed with `Browser::Cloudflare.header_names = {bot_score: "X-My-Score"}`. These headers
177
+ prove something only when every request goes through Cloudflare: if clients can reach your server directly, they
178
+ can send the headers themselves. The [guide](https://levups.github.io/agentry/guide.html#cloudflare) has the rule to
179
+ create and how to lock the origin.
389
180
 
390
- ```ruby
391
- browser.bot.owner # => :google for a user agent claiming Googlebot
392
- browser.bot.verified?(request.remote_ip) # => whether the IP is in Google's ranges
393
- browser.bot.verified?(request.remote_ip, reverse_dns: true) # also Google's DNS check: two DNS queries
394
- Browser::Bot::IPRanges.refresh # fetches the ranges again, in a long-lived process
395
- ```
181
+ Chromium browsers no longer put the real Windows, macOS or Android version in their user agent. They send it in
182
+ Client Hints (`Sec-CH-UA`, `Sec-CH-UA-Mobile`, `Sec-CH-UA-Platform`, `Sec-CH-UA-Platform-Version`), which Agentry
183
+ uses to refine the platform and the device: `browser.platform.windows11?` needs them. Without `headers:`, every
184
+ answer comes from the user agent alone, as in browser. The
185
+ [guide](https://levups.github.io/agentry/guide.html#client-hints) shows how to ask browsers for the platform
186
+ version. `bench/signals.rb` measures what reading these headers costs.
396
187
 
397
- ### Cloudflare and Client Hints
188
+ ## Accept-Language
398
189
 
399
- `Browser.new(user_agent, accept_language:, headers: rack_env)` also reads the request's headers; the Rails helper
400
- and the middleware pass them. `browser.cloudflare` reads the headers a Cloudflare Transform Rule sets
401
- (`verified_bot?`, `bot_category`, `bot_score`) and `CF-IPCountry` (`country`); their names are configurable with
402
- `Browser::Cloudflare.header_names = {bot_score: "X-My-Score"}`. Chromium's Client Hints (`Sec-CH-UA`,
403
- `Sec-CH-UA-Mobile`, `Sec-CH-UA-Platform`, `Sec-CH-UA-Platform-Version`) refine the platform and the device, and
404
- `browser.platform.windows11?` needs them. Without `headers:`, every answer is the user agent's, as before. The
405
- rule to create and the details are in the [guide](https://levups.github.io/agentry/guide.html#cloudflare).
406
-
407
- `bench/signals.rb` measures what they cost.
408
-
409
- ### Middleware
410
-
411
- You can use the `Browser::Middleware` to redirect user agents.
190
+ `browser.accept_language` turns the `Accept-Language` header into a list of languages, the most wanted first:
412
191
 
413
192
  ```ruby
414
- use Browser::Middleware do
415
- redirect_to "/upgrade" if browser.ie?
416
- end
193
+ language = Browser.new(user_agent, accept_language: "en-us").accept_language.first
194
+
195
+ language.code # => "en"
196
+ language.region # => "US"
197
+ language.full # => "en-US"
198
+ language.quality # => 1.0
199
+ language.name # => "English/United States"
417
200
  ```
418
201
 
419
- If you're using Rails, you can use the route helper methods. Just add something
420
- like the following to a initializer file (`config/initializers/browser.rb`).
202
+ As the HTTP specification says, a language without a quality value has 1.0, and one with a quality of 0 is not
203
+ accepted, so it is left out.
421
204
 
422
- ```ruby
423
- Rails.configuration.middleware.use Browser::Middleware do
424
- redirect_to upgrade_path if browser.ie?
425
- end
426
- ```
205
+ ## Middleware
427
206
 
428
- If you need access to the `Rack::Request` object (e.g. to exclude a path), you
429
- can do so with `request`.
207
+ `Browser::Middleware` redirects requests based on the browser, before they reach your app. In Rails, from an
208
+ initializer such as `config/initializers/browser.rb`, route helpers work and `request` is the `Rack::Request`:
430
209
 
431
210
  ```ruby
432
211
  Rails.configuration.middleware.use Browser::Middleware do
@@ -434,57 +213,70 @@ Rails.configuration.middleware.use Browser::Middleware do
434
213
  end
435
214
  ```
436
215
 
437
- ### Restrictions
216
+ In another Rack app, `use Browser::Middleware do … end` takes the same block. To use Agentry in Rails without the
217
+ `browser` helper, require only the library: `gem "agentry", require: "browser/browser"`.
438
218
 
439
- - User agent has a size limit of 2048 bytes. This can be customized through
440
- `Browser.user_agent_size_limit=(size)`.
441
- - Accept-Language has a size limit of 2048 bytes. This can be customized through
442
- `Browser.accept_language_size_limit=(size)`.
219
+ ## Cache, limits and boot
443
220
 
444
- If size is not respected, then `Browser::Error` is raised.
221
+ Each process remembers what it found for the last 1,000 user agents (browser, platform, device and bot answers)
222
+ and the last 1,000 `Accept-Language` headers, about 1 KB per user agent. A user agent seen before costs about 2 µs.
223
+ The cache empties itself when it is full, and when `Browser.matchers`, `Browser::Bot.matchers`,
224
+ `Browser::Platform.matchers`, `Browser::Device.matchers`, the bot list or its exceptions change. Setting
225
+ `Browser.cache_size` empties it too, for example after renaming an entry of `Browser::Bot.bots` in place:
445
226
 
446
227
  ```ruby
447
- Browser.user_agent_size_limit = 4096
448
- Browser.accept_language_size_limit = 4096
228
+ Browser.cache_size = 5_000 # 0 turns it off
449
229
  ```
450
230
 
451
- ### Cache
231
+ 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`.
452
233
 
453
- Each process keeps what it detected for the last 1,000 distinct user agents (browser, platform, device, bot
454
- answers) and the last 1,000 Accept-Language headers it parsed, about 1 KB per user agent: a repeated user agent
455
- costs about 2 µs. The cache empties itself when full, and when `Browser.matchers`, `Browser::Bot.matchers`,
456
- `Browser::Platform.matchers`, `Browser::Device.matchers`, the bot list or its exceptions change. Setting
457
- `Browser.cache_size` empties it too, for example after renaming an entry of `Browser::Bot.bots` in place.
234
+ The lists and matchers are built on first use: the first request of each process takes about 13 ms more, the
235
+ following ones 0.2 ms. `Browser.eager_load!` builds them at boot, so that a server that loads the app before
236
+ forking (Puma's `preload_app!`, Unicorn, Pitchfork) shares them with every worker. Rails calls it whenever it eager
237
+ loads, which production does by default. Elsewhere, call `Browser.eager_load!` before the fork.
458
238
 
459
- ```ruby
460
- Browser.cache_size = 5_000 # 0 turns it off
461
- ```
239
+ ## Switching from browser
462
240
 
463
- ### Preloading before a fork
241
+ Replace `gem "browser"` with `gem "agentry"` in the Gemfile. Nothing else in the code changes.
464
242
 
465
- The lists, the matchers and the bot keyword tries are built on first use: the first request of each process pays
466
- about 13 ms for them, the next ones 0.2 ms. `Browser.eager_load!` builds them up front, so a server that preloads
467
- its app before forking (Puma's `preload_app!`, Unicorn, Pitchfork) shares them with every worker. Rails calls it on
468
- its own when it eager loads, which production does by default. Elsewhere, call it before the fork:
243
+ Some answers change from the day you switch, because of the fresher bot list: 600 user agents of the corpus that
244
+ browser took for people are bots in Agentry, so analytics that leave bots out will count fewer visitors, and 792
245
+ user agents that browser called `Generic Bot` get a real name. No browser, platform or device answer changes, and
246
+ test drivers still are not bots.
469
247
 
470
- ```ruby
471
- Browser.eager_load!
472
- ```
248
+ To go back, remove the calls to what browser does not have (`Browser.cache_size=`, the `headers:` argument,
249
+ `bot.category`, `bot.ai?`, `bot.user_triggered?`, `bot.verified?`, `browser.cloudflare`), then put
250
+ `gem "browser"` back. The [migration guide](https://levups.github.io/agentry/guide.html#migrating) has the details.
473
251
 
474
252
  ## Performance
475
253
 
476
- `bench/request.rb` replays what a web request asks (name, version, `bot?`, platform, device, accept-language) over
477
- 2,000 distinct user agents of the corpus and over 20 repeated ones, median of 7. On Ruby 4.0.9 with YJIT, browser
478
- 6.2.0 takes 126 µs and 245 objects per request, Agentry 25 µs and 52 objects on a new user agent, 2 µs and 12
479
- objects on a repeated one. All four runs, with and without YJIT, are on the
480
- [benchmarks page](https://levups.github.io/agentry/benchmarks.html).
254
+ `bench/request.rb` asks what a web request typically asks (name, version, `bot?`, platform, device,
255
+ accept-language) over 2,000 distinct user agents of the corpus and over 20 repeated ones, and keeps the median of 7
256
+ runs. On Ruby 4.0.9 with YJIT:
257
+
258
+ | Per request | browser 6.2.0 | Agentry |
259
+ |---|---:|---:|
260
+ | A new user agent | 126 µs, 245 objects | 25 µs, 52 objects |
261
+ | A user agent seen before | 114 µs, 217 objects | 2 µs, 12 objects |
262
+
263
+ The [benchmarks page](https://levups.github.io/agentry/benchmarks.html) has the runs without YJIT, the machine, and
264
+ why Agentry is faster. To run them again:
481
265
 
482
266
  ```bash
483
- bundle exec rake bench UPSTREAM=path/to/upstream # fnando/browser at dd88933
267
+ bundle exec rake bench UPSTREAM=path/to/upstream # a clone of fnando/browser at dd88933
484
268
  ```
485
269
 
486
- It runs `ruby [--yjit] -I lib bench/request.rb --write` for each gem and JIT, which records the run (gem, commit,
487
- Ruby, YJIT, machine, date) in `docs/benchmarks.json`, then `rake docs:render` rewrites the site's numbers from it.
270
+ It runs `ruby [--yjit] -I lib bench/request.rb --write` for each gem and JIT setting, records each run (gem,
271
+ commit, Ruby, YJIT, machine, date) in `docs/benchmarks.json`, and `rake docs:render` then rewrites the site's
272
+ numbers from that file.
273
+
274
+ ## How the answers are checked
275
+
276
+ Agentry runs browser's own test suite, unmodified, next to its own tests. The repository also holds a corpus of
277
+ 20,328 user agents, gathered from uap-core, crawler-user-agents and browser's test fixtures. On every pull request,
278
+ CI asks both browser and Agentry about each of them and compares the answers. An answer that differs is either
279
+ listed in `corpus/expected_differences.jsonl` as an intended change, such as a newly known bot, or fails the build.
488
280
 
489
281
  ## Development
490
282
 
@@ -494,8 +286,7 @@ bundle exec rake test
494
286
  bundle exec rubocop
495
287
  ```
496
288
 
497
- A change that is not meant to change an answer proves it on the corpus of 20,000 user agents, against a plain
498
- clone of [fnando/browser](https://github.com/fnando/browser) at `dd88933`, as CI does on every pull request:
289
+ To compare every answer with [fnando/browser](https://github.com/fnando/browser) at `dd88933`, as CI does:
499
290
 
500
291
  ```bash
501
292
  ruby -I path/to/upstream/lib script/corpus_snapshot.rb > /tmp/upstream.jsonl
@@ -503,10 +294,10 @@ ruby -I lib script/corpus_snapshot.rb > /tmp/agentry.jsonl
503
294
  ruby script/corpus_diff.rb /tmp/upstream.jsonl /tmp/agentry.jsonl
504
295
  ```
505
296
 
506
- The diff passes when the answers that differ are exactly those of `corpus/expected_differences.jsonl`: a change
507
- meant to change answers rewrites it with `ruby script/corpus_diff.rb --write …` and shows its diff.
297
+ It passes when the answers that differ are exactly those in `corpus/expected_differences.jsonl`. A change meant to
298
+ change answers rewrites that file with `ruby script/corpus_diff.rb --write …`, and its diff shows what changed.
508
299
 
509
- The weekly bot sync (`.github/workflows/bot-sync.yml`) runs the same steps by hand:
300
+ The weekly bot sync (`.github/workflows/bot-sync.yml`) runs these steps, which also work by hand:
510
301
 
511
302
  ```bash
512
303
  ruby script/sync_bots.rb # bots.yml and bot_categories.yml
@@ -516,37 +307,39 @@ ruby script/bot_coverage.rb path/to/upstream/lib # docs/bot_coverage.json, and i
516
307
  ruby script/render_docs.rb # the site's numbers, from docs/*.json and the lists
517
308
  ```
518
309
 
519
- The numbers of `docs/*.html` sit between `<!-- render:… -->` markers and come from `docs/benchmarks.json`,
520
- `docs/bot_coverage.json`, the bot lists and `corpus/expected_differences.jsonl`: CI fails when a page differs from
521
- what `bundle exec rake docs:render` writes.
310
+ The numbers on the site's pages (`docs/*.html`) sit between `<!-- render:… -->` markers and come from
311
+ `docs/benchmarks.json`, `docs/bot_coverage.json`, the bot lists and `corpus/expected_differences.jsonl`. CI fails
312
+ when a page differs from what `bundle exec rake docs:render` writes. A new code block on a page needs
313
+ `bundle exec rake docs:highlight`.
522
314
 
523
- To add a bot, add its user agent to `test/ua_bots.yml` and its readable name to `bots.yml` above the synced
524
- section, keyed by a lowercased string the user agent contains. A search engine goes in `test/ua_search_engines.yml`,
315
+ To add a bot, add its user agent to `test/ua_bots.yml`, and its name to `bots.yml` above the synced section, under
316
+ a lowercase string its user agent contains. A search engine goes in `test/ua_search_engines.yml`,
525
317
  `test/ua_bots.yml` and `search_engines.yml`.
526
318
 
319
+ See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
320
+
527
321
  ### Releasing
528
322
 
529
- Pull requests leave `CHANGELOG.md` alone: a release starts by writing its notes under `## [Unreleased]`, from the
530
- pull requests merged since the last tag, without committing them. Then, from an up-to-date `main` whose CI is
531
- green:
323
+ Pull requests leave `CHANGELOG.md` alone. A release starts by writing its notes under `## [Unreleased]`, from the
324
+ pull requests merged since the last tag, without committing them. Then, from an up-to-date `main` with green CI:
532
325
 
533
326
  ```sh
534
- bin/release minor --dry-run # the notes and the changes, nothing written
327
+ bin/release minor --dry-run # shows the notes and the changes, writes nothing
535
328
  bin/release minor # or patch, major, or an exact version such as 1.2.0
536
329
  ```
537
330
 
538
- `main` takes no direct push, so `bin/release` dates the Unreleased section and bumps the version on a branch
539
- `release/vX.Y.Z`, opens its pull request, which merges itself once CI is green, and waits for it. It then pushes a
540
- signed tag `vX.Y.Z` on the merge commit (`bin/release --tag X.Y.Z` does it if you stopped waiting). The tag starts
541
- the Release workflow, which publishes the gem to RubyGems through trusted publishing and creates the GitHub release
542
- with the version's notes and the `.gem`.
331
+ `main` takes no direct push, so `bin/release` dates the notes and bumps the version on a `release/vX.Y.Z` branch,
332
+ opens a pull request that merges itself once CI is green, and waits for it. It then pushes a signed `vX.Y.Z` tag on
333
+ the merge commit (`bin/release --tag X.Y.Z` does it if you stopped waiting). The tag starts the Release workflow,
334
+ which checks that the tag is signed and on `main`, then publishes the gem to RubyGems through trusted publishing
335
+ and creates the GitHub release with the notes and the `.gem`.
543
336
 
544
337
  ## License
545
338
 
546
339
  MIT, by Level UP Solutions, see [LICENSE.md](LICENSE.md). Originally written by Nando Vieira as
547
340
  [fnando/browser](https://github.com/fnando/browser) (MIT). The corpus gathers user agents from
548
341
  [ua-parser/uap-core](https://github.com/ua-parser/uap-core) (Apache 2.0),
549
- [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT) and browser's test fixtures; the bot
342
+ [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT) and browser's test fixtures. The bot
550
343
  list and its categories come from [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT)
551
- and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (MIT); the crawler IP ranges are those their
552
- owners publish for that purpose: see [corpus/SOURCES.md](corpus/SOURCES.md).
344
+ and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (MIT). The crawler IP ranges are the ones
345
+ their owners publish for that purpose. [corpus/SOURCES.md](corpus/SOURCES.md) lists every source.
@@ -15,8 +15,10 @@ module Browser
15
15
  google-read-aloud google-safety google-site-verification googleagent-urlcontext googlebot
16
16
  googleother googleproducer mediapartners-google storebot-google
17
17
  ],
18
- # https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot
19
- domains: %w[googlebot.com google.com googleusercontent.com],
18
+ # https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot. Not
19
+ # googleusercontent.com: a Google Cloud VM's reverse DNS is x.x.x.x.bc.googleusercontent.com, and the
20
+ # user-triggered fetchers it also names are in the published ranges.
21
+ domains: %w[googlebot.com google.com],
20
22
  urls: %w[common-crawlers.json special-crawlers.json user-triggered-fetchers.json
21
23
  user-triggered-fetchers-google.json user-triggered-agents.json].map { |file| "#{GOOGLE_RANGES_URL}/#{file}" }
22
24
  },
@@ -50,7 +52,15 @@ module Browser
50
52
  FETCH_TIMEOUT_SECONDS = 10
51
53
  # Owners move their files: Google's old URLs redirect to the new ones.
52
54
  MAX_REDIRECTS = 3
53
- DNS_TIMEOUT_SECONDS = 2
55
+ # How long each DNS query waits for each nameserver of /etc/resolv.conf: a resolver answers in milliseconds,
56
+ # and only a stalled one (or an attacker's own reverse zone) ever waits that long.
57
+ DNS_TIMEOUT_SECONDS = 1
58
+ # The broadest prefix a source may publish: a wider one, or one outside the public unicast space, is a broken
59
+ # or compromised source, never a crawler's range.
60
+ MIN_PREFIX_LENGTH_BY_FAMILY = {Socket::AF_INET => 12, Socket::AF_INET6 => 24}.freeze
61
+ # Multicast and reserved: 224.0.0.0/3 (with 240.0.0.0/4 and the broadcast address), ff00::/8.
62
+ NON_UNICAST_BY_FAMILY = {Socket::AF_INET => IPAddr.new("224.0.0.0/3"), Socket::AF_INET6 => IPAddr.new("ff00::/8")}
63
+ .freeze
54
64
 
55
65
  # The owner whose crawler the lowercase user agent claims to be.
56
66
  def self.owner(ua)
@@ -71,19 +81,80 @@ module Browser
71
81
  index >= 0 && number <= ends[index]
72
82
  end
73
83
 
84
+ @dns_mutex = Mutex.new
85
+ @dns_verdicts = {}
86
+
87
+ class << self
88
+ attr_reader :dns_timeout, :dns_cache_size, :dns_cache_ttl
89
+ end
90
+
91
+ def self.positive?(seconds)
92
+ seconds.is_a?(Numeric) && seconds.positive?
93
+ end
94
+
95
+ # Seconds each DNS query waits for each nameserver.
96
+ def self.dns_timeout=(seconds)
97
+ raise ArgumentError, "dns_timeout must be a positive number, got #{seconds.inspect}" unless positive?(seconds)
98
+
99
+ @dns_timeout = seconds
100
+ end
101
+
102
+ # How many reverse DNS verdicts to remember, the oldest dropped first: 0, the default, remembers none. A forged
103
+ # Googlebot then costs its DNS queries once per IP and TTL, not on every request.
104
+ def self.dns_cache_size=(size)
105
+ unless size.is_a?(Integer) && size >= 0
106
+ raise ArgumentError, "dns_cache_size must be an Integer >= 0, got #{size.inspect}"
107
+ end
108
+
109
+ @dns_cache_size = size
110
+ clear_dns_cache
111
+ end
112
+
113
+ # Seconds a verdict holds, a failed lookup's as long as any other's.
114
+ def self.dns_cache_ttl=(seconds)
115
+ raise ArgumentError, "dns_cache_ttl must be a positive number, got #{seconds.inspect}" unless positive?(seconds)
116
+
117
+ @dns_cache_ttl = seconds
118
+ end
119
+
120
+ def self.clear_dns_cache
121
+ @dns_mutex.synchronize { @dns_verdicts.clear }
122
+ end
123
+
124
+ self.dns_timeout = DNS_TIMEOUT_SECONDS
125
+ self.dns_cache_size = 0
126
+ self.dns_cache_ttl = 3600
127
+
74
128
  # Google's documented method: the reverse lookup names one of the owner's
75
- # domains, and that name resolves back to the IP. Two DNS queries.
129
+ # domains, and that name resolves back to the IP. Two DNS queries, or
130
+ # none when dns_cache_size holds a fresh verdict for the IP.
76
131
  def self.verified_by_dns?(owner, ip)
77
132
  address = parse(ip)
78
133
  domains = SOURCES.dig(owner, :domains)
79
134
  return false unless address && domains&.any?
135
+ return resolves_to?(address, domains) if dns_cache_size.zero?
136
+
137
+ key = "#{owner} #{address}"
138
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ verdict, expires_at = @dns_mutex.synchronize { @dns_verdicts[key] }
140
+ return verdict if expires_at && now < expires_at
80
141
 
142
+ verdict = resolves_to?(address, domains)
143
+ @dns_mutex.synchronize do
144
+ @dns_verdicts.delete(key)
145
+ @dns_verdicts.shift while @dns_verdicts.size >= dns_cache_size
146
+ @dns_verdicts[key] = [verdict, now + dns_cache_ttl]
147
+ end
148
+ verdict
149
+ end
150
+
151
+ def self.resolves_to?(address, domains)
81
152
  require "resolv"
82
153
  Resolv::DNS.open do |dns|
83
- dns.timeouts = DNS_TIMEOUT_SECONDS
154
+ dns.timeouts = dns_timeout
84
155
  host = dns.getname(address.to_s).to_s.downcase
85
156
  domains.any? { |domain| host == domain || host.end_with?(".#{domain}") } &&
86
- dns.getaddresses(host).any? { |resolved| IPAddr.new(resolved.to_s) == address }
157
+ dns.getaddresses(Resolv::DNS::Name.create("#{host}.")).any? { |resolved| IPAddr.new(resolved.to_s) == address }
87
158
  end
88
159
  rescue Resolv::ResolvError, Resolv::ResolvTimeout, SystemCallError
89
160
  false
@@ -114,7 +185,10 @@ module Browser
114
185
  def self.fetch
115
186
  SOURCES.to_h do |owner, source|
116
187
  addresses = source[:urls].flat_map { |url| fetch_prefixes(url) }.map do |prefix|
117
- IPAddr.new(prefix)
188
+ address = IPAddr.new(prefix)
189
+ raise Error, "#{owner}: implausible prefix #{prefix.inspect}" unless plausible?(address)
190
+
191
+ address
118
192
  rescue ArgumentError
119
193
  raise Error, "#{owner}: invalid prefix #{prefix.inspect}"
120
194
  end
@@ -135,20 +209,36 @@ module Browser
135
209
  end
136
210
  break unless response.is_a?(Net::HTTPRedirection)
137
211
 
138
- uri = uri.merge(response["Location"])
212
+ raise Error, "#{url}: HTTP #{response.code} without a Location" unless response["Location"]
213
+
214
+ location = uri.merge(response["Location"])
215
+ # Only the owner's own host, over HTTPS, publishes its ranges.
216
+ raise Error, "#{url}: redirected to #{location}" unless location.is_a?(URI::HTTPS) && location.host == uri.host
217
+
218
+ uri = location
139
219
  end
140
220
  raise Error, "#{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
141
221
 
142
- prefixes = JSON.parse(response.body).fetch("prefixes").map do |prefix|
143
- prefix["ipv4Prefix"] || prefix["ipv6Prefix"]
144
- end
145
- raise Error, "#{url}: no prefixes" if prefixes.empty?
222
+ document = JSON.parse(response.body)
223
+ entries = document.fetch("prefixes") if document.is_a?(Hash)
224
+ raise Error, "#{url}: no prefixes" unless entries.is_a?(Array) && entries.any?
225
+
226
+ entries.map do |entry|
227
+ prefix = entry["ipv4Prefix"] || entry["ipv6Prefix"] if entry.is_a?(Hash)
228
+ raise Error, "#{url}: invalid entry #{entry.inspect[0, 100]}" unless prefix.is_a?(String)
146
229
 
147
- prefixes
148
- rescue JSON::ParserError, KeyError, Timeout::Error, IOError, SystemCallError, OpenSSL::SSL::SSLError => exception
230
+ prefix
231
+ end
232
+ rescue JSON::ParserError, KeyError, URI::Error, Timeout::Error, IOError, SystemCallError, OpenSSL::SSL::SSLError => exception
149
233
  raise Error, "#{url}: #{exception.message}"
150
234
  end
151
235
 
236
+ def self.plausible?(address)
237
+ address.prefix >= MIN_PREFIX_LENGTH_BY_FAMILY.fetch(address.family) && !address.to_i.zero? &&
238
+ !(address.private? || address.loopback? || address.link_local?) &&
239
+ !NON_UNICAST_BY_FAMILY.fetch(address.family).include?(address)
240
+ end
241
+
152
242
  # Per owner and address family, the merged ranges as two sorted arrays of
153
243
  # integers, first and last addresses, for a binary search.
154
244
  def self.build_ranges(prefixes_by_owner)
@@ -188,7 +278,7 @@ module Browser
188
278
  nil
189
279
  end
190
280
 
191
- private_class_method :fetch_prefixes, :build_ranges, :merge, :parse
281
+ private_class_method :resolves_to?, :positive?, :fetch_prefixes, :plausible?, :build_ranges, :merge, :parse
192
282
  end
193
283
  end
194
284
  end
@@ -5,6 +5,8 @@ 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.
9
+ HEADER_SIZE_LIMIT_BYTES = 128
8
10
  DEFAULT_HEADER_NAMES = {
9
11
  verified_bot: "X-Bot-Verified",
10
12
  bot_category: "X-Bot-Category",
@@ -59,7 +61,7 @@ module Browser
59
61
 
60
62
  private def header(signal)
61
63
  value = @headers&.[](self.class.env_key_by_signal[signal])
62
- value if value.is_a?(String)
64
+ value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
63
65
  end
64
66
  end
65
67
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browser
4
- VERSION = "3.0.0"
4
+ VERSION = "3.1.0"
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.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nando Vieira
@@ -268,7 +268,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
268
268
  - !ruby/object:Gem::Version
269
269
  version: '0'
270
270
  requirements: []
271
- rubygems_version: 4.0.24
271
+ rubygems_version: 4.0.20
272
272
  specification_version: 4
273
273
  summary: 'Browser detection for Ruby: the browser gem''s API, faster'
274
274
  test_files: []