otto 2.8.0 → 2.9.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.
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,82 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.9.0:
11
+
12
+ 2.9.0 — 2026-08-18
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Add opt-in request-scoped CSP directive extras for sources that are known
19
+ only while handling a request. Enable the feature at boot with
20
+ ``security_config.enable_csp_request_extras!``, then add approved source
21
+ origins by directive through ``env['otto.csp.extra_directives']``. This
22
+ supports cases such as adding a tenant's SSO provider to ``form-action``.
23
+ Disabled by default. (#243)
24
+
25
+ Fixed
26
+ -----
27
+
28
+ - Prevent request-scoped CSP extras from invalidating valueless directives,
29
+ including ``upgrade-insecure-requests`` and ``block-all-mixed-content``.
30
+ Unsupported extras are ignored and logged; valid extras for other
31
+ directives continue to apply. (#243)
32
+
33
+ Security
34
+ --------
35
+
36
+ - Validate request-scoped CSP extras before adding them to a response policy.
37
+ Extras can only add valid HTTP(S) origins to compatible directives already
38
+ present in the configured policy; script and default source directives are
39
+ excluded. Invalid or unsupported entries are ignored and logged without
40
+ affecting the remaining policy. (#243)
41
+
42
+ - Reject request-scoped CSP extras for valueless directives during input
43
+ validation, with equivalent protection for direct CSP policy generation.
44
+ (#243)
45
+
46
+ .. _changelog-2.8.1:
47
+
48
+ 2.8.1 — 2026-08-16
49
+ ==================
50
+
51
+ Added
52
+ -----
53
+
54
+ - Opt-in ASN resolution: ``req.asn`` / ``env['otto.privacy.asn']`` resolves
55
+ the client's network operator (``'AS15169'``) from a local MaxMind-format
56
+ database configured via ``configure_ip_privacy(asn: true, asn_db_path:
57
+ ...)`` (or a bring-your-own ``asn_db_reader``). Database-only — no header
58
+ tier exists, so the ``geo_header``/``trusted_proxy_depth`` conflict
59
+ machinery does not apply — and the lookup uses the already-masked IP, which
60
+ is safe because IPv4 BGP routes are not announced longer than /24. Off by
61
+ default: ``nil`` when disabled, ``'**'`` when enabled but unresolved.
62
+
63
+ - Opt-in anonymizer classification: ``req.anonymizer`` /
64
+ ``env['otto.privacy.anonymizer']`` labels the client address as ``'tor'``,
65
+ ``'proxy'``, ``'vpn'``, ``'residential_proxy'``, ``'hosting'``,
66
+ ``'anonymous'``, ``'none'`` (consulted, not listed), or ``'**'`` (no
67
+ answer), from a local anonymous-IP database
68
+ (``configure_ip_privacy(anonymizer: true, anonymizer_db_path: ...)``).
69
+ This is the one database lookup performed on the UNMASKED address:
70
+ anonymizer data lists individual egress nodes at or near /32, so a masked
71
+ lookup would answer for the node's neighbours — flagging innocent
72
+ addresses and missing actual exit nodes. Only the label ever leaves the
73
+ resolver, the same containment contract as ``hash_ip`` and
74
+ ``env['otto.ip_match']``. Off by default.
75
+
76
+ Documentation
77
+ -------------
78
+
79
+ - New ``docs/enrichment.md`` covering both signals, including the corrected
80
+ dataset guidance: ``geo-whois-asn-country`` is a country database (the
81
+ ``asn`` in its name describes its data *sources*, not its record
82
+ contents); deployments wanting ASN data need a separate ASN file, e.g.
83
+ sapics ``origin-asn`` (PDDL, rebuilt daily) — verified against the
84
+ published file, whose records carry a flat ``autonomous_system_number``.
85
+
10
86
  .. _changelog-2.8.0:
11
87
 
12
88
  2.8.0 — 2026-08-07
data/Gemfile CHANGED
@@ -29,7 +29,7 @@ group :development do
29
29
  gem 'debug'
30
30
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
31
31
  gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
32
- gem 'rubocop', '~> 1.88.2', require: false
32
+ gem 'rubocop', '~> 1.89.0', require: false
33
33
  gem 'rubocop-performance', require: false
34
34
  gem 'rubocop-rspec', require: false
35
35
  gem 'rubocop-thread_safety', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.8.0)
4
+ otto (2.9.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -92,7 +92,7 @@ GEM
92
92
  nokogiri (1.19.4-x86_64-linux-musl)
93
93
  racc (~> 1.4)
94
94
  parallel (1.28.0)
95
- parser (3.3.11.1)
95
+ parser (3.3.12.0)
96
96
  ast (~> 2.4.1)
97
97
  racc
98
98
  pastel (0.8.0)
@@ -148,7 +148,7 @@ GEM
148
148
  diff-lcs (>= 1.2.0, < 2.0)
149
149
  rspec-support (~> 3.13.0)
150
150
  rspec-support (3.13.7)
151
- rubocop (1.88.2)
151
+ rubocop (1.89.0)
152
152
  json (~> 2.3)
153
153
  language_server-protocol (~> 3.17.0.2)
154
154
  lint_roller (~> 1.1.0)
@@ -227,7 +227,7 @@ DEPENDENCIES
227
227
  rake (~> 13.4)
228
228
  reek (~> 6.5)
229
229
  rspec (~> 3.13)
230
- rubocop (~> 1.88.2)
230
+ rubocop (~> 1.89.0)
231
231
  rubocop-performance
232
232
  rubocop-rspec
233
233
  rubocop-thread_safety
data/README.md CHANGED
@@ -129,7 +129,70 @@ result.skip_reason # => nil (or :disabled / :blank_nonce / :non_html / :exis
129
129
 
130
130
  Apps with an existing nonce env-key convention can point the accessor at it with
131
131
  `app.security_config.csp_nonce_key = 'onetime.nonce'` — the views and the header
132
- still share one value.
132
+ still share one value. Boot-time policy shaping goes through
133
+ `security_config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }`,
134
+ which replaces (or with `nil`, removes) a directive's sources wholesale.
135
+
136
+ #### Request-scoped directive extras
137
+
138
+ Some directive values only exist at request time — the canonical case is a
139
+ multi-tenant app that must allow the resolved tenant's SSO IdP origin in
140
+ `form-action`. The channel is **boot-time opt-in** (the env key is a write
141
+ surface any middleware in the Rack stack can reach, so it does not exist until
142
+ boot code says so):
143
+
144
+ ```ruby
145
+ app.security_config.enable_csp_request_extras! # default: off
146
+ ```
147
+
148
+ With the channel enabled, a handler (or middleware) writes a hash of directive
149
+ name => additional source tokens to the env before the response is finalized:
150
+
151
+ ```ruby
152
+ def signin(req, res)
153
+ idp_origin = resolve_tenant(req).idp_origin # e.g. "https://login.example-idp.com"
154
+ req.env['otto.csp.extra_directives'] = { 'form-action' => [idp_origin] }
155
+ # ... render as usual; the emitted CSP now carries the origin
156
+ end
157
+ ```
158
+
159
+ Without the opt-in, the env key is ignored entirely — no sanitization, no
160
+ logs. The `Writer::Result` returned by the emission surfaces reports what
161
+ actually happened: `result.extra_directives` carries only the extras that
162
+ landed in the policy; rejected or dropped entries are excluded and logged with
163
+ request context instead.
164
+
165
+ The extras channel is **additive-only** and deliberately narrow:
166
+
167
+ - Tokens are appended to directives already present in the built policy,
168
+ deduplicated. A directive that is *absent* (not in the base set, or removed
169
+ by a boot override) is dropped — creating one at request time would tighten
170
+ the policy (`form-action` does not fall back to `default-src`), and
171
+ re-adding one would resurrect a deliberate removal.
172
+ - Directives that take **no value** (`upgrade-insecure-requests`,
173
+ `block-all-mixed-content`) are refused during sanitization: a source appended
174
+ there would emit `upgrade-insecure-requests https://…`, which browsers treat
175
+ as malformed and discard — an extras key would silently switch the directive
176
+ *off*. The policy assembler independently leaves such directives
177
+ byte-identical for direct callers that bypass the sanitizer.
178
+ - Only **origins** are accepted: `scheme://host[:port]` with an http(s) scheme
179
+ — no keywords (`'self'`, `'unsafe-inline'`), no scheme sources (`data:`,
180
+ `https:`), no wildcards, no paths, nothing that could smuggle a separator.
181
+ - `script-src` (and `-elem`/`-attr`) and `default-src` are refused outright.
182
+ For the script family that is defence-in-depth policy, not nonce protection
183
+ (extras append, so the nonce would survive); `default-src` is refused
184
+ because widening it widens every unlisted directive at once.
185
+ - Everything that fails validation is **dropped and logged** (`warn`, with the
186
+ directive, token, and reason) — a hostile value never raises, and the
187
+ response ships with the rest of the policy intact.
188
+
189
+ Otto validates defensively, but it is not the policy authority: the app
190
+ decides *which* origins to admit (resolve them from trusted per-request data,
191
+ never echo attacker-controlled input). Extras live only in the request env —
192
+ nothing is memoized on the (frozen-in-production) security config, so
193
+ concurrent requests can never bleed into each other. The constant
194
+ `Otto::EnvKeys::CSP::EXTRA_DIRECTIVES` (via `require 'otto/env_keys'`) names
195
+ the key for downstream apps.
133
196
 
134
197
  > [!NOTE]
135
198
  > `res.send_csp_headers(content_type, nonce)` is **deprecated** in favour of
data/docs/.gitignore CHANGED
@@ -3,6 +3,7 @@
3
3
  !migrating/
4
4
  !migrating/*.md
5
5
  !ipaddr-encoding-quirk.md
6
+ !enrichment.md
6
7
  !geo-country.md
7
8
  !modern-authentication-authorization-landscape.md
8
9
  !multi-strategy-authentication-design.md
@@ -0,0 +1,128 @@
1
+ # ASN and anonymizer enrichment
2
+
3
+ Two opt-in, country-adjacent signals layered on the same privacy pipeline as
4
+ [geo-country](geo-country.md):
5
+
6
+ - **ASN** — the network operator an address belongs to, as `req.asn` /
7
+ `env['otto.privacy.asn']`: `'AS15169'`.
8
+ - **Anonymizer** — whether the address is a known anonymizing egress, as
9
+ `req.anonymizer` / `env['otto.privacy.anonymizer']`: `'tor'`, `'proxy'`,
10
+ `'vpn'`, `'residential_proxy'`, `'hosting'`, `'anonymous'`, `'none'`, `'**'`.
11
+
12
+ Both are **off by default** (unlike geo) and **database-only**: no CDN
13
+ publishes a client-ASN or anonymizer header with meaningful deployment, so
14
+ there is no header tier and none of geo's header-trust machinery applies —
15
+ including the `geo_header`/`trusted_proxy_depth` boot conflict.
16
+
17
+ Every signal keeps the same three-state contract:
18
+
19
+ | Value | Meaning |
20
+ | --- | --- |
21
+ | `nil` | the signal is switched off |
22
+ | `'**'` | switched on, but nothing resolved (no database, or lookup failed) |
23
+ | a label | a real answer |
24
+
25
+ ## Configuration
26
+
27
+ ```ruby
28
+ otto.configure_ip_privacy(
29
+ asn: true,
30
+ asn_db_path: 'data/origin-asn.mmdb',
31
+ anonymizer: true,
32
+ anonymizer_db_path: 'data/anonymizer.mmdb',
33
+ )
34
+ ```
35
+
36
+ Both accept the same bring-your-own-reader seam as geo (`asn_db_reader:` /
37
+ `anonymizer_db_reader:` — any object responding to `#get(ip)`; a reader
38
+ supplied in the same call wins over a path). Bad paths raise at boot, not
39
+ per-request. The [`maxmind-db`](https://rubygems.org/gems/maxmind-db) gem is
40
+ required only when a `*_db_path` is configured.
41
+
42
+ ## ASN: which address is looked up, and why that's safe
43
+
44
+ The ASN lookup uses the **masked** IP, same as geo. This is not a compromise:
45
+ IPv4 BGP routes are not announced longer than /24, so a /24-masked address
46
+ falls inside the same announced prefix — and therefore the same ASN — as the
47
+ real one. (IPv6 is coarser: at `octet_precision: 1` Otto zeroes the last 80
48
+ bits, wider than many IPv6 announcements, so treat IPv6 ASN as best-effort.)
49
+
50
+ ### Data file
51
+
52
+ > **Naming caution:** the country database this project recommends,
53
+ > `geo-whois-asn-country`, does **not** contain ASN data. In
54
+ > sapics/ip-location-db naming, `geo-whois-asn` describes the *sources* the
55
+ > country data was derived from; the final token (`-country`) is what the
56
+ > records contain. ASN data is a separate file type in that project.
57
+
58
+ The recommended ASN file is
59
+ [`origin-asn`](https://github.com/sapics/ip-location-db/tree/main/origin-asn/)
60
+ from sapics/ip-location-db — like `geo-whois-asn-country` it is **PDDL v1.0
61
+ (public domain)** and rebuilt daily, so the licensing/freshness posture
62
+ matches the geo guidance:
63
+
64
+ ```bash
65
+ curl -fsSL -o data/origin-asn.mmdb \
66
+ https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb
67
+ ```
68
+
69
+ Its records carry a flat `autonomous_system_number` (verified against the
70
+ published file), which is the primary key `AsnResolver` reads. GeoLite2-ASN
71
+ and DB-IP ASN Lite MMDBs work too (same key; GeoLite2's EULA caveats from the
72
+ geo doc apply). Reserved ASNs (0 per RFC 7607, the AS_TRANS placeholder 23456
73
+ per RFC 6793) resolve to `'**'` rather than being reported as operators.
74
+
75
+ ## Anonymizer: the one unmasked lookup
76
+
77
+ Anonymizer classification reads the **unmasked** address. This is deliberate
78
+ and documented in `AnonymizerResolver` itself: anonymizer databases list
79
+ individual egress nodes at or near /32, so the /24 equivalence that justifies
80
+ masked geo and ASN lookups does not hold. A masked lookup would flag a whole
81
+ /24 because one host in it runs a Tor exit, and miss the exit node itself —
82
+ wrong in both directions.
83
+
84
+ The privacy containment is the same one Otto already relies on for
85
+ `hash_ip` and `env['otto.ip_match']`, which also consume the full IP: **only
86
+ the derived value leaves**. The resolver returns a label; the address is
87
+ never persisted, serialized, or handed downstream.
88
+
89
+ ### Reading the labels
90
+
91
+ When several database flags are set at once (a Tor exit hosted at a cloud
92
+ provider), the **most specific** label wins: `tor` > `proxy` > `vpn` >
93
+ `residential_proxy` > `hosting` > `anonymous`.
94
+
95
+ Two labels deserve care:
96
+
97
+ - **`'none'`** means the database was consulted and does not list the
98
+ address. For an anonymizer database that is a real answer (these files
99
+ record only flagged addresses), but it is *not* a positive assertion the
100
+ visitor is residential — it is only as fresh as your database file.
101
+ - **`'**'`** means no database answered at all. Do not collapse it into
102
+ `'none'`: `'none'` is evidence, `'**'` is the absence of evidence. A
103
+ "block anonymizers" rule that treats `'**'` as `'none'` fails open when
104
+ the database file goes missing.
105
+
106
+ ### Data file
107
+
108
+ There is no public-domain anonymizer dataset of `origin-asn`'s quality; this
109
+ signal is bring-your-own-database. `AnonymizerResolver` reads the MaxMind
110
+ GeoIP2 Anonymous-IP flag schema (`is_tor_exit_node`, `is_public_proxy`,
111
+ `is_anonymous_vpn`, `is_residential_proxy`, `is_hosting_provider`,
112
+ `is_anonymous`), which commercial and self-built MMDBs alike use. A workable
113
+ self-built option: compile the [Tor bulk exit
114
+ list](https://check.torproject.org/torbulkexitlist) into an MMDB with
115
+ `is_tor_exit_node` set — that covers the highest-signal label with fully
116
+ public data.
117
+
118
+ ## Acceptance behavior summary
119
+
120
+ | Scenario | Result |
121
+ | --- | --- |
122
+ | Signal not enabled | `nil` everywhere (env key absent for exempt IPs) |
123
+ | Enabled, no database configured | `'**'` |
124
+ | Database read raises | `'**'` (a lookup must never crash a request) |
125
+ | ASN lookup | masked IP only (re-masked defensively in the resolver) |
126
+ | Anonymizer lookup | unmasked IP in, label out, nothing else retained |
127
+ | Private/localhost client (privacy-exempt) | no enrichment keys in env |
128
+ | Bad `*_db_path` | raises at boot, not per-request |
data/docs/geo-country.md CHANGED
@@ -3,7 +3,8 @@
3
3
  Otto resolves a country-level ISO 3166-1 alpha-2 code for each request and
4
4
  exposes it as `req.geo_country` / `env['otto.privacy.geo_country']`. Resolution
5
5
  is country-only by design — that is the privacy posture; there is no city or
6
- region lookup.
6
+ region lookup. (Two opt-in, database-only companion signals — ASN and
7
+ anonymizer classification — are covered in [enrichment](enrichment.md).)
7
8
 
8
9
  ## Resolution order
9
10
 
@@ -11,8 +11,8 @@ class Otto
11
11
  # Error handling module providing secure error reporting and logging functionality
12
12
  module ErrorHandler
13
13
  def handle_error(error, env)
14
- # Check if this is a registered expected error
15
- if handler_config = @error_handlers[error.class.name]
14
+ # Check if this is a registered expected error (exact match first, then ancestors)
15
+ if handler_config = find_error_handler(error)
16
16
  return handle_expected_error(error, env, handler_config)
17
17
  end
18
18
 
@@ -116,6 +116,19 @@ class Otto
116
116
 
117
117
  private
118
118
 
119
+ # Find the best matching error handler by walking the exception's ancestor chain.
120
+ # Returns the handler config for the most specific registered class, or nil.
121
+ def find_error_handler(error)
122
+ error.class.ancestors.each do |klass|
123
+ next unless klass.is_a?(Class)
124
+
125
+ if handler_config = @error_handlers[klass.name]
126
+ return handler_config
127
+ end
128
+ end
129
+ nil
130
+ end
131
+
119
132
  # Register all Otto framework error classes with appropriate status codes
120
133
  #
121
134
  # This method auto-registers base HTTP error classes and all framework-specific
data/lib/otto/env_keys.rb CHANGED
@@ -74,6 +74,28 @@ class Otto
74
74
  # (e.g. 'onetime.nonce'), so the header and views still share one value.
75
75
  NONCE = 'otto.nonce'
76
76
 
77
+ # Content-Security-Policy request-scoped keys
78
+ module CSP
79
+ # Request-scoped CSP directive extras (delano/otto#243).
80
+ # Type: Hash{String|Symbol directive-name => String|Array<String> tokens}
81
+ # Set by: the consuming application (a handler, logic class, or
82
+ # middleware) any time before the response is finalized
83
+ # Used by: Otto::Security::CSP::RequestExtras (read + sanitized) and
84
+ # folded ADDITIVELY into the nonce policy by
85
+ # Otto::Security::CSP::Policy.append_extra_sources at build time
86
+ # Note: BOOT-TIME OPT-IN — the channel does not exist until the app
87
+ # calls Otto::Security::Config#enable_csp_request_extras! (default
88
+ # off); without it the key is ignored entirely, no sanitize work and
89
+ # no logs. The env key is a write surface any middleware in the Rack
90
+ # stack can reach, a lower-trust position than boot code.
91
+ # Note: additive-only — extras can only APPEND origin tokens
92
+ # (scheme://host[:port], http/https) to directives already present in
93
+ # the built policy. The script-src family and default-src are refused
94
+ # outright, keyword/scheme sources and wildcards are dropped, and every
95
+ # drop is logged. See Otto::Security::CSP::RequestExtras.
96
+ EXTRA_DIRECTIVES = 'otto.csp.extra_directives'
97
+ end
98
+
77
99
  # Whether the request arrived via a trusted proxy. TRI-STATE.
78
100
  # Type: Boolean when present; the key may be ABSENT.
79
101
  # Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
@@ -193,6 +215,30 @@ class Otto
193
215
  # Used by: Analytics, localization
194
216
  GEO_COUNTRY = 'otto.privacy.geo_country'
195
217
 
218
+ # Autonomous System Number of the network the client belongs to
219
+ # Type: String ('AS15169'), '**' when unresolved, or nil when disabled
220
+ # Set by: IPPrivacyMiddleware (opt-in; off unless asn_enabled)
221
+ # Used by: Network-operator allow/deny rules, dynamic access zones
222
+ # Read via: Otto::Request#asn
223
+ # Note: resolved from a local database against the MASKED IP, so it is
224
+ # subject to the same /24 equivalence country relies on.
225
+ ASN = 'otto.privacy.asn'
226
+
227
+ # Anonymizing-egress classification for the client address
228
+ # Type: String label, or nil when disabled. One of: 'tor', 'proxy',
229
+ # 'vpn', 'residential_proxy', 'hosting', 'anonymous', 'none', '**'
230
+ # Set by: IPPrivacyMiddleware (opt-in; off unless anonymizer_enabled)
231
+ # Used by: "Block anonymizers" access zones, abuse triage
232
+ # Read via: Otto::Request#anonymizer
233
+ # Note: 'none' means the database was consulted and did not list the
234
+ # address — a real answer, since these databases record only flagged
235
+ # addresses. '**' means no database answered at all. The two are not
236
+ # interchangeable: 'none' is evidence, '**' is the absence of it.
237
+ # Contrast: this is the one lookup performed on the UNMASKED address,
238
+ # because anonymizer data is per-node (~/32) and a masked lookup would
239
+ # answer for the node's neighbours. Only the label is retained.
240
+ ANONYMIZER = 'otto.privacy.anonymizer'
241
+
196
242
  # Daily-rotating IP hash for session correlation
197
243
  # Type: String (hexadecimal)
198
244
  # Set by: IPPrivacyMiddleware
@@ -0,0 +1,138 @@
1
+ # lib/otto/privacy/anonymizer_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Privacy
7
+ # Anonymizer (Tor / VPN / proxy / hosting) classification for IP addresses
8
+ #
9
+ # Answers one question — "is this address a known anonymizing egress, and
10
+ # of what kind?" — as a single label a downstream allow/deny rule can
11
+ # compare directly. Database-only, and the ONLY thing that ever leaves
12
+ # this resolver is the label.
13
+ #
14
+ # ## Why this one reads the UNMASKED address
15
+ #
16
+ # Every other database lookup in Otto operates on the masked IP, because
17
+ # country and ASN networks are >= /24 and a masked address lands in the
18
+ # same network as the real one. Anonymizer data breaks that equivalence:
19
+ # providers list individual egress nodes at or near /32, so a /24-masked
20
+ # lookup answers a question about the node's *neighbours* rather than the
21
+ # node. That produces wrong verdicts in both directions — flagging a whole
22
+ # /24 because one host in it is an exit node, and missing the exit node
23
+ # itself. A signal that quietly lies is worse than no signal.
24
+ #
25
+ # So this resolver takes the real address and returns only a label, which
26
+ # is the same trade Otto already makes twice: {IPPrivacy.hash_ip} consumes
27
+ # the full IP to emit an opaque digest, and +env['otto.ip_match']+ closes
28
+ # over the full IP to emit a boolean. The invariant Otto defends is that a
29
+ # raw address is never persisted, serialized, or handed downstream — not
30
+ # that it is unreachable in-process. A label honours that invariant; a
31
+ # masked lookup here would honour the letter of it while breaking the
32
+ # feature.
33
+ #
34
+ # ## Reading the 'none' label
35
+ #
36
+ # 'none' means "the database was consulted and did not list this address".
37
+ # Anonymizer databases are allow-list-by-omission — an address absent from
38
+ # the file is simply not a known egress — so absence is a real answer, not
39
+ # a miss. It is NOT a positive assertion that the address is a residential
40
+ # user, and it is only as fresh as the database file. '**' is reserved for
41
+ # "no database, or the lookup failed" — genuinely no answer.
42
+ #
43
+ # @example Configuring
44
+ # otto.configure_ip_privacy(
45
+ # anonymizer: true,
46
+ # anonymizer_db_path: 'data/GeoIP2-Anonymous-IP.mmdb',
47
+ # )
48
+ #
49
+ # @example Reading
50
+ # req.anonymizer # => 'tor' | 'vpn' | 'none' | '**' | nil
51
+ # env['otto.privacy.anonymizer']
52
+ #
53
+ class AnonymizerResolver
54
+ # Returned when classification is enabled but no database answered.
55
+ UNKNOWN = '**'
56
+
57
+ # Returned when the database was consulted and did not list the address.
58
+ NONE = 'none'
59
+
60
+ # Database flag => label, in precedence order. An address can carry
61
+ # several flags at once (a Tor exit node hosted at a cloud provider sets
62
+ # both +is_tor_exit_node+ and +is_hosting_provider+); the FIRST match
63
+ # wins, so the most specific and most access-relevant classification is
64
+ # what surfaces. Ordering rationale: Tor and public proxies are
65
+ # deliberate anonymity, commercial VPNs next, then residential proxies
66
+ # (frequently abuse infrastructure), then hosting — which is merely "not
67
+ # an eyeball network" and the weakest signal of the set.
68
+ CLASSIFICATIONS = [
69
+ %w[is_tor_exit_node tor],
70
+ %w[is_public_proxy proxy],
71
+ %w[is_anonymous_vpn vpn],
72
+ %w[is_residential_proxy residential_proxy],
73
+ %w[is_hosting_provider hosting],
74
+ # Generic catch-all: the provider says "anonymous" without saying how.
75
+ # Last, so it never masks a specific classification above.
76
+ %w[is_anonymous anonymous],
77
+ ].freeze
78
+
79
+ # Every label this resolver can emit, for consumers building zone rules.
80
+ LABELS = (CLASSIFICATIONS.map(&:last) + [NONE, UNKNOWN]).freeze
81
+
82
+ class << self
83
+ # Classify an IP address.
84
+ #
85
+ # @param ip [String] the UNMASKED client IP (see the class docs)
86
+ # @param config [Otto::Privacy::Config, nil] privacy configuration
87
+ # @return [String] one of {LABELS}
88
+ def resolve(ip, config = nil)
89
+ return UNKNOWN if ip.nil? || ip.empty?
90
+
91
+ check_anonymizer_database(ip, config) || UNKNOWN
92
+ end
93
+
94
+ private
95
+
96
+ # Look the address up in the configured anonymizer database.
97
+ #
98
+ # No masking: see the class documentation for why this resolver is the
99
+ # one exception, and note the record is reduced to a label before it
100
+ # returns, so the address itself goes no further.
101
+ #
102
+ # @return [String, nil] a label, or nil to fall through to UNKNOWN
103
+ def check_anonymizer_database(ip, config)
104
+ reader = config&.anonymizer_db_reader
105
+ return nil unless reader
106
+
107
+ classify(reader.get(ip))
108
+ rescue StandardError => e
109
+ warn "AnonymizerResolver database lookup error: #{e.message}" if $DEBUG
110
+ nil
111
+ end
112
+
113
+ # Reduce a database record to a single label.
114
+ #
115
+ # A nil record means "not listed", which for an anonymizer database is
116
+ # the meaningful answer NONE rather than a miss — these files record
117
+ # only flagged addresses. A non-Hash record is a malformed answer and
118
+ # falls through to UNKNOWN.
119
+ #
120
+ # @return [String, nil]
121
+ def classify(record)
122
+ return NONE if record.nil?
123
+ return nil unless record.is_a?(Hash)
124
+
125
+ match = CLASSIFICATIONS.find { |flag, _label| flagged?(record[flag]) }
126
+ match ? match.last : NONE
127
+ end
128
+
129
+ # MaxMind omits these keys entirely when false, so absent and false are
130
+ # the same answer. Accept the string forms too, since MMDB builds from
131
+ # other vendors sometimes encode the flags as text.
132
+ def flagged?(value)
133
+ value == true || value.to_s == 'true' || value.to_s == '1'
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end