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.
- 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 +4 -4
- data/README.md +64 -1
- data/docs/.gitignore +1 -0
- data/docs/enrichment.md +128 -0
- data/docs/geo-country.md +2 -1
- data/lib/otto/core/error_handler.rb +15 -2
- data/lib/otto/env_keys.rb +46 -0
- 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 +53 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +15 -2
- data/lib/otto/privacy.rb +9 -0
- data/lib/otto/request.rb +39 -0
- data/lib/otto/response.rb +7 -1
- data/lib/otto/security/config.rb +51 -6
- data/lib/otto/security/csp/emit_middleware.rb +2 -1
- data/lib/otto/security/csp/policy.rb +164 -10
- data/lib/otto/security/csp/request_extras.rb +256 -0
- data/lib/otto/security/csp/writer.rb +153 -12
- data/lib/otto/security/csp.rb +5 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +6 -1
- data/lib/otto/version.rb +1 -1
- metadata +7 -2
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.
|
|
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.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.
|
|
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/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
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
|
|
|
@@ -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
|
@@ -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
|