otto 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.8.1:
11
+
12
+ 2.8.1 — 2026-08-16
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Opt-in ASN resolution: ``req.asn`` / ``env['otto.privacy.asn']`` resolves
19
+ the client's network operator (``'AS15169'``) from a local MaxMind-format
20
+ database configured via ``configure_ip_privacy(asn: true, asn_db_path:
21
+ ...)`` (or a bring-your-own ``asn_db_reader``). Database-only — no header
22
+ tier exists, so the ``geo_header``/``trusted_proxy_depth`` conflict
23
+ machinery does not apply — and the lookup uses the already-masked IP, which
24
+ is safe because IPv4 BGP routes are not announced longer than /24. Off by
25
+ default: ``nil`` when disabled, ``'**'`` when enabled but unresolved.
26
+
27
+ - Opt-in anonymizer classification: ``req.anonymizer`` /
28
+ ``env['otto.privacy.anonymizer']`` labels the client address as ``'tor'``,
29
+ ``'proxy'``, ``'vpn'``, ``'residential_proxy'``, ``'hosting'``,
30
+ ``'anonymous'``, ``'none'`` (consulted, not listed), or ``'**'`` (no
31
+ answer), from a local anonymous-IP database
32
+ (``configure_ip_privacy(anonymizer: true, anonymizer_db_path: ...)``).
33
+ This is the one database lookup performed on the UNMASKED address:
34
+ anonymizer data lists individual egress nodes at or near /32, so a masked
35
+ lookup would answer for the node's neighbours — flagging innocent
36
+ addresses and missing actual exit nodes. Only the label ever leaves the
37
+ resolver, the same containment contract as ``hash_ip`` and
38
+ ``env['otto.ip_match']``. Off by default.
39
+
40
+ Documentation
41
+ -------------
42
+
43
+ - New ``docs/enrichment.md`` covering both signals, including the corrected
44
+ dataset guidance: ``geo-whois-asn-country`` is a country database (the
45
+ ``asn`` in its name describes its data *sources*, not its record
46
+ contents); deployments wanting ASN data need a separate ASN file, e.g.
47
+ sapics ``origin-asn`` (PDDL, rebuilt daily) — verified against the
48
+ published file, whose records carry a flat ``autonomous_system_number``.
49
+
50
+ .. _changelog-2.8.0:
51
+
52
+ 2.8.0 — 2026-08-07
53
+ ==================
54
+
55
+ Changed
56
+ -------
57
+
58
+ - ``env['otto.via_trusted_proxy']`` is now tri-state: written only when proxy
59
+ trust is configured (CIDR matchers or ``trusted_proxy_depth``). A present
60
+ key is authoritative in both directions; an absent key means
61
+ "unconfigured". Consumers reading the raw env key should presence-check
62
+ (``env.key?``) rather than compare ``== true``. (#228)
63
+
64
+ - Configuring an ip-privacy ``geo_header`` together with
65
+ ``trusted_proxy_depth`` now raises ``ArgumentError`` at configuration time:
66
+ geo headers are only honored for CIDR-verified proxies, so under depth mode
67
+ the header could never be consulted. Database-backed geo (``geo_db_path`` /
68
+ ``geo_db_reader``) remains fully supported under depth. (#228)
69
+
70
+ Fixed
71
+ -----
72
+
73
+ - Depth mode now records a peer-trust verdict in
74
+ ``env['otto.via_trusted_proxy']`` (previously always ``false``, leaving
75
+ downstream middleware with no trust signal);
76
+ ``Otto::Request#forwarded_by_trusted_proxy?`` mirrors the grant on its
77
+ no-middleware fallback path. (#226)
78
+
79
+ Documentation
80
+ -------------
81
+
82
+ - Corrected the v2.3.0 migration guide's depth-porting guidance: map depth
83
+ values directly, not ``+1`` — otto's chain index already accounts for the
84
+ appended ``REMOTE_ADDR``. (#227, #228)
85
+
10
86
  .. _changelog-2.7.0:
11
87
 
12
88
  2.7.0 — 2026-08-03
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.7.0)
4
+ otto (2.8.1)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -59,7 +59,7 @@ GEM
59
59
  prism (>= 1.3.0)
60
60
  rdoc (>= 4.0.0)
61
61
  reline (>= 0.4.2)
62
- json (2.20.0)
62
+ json (2.21.2)
63
63
  json_schemer (2.5.0)
64
64
  bigdecimal
65
65
  hana (~> 1.3)
@@ -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/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
 
@@ -152,7 +153,15 @@ Origins Otto cannot verify are **not** trusted:
152
153
  no `trusted_proxies` configured, header steps are skipped and resolution falls
153
154
  to the resolver / database (`'**'` if neither is set).
154
155
  - **Count-based `trusted_proxy_depth` mode.** The header-setting hop cannot be
155
- verified as a geo-CDN, so depth mode does not enable header trust.
156
+ verified as a geo-CDN, so depth mode does not enable header trust. This
157
+ conflict fails loud: configuring a `geo_header` together with a
158
+ `trusted_proxy_depth` raises `ArgumentError` at configuration time (in
159
+ either order) instead of silently ignoring the header per-request.
160
+ Database-backed geo remains fully supported under depth. The built-in
161
+ provider headers stay legal (there is nothing to configure, so nothing
162
+ can raise) but are equally inert — header trust requires CIDR-verified
163
+ proxies — so only an explicitly configured `geo_header` is rejected, and
164
+ depth deployments that want geo should set `geo_db_path`.
156
165
 
157
166
  **Migration:** to keep header-based geo, configure `trusted_proxies` (CIDR
158
167
  matchers) so Otto can verify the proxy origin. Depth-mode and header-only
@@ -55,8 +55,16 @@ return `false` behind a TLS-terminating trusted proxy.
55
55
 
56
56
  The middleware now records the peer-trust decision once (before masking) in a
57
57
  leak-free boolean `env['otto.via_trusted_proxy']`, and `secure?` reads it.
58
- When the middleware has not run (standalone request use), `secure?` falls back
59
- to its previous behavior. No app changes are required.
58
+ Since the first release after 2.7.0 the key is **tri-state**: it is written
59
+ only when proxy trust is actually configured (CIDR matchers or a depth), so a
60
+ present key is authoritative in both directions and an *absent* key means "no
61
+ proxy trust configured". When the key is absent (standalone request use, or an
62
+ unconfigured deployment), `secure?` falls back to the same decision the
63
+ middleware would have implied: a CIDR check of the connecting peer — or, since
64
+ the depth-mode peer-trust fix
65
+ ([#226](https://github.com/delano/otto/issues/226), first release after
66
+ 2.7.0), an unconditional grant when `trusted_proxy_depth` is configured. No
67
+ app changes are required.
60
68
 
61
69
  ### 3. Privacy helpers now return values (previously `nil`)
62
70
 
@@ -100,7 +108,7 @@ use `Otto::Request` standalone without the Otto middleware stack.
100
108
  | Key | Type | Meaning |
101
109
  |-----|------|---------|
102
110
  | `otto.client_ip` | String | Canonical client IP, resolved once. Masked when privacy is enabled; resolved real IP when disabled or exempt. Read by `Request#ip` / `#client_ipaddress`. |
103
- | `otto.via_trusted_proxy` | Boolean | Whether the request arrived via a trusted proxy, decided before masking. Read by `Request#secure?`. |
111
+ | `otto.via_trusted_proxy` | Boolean (tri-state: may be absent) | Peer trust decided before masking, written **only when proxy trust is configured** (first release after 2.7.0): `true` on a CIDR match — or unconditionally when depth mode is configured ([#226](https://github.com/delano/otto/issues/226)); `false` means trust is configured and the peer failed it (authoritative deny). Absent = no proxy trust configured — the only case for consumer-side fallback heuristics. Read by `Request#secure?`. |
104
112
 
105
113
  The privacy data keys remain `otto.privacy.{fingerprint,masked_ip,hashed_ip,geo_country}`.
106
114
 
@@ -187,15 +195,26 @@ them). Which *multi-hop* header depth counts from — `X-Forwarded-For` (default
187
195
  the RFC 7239 `Forwarded` header, or `Both` — is configurable as of 2.3.1; see
188
196
  *Selecting the forwarded header* below.
189
197
 
190
- **`secure?` is independent of depth.** Depth mode resolves the client **IP**
191
- only; it does **not** grant proxy trust for `X-Forwarded-Proto` / `X-Scheme`.
192
- `env['otto.via_trusted_proxy']`which `Otto::Request#secure?` consults to honor
193
- a forwarded proto is derived solely from the trusted-proxy *identity* check
194
- (does `REMOTE_ADDR` match a configured `trusted_proxies` CIDR?), never from hop
195
- depth. Because depth mode and `trusted_proxies` are mutually exclusive, that
196
- check is `false` under depth, so `secure?` does not honor a forwarded proto and
197
- reflects only a direct TLS connection (`HTTPS=on` / port 443). This mirrors the
198
- downstream (OneTimeSecret) behavior: proto-trust is never derived from depth.
198
+ **Depth grants peer trust ([#226](https://github.com/delano/otto/issues/226),
199
+ first release after 2.7.0).** Configuring a depth asserts that the connecting
200
+ peer *is* your proxy tier that is what the setting means — so depth mode
201
+ records `env['otto.via_trusted_proxy'] = true` on every request, and
202
+ `Otto::Request#secure?` (which consults that flag) honors a forwarded
203
+ `X-Forwarded-Proto` / `X-Scheme` in depth mode. Releases up to and including
204
+ 2.7.0 behaved differently: the flag was derived solely from the
205
+ `trusted_proxies` CIDR identity check, which is always empty under depth
206
+ (the modes are mutually exclusive), so it was recorded `false` and `secure?`
207
+ reflected only a direct TLS connection (`HTTPS=on` / port 443). Because the
208
+ grant is unconditional, the *origin lockdown* prerequisite below now covers
209
+ proto trust exactly as it covers IP resolution. Geo headers are unaffected:
210
+ they remain gated on enumerated `trusted_proxies` matchers and are still
211
+ **not** trusted in depth mode (a hop trusted by count cannot be verified as a
212
+ geo-setting CDN). As of the first release after 2.7.0 this carve-out fails
213
+ loud instead of silently: configuring an ip-privacy `geo_header` together
214
+ with a `trusted_proxy_depth` raises `ArgumentError` at configuration time
215
+ (in either order, with a freeze-time backstop) — use filter mode for
216
+ header-based geo, or a geo database (`geo_db_path`) under depth. Database-
217
+ backed geo with depth remains fully supported.
199
218
 
200
219
  ### Selecting the forwarded header (added in 2.3.1)
201
220
 
@@ -249,9 +268,10 @@ Otto.new(routes, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
249
268
  This is the inherent trade-off versus CIDR-walk: depth relies on a fixed network
250
269
  **topology** instead of enumerable proxy **addresses**. If a client can reach
251
270
  your app directly (origin not locked down), it can pad `X-Forwarded-For` so that
252
- a forged value lands at `chain[-(N+1)]`, spoofing the resolved client IP. (Proto
253
- trust is unaffected depth never feeds `secure?` — but the resolved IP is only
254
- as trustworthy as the lockdown.)
271
+ a forged value lands at `chain[-(N+1)]`, spoofing the resolved client IP. (Since
272
+ [#226](https://github.com/delano/otto/issues/226) this applies to proto trust
273
+ too: depth grants `otto.via_trusted_proxy`, so a directly-reachable origin
274
+ could spoof the scheme via `X-Forwarded-Proto` as well as the client IP.)
255
275
 
256
276
  Before enabling depth, ensure the origin only accepts connections from the proxy
257
277
  tier (private networking, security groups, an authenticating header the proxy
@@ -262,13 +282,26 @@ injects, etc.). If you can enumerate your proxies instead, prefer CIDR-walk.
262
282
  If you are collapsing OneTimeSecret's `ClientIpHelpers` / `ConfigureTrustedProxy`
263
283
  depth logic onto this resolver, note two intentional differences:
264
284
 
265
- - **Off-by-one (Otto counts the peer).** Otto's chain is `X-Forwarded-For`
266
- **plus** `REMOTE_ADDR`, so it is one hop longer than OTS's XFF-only chain. To
267
- resolve the same client, Otto's depth must be **one higher** than the
268
- operator's OTS `depth:`. When the YAML→Otto translator is built, map
269
- **`trusted_proxy_depth = ots_depth + 1`** so existing `depth:` values keep
270
- their meaning. Keep a parity regression test on the OTS side to lock this
271
- mapping.
285
+ - **Map depth values directly — do NOT add one.** Otto's chain is
286
+ `X-Forwarded-For` **plus** `REMOTE_ADDR`, and the client is selected at
287
+ `chain[-(N+1)]` the appended peer is already accounted for by the index
288
+ arithmetic. `trusted_proxy_depth = N` therefore means "N proxy hops,
289
+ counting the connecting peer as hop 1", which is exactly what the
290
+ operator-facing OTS `depth: N` documents ("1 = standard single reverse
291
+ proxy"). Map **`trusted_proxy_depth = ots_depth`**, and keep a parity
292
+ regression test on the OTS side — including a padded-chain case asserting a
293
+ forged leftmost `X-Forwarded-For` entry is never selected.
294
+
295
+ > **Correction.** Earlier revisions of this guide recommended
296
+ > `trusted_proxy_depth = ots_depth + 1` to "keep existing `depth:` values'
297
+ > meaning". That reproduced an internal off-by-one of the deleted OTS
298
+ > walker (whose indexed path selected `XFF[-(depth+1)]`, one position left
299
+ > of its own documented contract), not the operator-facing meaning. Under
300
+ > the `+1` remap every honest documented-topology request hit the
301
+ > short-chain fallback and resolved the **proxy** address as the client,
302
+ > and a single forged leftmost `X-Forwarded-For` entry re-lengthened the
303
+ > chain so the forged value was selected. The direct mapping resolves the
304
+ > true client on honest chains and is padding-resistant.
272
305
 
273
306
  - **Stricter short-chain behavior (kept on purpose).** When the chain is shorter
274
307
  than `N + 1`, Otto returns `REMOTE_ADDR` (the peer), whereas OTS returned the
@@ -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
@@ -5,10 +5,13 @@
5
5
  # Central registry of all env['otto.*'] keys used throughout Otto framework.
6
6
  # This documentation helps prevent key conflicts and aids multi-app integration.
7
7
  #
8
- # DOCUMENTATION-ONLY MODULE: The constants defined here are intentionally NOT used
9
- # in the codebase. Otto uses string literals (e.g., env['otto.strategy_result'])
10
- # for readibility/simplicity. This module exists as reference documentation but
11
- # may be considered for future use if needed.
8
+ # Otto's own code writes the string literals directly (e.g.
9
+ # env['otto.strategy_result']) for readability/simplicity, so this file is
10
+ # not loaded by `require 'otto'` consumers must `require 'otto/env_keys'`
11
+ # explicitly. The constants are nevertheless PUBLIC API: downstream
12
+ # applications reference them at runtime to pin the cross-gem env contract
13
+ # (OneTimeSecret's Rack::DetectHost reads VIA_TRUSTED_PROXY). Renaming or
14
+ # removing a constant — or this file — is a breaking change.
12
15
  #
13
16
  class Otto
14
17
  # Rack environment keys used by Otto framework
@@ -71,15 +74,26 @@ class Otto
71
74
  # (e.g. 'onetime.nonce'), so the header and views still share one value.
72
75
  NONCE = 'otto.nonce'
73
76
 
74
- # Whether the request arrived via a trusted proxy.
75
- # Type: Boolean
76
- # Set by: IPPrivacyMiddleware (every request, evaluated on the original
77
- # peer BEFORE REMOTE_ADDR is masked). This is the trusted-proxy identity
78
- # check (does REMOTE_ADDR match a configured trusted_proxies CIDR?) — it is
79
- # independent of count-based depth mode, which resolves the client IP but
80
- # never grants proxy trust for forwarded proto.
77
+ # Whether the request arrived via a trusted proxy. TRI-STATE.
78
+ # Type: Boolean when present; the key may be ABSENT.
79
+ # Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
80
+ # REMOTE_ADDR is masked but ONLY when proxy trust is configured
81
+ # (Security::Config#proxy_trust_configured?: CIDR matchers or a depth).
82
+ # True when the peer matches a configured trusted_proxies CIDR (filter
83
+ # mode), or unconditionally when count-based depth mode is active
84
+ # (trusted_proxy_depth >= 1) — the modes are mutually exclusive, and
85
+ # configuring a depth is the operator's assertion that the connecting
86
+ # peer is their proxy tier (#226). False means trust IS configured and
87
+ # this peer failed it — an authoritative deny. When no proxy trust is
88
+ # configured the key is NOT written, so consumers can distinguish
89
+ # "denied" from "unconfigured" and apply legacy heuristics only in the
90
+ # latter case.
81
91
  # Used by: Otto::Request#secure? to authorize X-Forwarded-Proto / X-Scheme
82
- # without depending on the (masked) REMOTE_ADDR
92
+ # without depending on the (masked) REMOTE_ADDR, and by downstream
93
+ # middleware (e.g. forwarded-host handling) as the peer-trust signal now
94
+ # that REMOTE_ADDR no longer identifies the connecting peer. Consumers
95
+ # should treat a PRESENT key as authoritative in both directions and
96
+ # reserve fallback heuristics for the absent case.
83
97
  VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
84
98
 
85
99
  # Whether the connecting peer was the loopback interface.
@@ -179,6 +193,30 @@ class Otto
179
193
  # Used by: Analytics, localization
180
194
  GEO_COUNTRY = 'otto.privacy.geo_country'
181
195
 
196
+ # Autonomous System Number of the network the client belongs to
197
+ # Type: String ('AS15169'), '**' when unresolved, or nil when disabled
198
+ # Set by: IPPrivacyMiddleware (opt-in; off unless asn_enabled)
199
+ # Used by: Network-operator allow/deny rules, dynamic access zones
200
+ # Read via: Otto::Request#asn
201
+ # Note: resolved from a local database against the MASKED IP, so it is
202
+ # subject to the same /24 equivalence country relies on.
203
+ ASN = 'otto.privacy.asn'
204
+
205
+ # Anonymizing-egress classification for the client address
206
+ # Type: String label, or nil when disabled. One of: 'tor', 'proxy',
207
+ # 'vpn', 'residential_proxy', 'hosting', 'anonymous', 'none', '**'
208
+ # Set by: IPPrivacyMiddleware (opt-in; off unless anonymizer_enabled)
209
+ # Used by: "Block anonymizers" access zones, abuse triage
210
+ # Read via: Otto::Request#anonymizer
211
+ # Note: 'none' means the database was consulted and did not list the
212
+ # address — a real answer, since these databases record only flagged
213
+ # addresses. '**' means no database answered at all. The two are not
214
+ # interchangeable: 'none' is evidence, '**' is the absence of it.
215
+ # Contrast: this is the one lookup performed on the UNMASKED address,
216
+ # because anonymizer data is per-node (~/32) and a masked lookup would
217
+ # answer for the node's neighbours. Only the label is retained.
218
+ ANONYMIZER = 'otto.privacy.anonymizer'
219
+
182
220
  # Daily-rotating IP hash for session correlation
183
221
  # Type: String (hexadecimal)
184
222
  # Set by: IPPrivacyMiddleware