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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby-lint.yml +9 -8
- data/.rubocop.yml +2 -1
- data/.rubocop_todo.yml +1384 -0
- data/CHANGELOG.rst +76 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/docs/.gitignore +1 -0
- data/docs/enrichment.md +128 -0
- data/docs/geo-country.md +11 -2
- data/docs/migrating/v2.3.0.md +55 -22
- data/lib/otto/core/error_handler.rb +15 -2
- data/lib/otto/env_keys.rb +50 -12
- data/lib/otto/privacy/anonymizer_resolver.rb +138 -0
- data/lib/otto/privacy/asn_resolver.rb +149 -0
- data/lib/otto/privacy/config.rb +159 -11
- data/lib/otto/privacy/core.rb +64 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +15 -2
- data/lib/otto/privacy.rb +9 -0
- data/lib/otto/request.rb +56 -8
- data/lib/otto/security/config.rb +44 -6
- data/lib/otto/security/csp/policy.rb +5 -3
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -8
- data/lib/otto/version.rb +1 -1
- metadata +10 -3
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
230
|
+
rubocop (~> 1.89.0)
|
|
231
231
|
rubocop-performance
|
|
232
232
|
rubocop-rspec
|
|
233
233
|
rubocop-thread_safety
|
data/docs/.gitignore
CHANGED
data/docs/enrichment.md
ADDED
|
@@ -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
|
data/docs/migrating/v2.3.0.md
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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 |
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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. (
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
- **
|
|
266
|
-
**plus** `REMOTE_ADDR`,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 =
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
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
|