otto 2.9.0 → 2.10.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/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Configuration freezing
|
|
2
|
+
|
|
3
|
+
Otto supports multi-step boot configuration. In normal operation, it freezes the
|
|
4
|
+
configured application immediately before processing the first request. When
|
|
5
|
+
`RSpec` is defined, automatic request-time freezing is skipped so test suites can
|
|
6
|
+
assemble applications incrementally.
|
|
7
|
+
|
|
8
|
+
Configuration must be complete before traffic reaches the application. For a
|
|
9
|
+
more explicit boot boundary, call `freeze_configuration!` after setup and before
|
|
10
|
+
starting the Rack server.
|
|
11
|
+
|
|
12
|
+
## Configure before serving requests
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
otto = Otto.new('routes.txt')
|
|
16
|
+
|
|
17
|
+
otto.add_auth_strategy(
|
|
18
|
+
'session',
|
|
19
|
+
Otto::Security::Authentication::Strategies::SessionStrategy.new(
|
|
20
|
+
session_key: 'user_id'
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
otto.add_auth_strategy(
|
|
24
|
+
'api_key',
|
|
25
|
+
Otto::Security::Authentication::Strategies::APIKeyStrategy.new(
|
|
26
|
+
api_keys: ENV.fetch('API_KEYS').split(',')
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
otto.enable_csrf_protection!
|
|
30
|
+
otto.use MyApp::Middleware
|
|
31
|
+
otto.register_error_handler(MyApp::NotFound, status: 404, log_level: :info)
|
|
32
|
+
|
|
33
|
+
# Optional: make the end of boot explicit rather than waiting for a request.
|
|
34
|
+
otto.freeze_configuration!
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Calling a supported configuration method after freezing raises `FrozenError`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
41
|
+
# FrozenError: Cannot modify frozen configuration
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`freeze_configuration!` is idempotent and returns the Otto instance.
|
|
45
|
+
|
|
46
|
+
## What is frozen
|
|
47
|
+
|
|
48
|
+
`freeze_configuration!` freezes these owned structures:
|
|
49
|
+
|
|
50
|
+
- security and locale configuration objects;
|
|
51
|
+
- the middleware stack;
|
|
52
|
+
- authentication configuration and instance options;
|
|
53
|
+
- dynamic and literal route tables;
|
|
54
|
+
- route definitions and reverse-route indexes.
|
|
55
|
+
|
|
56
|
+
Hashes and arrays inside those structures are recursively frozen. Configuration
|
|
57
|
+
objects that implement `deep_freeze!` can prepare memoized values before they
|
|
58
|
+
are frozen.
|
|
59
|
+
|
|
60
|
+
The static-file route structure is an intentional exception. Its outer hash is
|
|
61
|
+
frozen, but the `routes_static[:GET]` `Concurrent::Map` remains writable because
|
|
62
|
+
Otto caches newly discovered static paths during requests.
|
|
63
|
+
|
|
64
|
+
## Scope and current limitations
|
|
65
|
+
|
|
66
|
+
Freezing is a guard around Otto's supported configuration APIs, not a complete
|
|
67
|
+
object-capability boundary. In particular, the current implementation exposes
|
|
68
|
+
some state that is not included in `freeze_configuration!`:
|
|
69
|
+
|
|
70
|
+
- `error_handlers` remains a mutable Hash, although
|
|
71
|
+
`register_error_handler` rejects calls after freezing;
|
|
72
|
+
- the `not_found` and `server_error` fallback response writers remain available;
|
|
73
|
+
- the inner static-file cache remains mutable by design.
|
|
74
|
+
|
|
75
|
+
Application code should not mutate those objects directly after boot. Do not
|
|
76
|
+
state or depend on a guarantee that every object reachable from an Otto instance
|
|
77
|
+
is deeply immutable.
|
|
78
|
+
|
|
79
|
+
## Expected failures after freezing
|
|
80
|
+
|
|
81
|
+
These supported mutation paths reject changes after the freeze boundary:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# Security configuration
|
|
85
|
+
otto.security_config.disable_csrf_protection!
|
|
86
|
+
otto.add_trusted_proxy('192.0.2.10')
|
|
87
|
+
otto.add_rate_limit_rule('uploads', limit: 5, period: 60)
|
|
88
|
+
|
|
89
|
+
# Middleware and authentication
|
|
90
|
+
otto.use MyApp::OtherMiddleware
|
|
91
|
+
otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
92
|
+
|
|
93
|
+
# Helpers and expected-error handling
|
|
94
|
+
otto.register_request_helpers(MyApp::RequestHelpers)
|
|
95
|
+
otto.register_response_helpers(MyApp::ResponseHelpers)
|
|
96
|
+
otto.register_error_handler(MyApp::RateLimited, status: 429)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Direct mutation of frozen nested structures also raises `FrozenError`:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
otto.security_config.rate_limiting_config[:custom_rules] = {}
|
|
103
|
+
otto.auth_config[:auth_strategies] = {}
|
|
104
|
+
otto.routes[:GET] = []
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Testing the boundary
|
|
108
|
+
|
|
109
|
+
Otto deliberately skips automatic first-request freezing when `RSpec` is
|
|
110
|
+
defined. A freeze-focused spec must freeze explicitly:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
RSpec.describe 'configuration freezing' do
|
|
114
|
+
it 'rejects configuration changes after the boot boundary' do
|
|
115
|
+
otto = Otto.new
|
|
116
|
+
otto.freeze_configuration!
|
|
117
|
+
|
|
118
|
+
expect(otto.frozen_configuration?).to be(true)
|
|
119
|
+
expect {
|
|
120
|
+
otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
121
|
+
}.to raise_error(FrozenError, /Cannot modify frozen configuration/)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use a fresh Otto instance when a later example needs mutable configuration.
|
|
127
|
+
`Otto.unfreeze_for_testing` is an internal test helper that only resets the
|
|
128
|
+
`@configuration_frozen` flag; it cannot unfreeze Ruby objects that were already
|
|
129
|
+
frozen by `freeze_configuration!`.
|
|
130
|
+
|
|
131
|
+
## Thread behavior
|
|
132
|
+
|
|
133
|
+
For the automatic production path, `Otto#call` guards the first freeze with a
|
|
134
|
+
mutex and checks the frozen flag again inside the critical section. Concurrent
|
|
135
|
+
first requests therefore do not run the freeze operation concurrently.
|
|
136
|
+
|
|
137
|
+
## Related source and tests
|
|
138
|
+
|
|
139
|
+
- [`Otto#call`](../../lib/otto.rb) — automatic first-request boundary and the
|
|
140
|
+
RSpec exception.
|
|
141
|
+
- [`Otto::Core::Configuration`](../../lib/otto/core/configuration.rb) — exact
|
|
142
|
+
freeze scope and mutation guard.
|
|
143
|
+
- [`Otto::Core::Freezable`](../../lib/otto/core/freezable.rb) — recursive
|
|
144
|
+
freezing behavior.
|
|
145
|
+
- [Configuration-freezing specs](../../spec/otto/configuration_freezing_spec.rb).
|
|
146
|
+
- [Static-file freezing specs](../../spec/otto/static_file_freezing_spec.rb).
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
For requests that pass through the privacy fingerprint pipeline, each signal
|
|
18
|
+
uses these values:
|
|
19
|
+
|
|
20
|
+
| Value | Meaning |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `nil` | no enrichment value was produced: the signal is off, IP privacy is disabled, no client IP resolved, or the client is exempt from fingerprinting |
|
|
23
|
+
| `'**'` | the signal is on, but no database answered or the lookup failed |
|
|
24
|
+
| a label | the configured reader returned a usable answer |
|
|
25
|
+
|
|
26
|
+
By default, private and localhost clients are exempt from fingerprinting. Their
|
|
27
|
+
enrichment environment keys are absent even when the signals are enabled. Use
|
|
28
|
+
the `:anonymous` privacy profile only if those clients should also be masked and
|
|
29
|
+
enriched.
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Add the optional reader gem when configuring an MMDB path:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Gemfile
|
|
37
|
+
gem 'maxmind-db', '~> 1.2'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then install an ASN database and enable the signal before the first request:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
bundle install
|
|
44
|
+
mkdir -p data
|
|
45
|
+
curl -fsSL -o data/origin-asn.mmdb \
|
|
46
|
+
https://github.com/sapics/ip-location-db/releases/download/latest/origin-asn.mmdb
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
otto.configure_ip_privacy(
|
|
51
|
+
asn: true,
|
|
52
|
+
asn_db_path: 'data/origin-asn.mmdb'
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Anonymizer classification requires a separate compatible database that Otto
|
|
57
|
+
does not provide:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
otto.configure_ip_privacy(
|
|
61
|
+
anonymizer: true,
|
|
62
|
+
anonymizer_db_path: 'data/your-anonymous-ip.mmdb'
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Do not use that placeholder path until the file exists. Both signals also accept
|
|
67
|
+
a bring-your-own reader (`asn_db_reader:` / `anonymizer_db_reader:`): any object
|
|
68
|
+
responding to `#get(ip)`. A reader supplied in the same call wins over a path.
|
|
69
|
+
|
|
70
|
+
The `maxmind-db` gem is loaded only when an enabled signal has a `*_db_path`.
|
|
71
|
+
Otto supports version 1.2.0 or newer in the 1.x series. Missing or incompatible
|
|
72
|
+
versions raise `Otto::OptionalDependencyError` during configuration. An
|
|
73
|
+
unreadable path also raises during configuration when its signal is enabled; a
|
|
74
|
+
path attached to a disabled signal is stored but not opened.
|
|
75
|
+
|
|
76
|
+
## ASN: which address is looked up, and why that's safe
|
|
77
|
+
|
|
78
|
+
The ASN lookup always uses the **masked** IP, same as geo. With the default
|
|
79
|
+
IPv4 `/24` masking, accuracy depends on the chosen database assigning the same
|
|
80
|
+
ASN across that `/24`; Otto accepts arbitrary MMDBs and injected readers, so it
|
|
81
|
+
cannot guarantee that equivalence. IPv6 is coarser: at `octet_precision: 1`
|
|
82
|
+
Otto zeroes the last 80 bits to produce a `/48`, which is wider than many IPv6
|
|
83
|
+
announcements. Treat masked ASN results as best-effort and verify the behavior
|
|
84
|
+
of the data source used by policy code.
|
|
85
|
+
|
|
86
|
+
### Data file
|
|
87
|
+
|
|
88
|
+
> **Dataset distinction:** sapics/ip-location-db publishes country and ASN data
|
|
89
|
+
> as separate file types. Current country files such as `user-country.mmdb` do
|
|
90
|
+
> not contain ASN records. Use an ASN file such as `origin-asn.mmdb` for this
|
|
91
|
+
> signal.
|
|
92
|
+
|
|
93
|
+
The recommended ASN file is
|
|
94
|
+
[`origin-asn`](https://github.com/sapics/ip-location-db/tree/main/origin-asn/)
|
|
95
|
+
from sapics/ip-location-db. It is **PDDL v1.0 (public domain)**, rebuilt daily,
|
|
96
|
+
and downloaded by the setup command above.
|
|
97
|
+
|
|
98
|
+
Compatible records must provide an integer `autonomous_system_number` either at
|
|
99
|
+
the top level or inside an `asn` map; Otto also accepts a bare integer `asn`
|
|
100
|
+
field. Reserved ASNs (0 per RFC 7607 and the AS_TRANS placeholder 23456 per RFC
|
|
101
|
+
6793) resolve to `'**'` rather than being reported as operators.
|
|
102
|
+
|
|
103
|
+
## Anonymizer: the one unmasked lookup
|
|
104
|
+
|
|
105
|
+
Anonymizer classification reads the **unmasked** address. This is deliberate
|
|
106
|
+
and documented in `AnonymizerResolver` itself: anonymizer databases list
|
|
107
|
+
individual egress nodes at or near /32, so the /24 equivalence that justifies
|
|
108
|
+
masked geo and ASN lookups does not hold. A masked lookup would flag a whole
|
|
109
|
+
/24 because one host in it runs a Tor exit, and miss the exit node itself —
|
|
110
|
+
wrong in both directions.
|
|
111
|
+
|
|
112
|
+
Otto passes the full address to the configured
|
|
113
|
+
`anonymizer_db_reader#get(ip)` and retains only the returned label. Because the
|
|
114
|
+
reader may be any application-provided object, Otto cannot guarantee that the
|
|
115
|
+
reader does not log, persist, or transmit the address. Use a trusted local
|
|
116
|
+
reader and review its network, logging, and retention behavior. Otto itself does
|
|
117
|
+
not place the raw address in the enrichment result or downstream environment.
|
|
118
|
+
|
|
119
|
+
### Reading the labels
|
|
120
|
+
|
|
121
|
+
When several database flags are set at once (a Tor exit hosted at a cloud
|
|
122
|
+
provider), the **most specific** label wins: `tor` > `proxy` > `vpn` >
|
|
123
|
+
`residential_proxy` > `hosting` > `anonymous`.
|
|
124
|
+
|
|
125
|
+
Two labels deserve care:
|
|
126
|
+
|
|
127
|
+
- **`'none'`** means the reader was consulted and returned no record or no
|
|
128
|
+
recognized true flag. For an anonymous-IP MMDB, this normally means the
|
|
129
|
+
address is not listed, but it is *not* a positive assertion that the visitor
|
|
130
|
+
is residential. The result is only as reliable and current as the reader's
|
|
131
|
+
data.
|
|
132
|
+
- **`'**'`** means no database answered at all. Do not collapse it into
|
|
133
|
+
`'none'`: `'none'` is evidence, `'**'` is the absence of evidence. A
|
|
134
|
+
"block anonymizers" rule that treats `'**'` as `'none'` fails open when
|
|
135
|
+
the database file goes missing.
|
|
136
|
+
|
|
137
|
+
### Data file
|
|
138
|
+
|
|
139
|
+
This signal is bring-your-own-database. `AnonymizerResolver` expects top-level
|
|
140
|
+
fields matching the MaxMind GeoIP2 Anonymous-IP schema:
|
|
141
|
+
`is_tor_exit_node`, `is_public_proxy`, `is_anonymous_vpn`,
|
|
142
|
+
`is_residential_proxy`, `is_hosting_provider`, and `is_anonymous`. Verify the
|
|
143
|
+
record shape before choosing a commercial or self-built MMDB.
|
|
144
|
+
|
|
145
|
+
The [Tor bulk exit list](https://check.torproject.org/torbulkexitlist) can be an
|
|
146
|
+
input to a self-built database, but Otto does not include an MMDB compiler or an
|
|
147
|
+
update job. A generated record must set `is_tor_exit_node` at the top level, and
|
|
148
|
+
the deployment must refresh and verify the file on its own schedule.
|
|
149
|
+
|
|
150
|
+
## Acceptance behavior summary
|
|
151
|
+
|
|
152
|
+
| Scenario | Result |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| Signal not enabled | `nil` when read; a processed public request may carry an env key whose value is `nil` |
|
|
155
|
+
| IP privacy disabled or no client IP resolves | `nil`; no enrichment values are written |
|
|
156
|
+
| Enabled, no database configured | `'**'` for a non-exempt request |
|
|
157
|
+
| Database read raises | `'**'` (a lookup must never crash a request) |
|
|
158
|
+
| ASN lookup | masked IP only (re-masked defensively in the resolver) |
|
|
159
|
+
| Anonymizer lookup | the configured reader receives the unmasked IP; Otto exposes only its label |
|
|
160
|
+
| Private/localhost client under the default profile | no enrichment keys in env |
|
|
161
|
+
| Bad `*_db_path` for an enabled signal | raises during configuration, not per-request |
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Forwarded host authority
|
|
2
|
+
|
|
3
|
+
Reverse proxies use forwarding headers to report the original request's host,
|
|
4
|
+
scheme, and port. Rack reads these values before it falls back to the request's
|
|
5
|
+
`Host` header and direct connection details. Without a trust boundary, a client
|
|
6
|
+
can send `X-Forwarded-Host` or an RFC 7239 `Forwarded` header and choose the host
|
|
7
|
+
that the application believes it serves.
|
|
8
|
+
|
|
9
|
+
This affects any value derived from `request.host`, `request.scheme`,
|
|
10
|
+
`request.ssl?`, or `request.port`, including redirect targets, generated links,
|
|
11
|
+
WebAuthn `rp_id`, OmniAuth `redirect_uri`, mailer base URLs, secure-cookie
|
|
12
|
+
decisions, and mounted Rack applications that build absolute URLs.
|
|
13
|
+
|
|
14
|
+
Otto uses the proxy trust configured for client IP resolution to decide whether
|
|
15
|
+
Rack may also use forwarded host, scheme, and port values.
|
|
16
|
+
|
|
17
|
+
> [!WARNING]
|
|
18
|
+
> Omitting proxy trust does not reject forwarded authority. Otto leaves the
|
|
19
|
+
> headers intact for compatibility, and Rack applies its own process-global
|
|
20
|
+
> policy. Choose an explicit trust posture before serving requests whenever
|
|
21
|
+
> application behavior depends on these request values.
|
|
22
|
+
|
|
23
|
+
## Choose a trust posture
|
|
24
|
+
|
|
25
|
+
| Deployment | Configuration | Result |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| The application is directly exposed and should trust no proxy | `trusted_proxies: :none` | Otto strips forwarded host, scheme, and port carriers from every request. |
|
|
28
|
+
| Proxy addresses can be enumerated | `trusted_proxies: [...]` | Otto keeps the carriers only when `REMOTE_ADDR` matches a configured proxy. |
|
|
29
|
+
| Proxy addresses cannot be enumerated, but the hop count is fixed | `trusted_proxy_depth: N` | Otto trusts the carriers on every request. The application origin must accept traffic only from the proxy tier. |
|
|
30
|
+
| Another layer owns the trust decision | Leave proxy trust unconfigured | Otto leaves the carriers unchanged and makes no trust assertion. |
|
|
31
|
+
|
|
32
|
+
> [!WARNING]
|
|
33
|
+
> These settings control forwarded authority only. They do not validate the
|
|
34
|
+
> ordinary `Host` header. After forwarded carriers are stripped, Rack falls back
|
|
35
|
+
> to `Host`, which a direct client can still choose. If redirects, generated
|
|
36
|
+
> links, WebAuthn, OAuth, or cookie policy require a canonical host, enforce a
|
|
37
|
+
> host allowlist in the front server or application.
|
|
38
|
+
|
|
39
|
+
For a directly exposed application:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
otto = Otto.new('routes', trusted_proxies: :none)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For an application behind proxies whose addresses are known:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
otto = Otto.new(
|
|
49
|
+
'routes',
|
|
50
|
+
trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use depth mode only when the proxy addresses cannot be listed and the number of
|
|
55
|
+
proxy hops is fixed:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
otto = Otto.new('routes', trusted_proxy_depth: 1)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Depth mode treats every connecting peer as trusted. Before enabling it, prevent
|
|
62
|
+
direct access to the application origin with private networking, firewall or
|
|
63
|
+
security-group rules, or an equivalent control. Otherwise, a client can submit
|
|
64
|
+
forwarded host, scheme, port, and IP values directly.
|
|
65
|
+
|
|
66
|
+
Configure these options before the first request, when Otto freezes its
|
|
67
|
+
configuration.
|
|
68
|
+
|
|
69
|
+
## How Otto handles each trust state
|
|
70
|
+
|
|
71
|
+
The decision is made by `IPPrivacyMiddleware` from the connecting peer
|
|
72
|
+
(`REMOTE_ADDR`) before any masking, and recorded in
|
|
73
|
+
`env['otto.via_trusted_proxy']`.
|
|
74
|
+
|
|
75
|
+
| Trust state | `otto.via_trusted_proxy` | Forwarded host, scheme, and port carriers |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `REMOTE_ADDR` matches a configured trusted-proxy CIDR | `true` | Kept. When privacy masking applies, `Forwarded` keeps its `proto=`, `host=`, and `by=` fields while its `for=` value is replaced with the masked IP. |
|
|
78
|
+
| Depth mode is enabled | `true` for every peer | Kept, subject to the same privacy masking. |
|
|
79
|
+
| Proxy trust is configured, but the peer does not match a configured CIDR | `false` | Deleted. |
|
|
80
|
+
| `trusted_proxies: :none` is configured | `false` for every peer | Deleted. |
|
|
81
|
+
| Proxy trust is not configured | absent | Left unchanged. Otto makes no trust assertion, so Rack may apply its own policy. |
|
|
82
|
+
|
|
83
|
+
The deleted keys are `HTTP_FORWARDED`, `HTTP_X_FORWARDED_HOST`,
|
|
84
|
+
`HTTP_X_FORWARDED_PROTO`, `HTTP_X_FORWARDED_SCHEME`, `HTTP_X_FORWARDED_SSL`,
|
|
85
|
+
and `HTTP_X_FORWARDED_PORT`. `X-Forwarded-For` is not deleted on this path;
|
|
86
|
+
Otto's own client IP resolution already ignores it from an untrusted peer, and a
|
|
87
|
+
masking privacy profile rewrites it separately. With IP privacy disabled the
|
|
88
|
+
header reaches the application intact, and `Rack::Request#ip` returns its value
|
|
89
|
+
whenever `REMOTE_ADDR` is private or loopback. Read `env['otto.client_ip']`
|
|
90
|
+
rather than `Rack::Request#ip`, and configure any mounted gem that reads
|
|
91
|
+
`request.ip` (for example `Rack::Attack`) accordingly.
|
|
92
|
+
|
|
93
|
+
The absent `otto.via_trusted_proxy` key is intentional. It means the operator
|
|
94
|
+
made no proxy-trust assertion, so downstream consumers may apply their own
|
|
95
|
+
heuristics. This preserves compatibility for applications that already run
|
|
96
|
+
behind a proxy without configuring Otto's trust controls.
|
|
97
|
+
|
|
98
|
+
## Direct exposure: trust no proxy
|
|
99
|
+
|
|
100
|
+
Use `trusted_proxies: :none` when clients connect directly to the application
|
|
101
|
+
and no reverse proxy should influence request authority. This explicit assertion
|
|
102
|
+
is different from omitting `trusted_proxies`. The String spelling `'none'` (any
|
|
103
|
+
case) is accepted too, for YAML- or environment-driven configuration. The
|
|
104
|
+
sentinel is only valid as the whole option: a list that contains it, such as
|
|
105
|
+
`trusted_proxies: ['none']`, is rejected at configuration time rather than
|
|
106
|
+
installed as a proxy entry.
|
|
107
|
+
|
|
108
|
+
You can also make the assertion after construction, but before the first
|
|
109
|
+
request:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
otto.trust_no_proxies!
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The same operation is available through the security configurator and the
|
|
116
|
+
underlying security configuration:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
otto.security.trust_no_proxies!
|
|
120
|
+
otto.security_config.trust_no_proxies!
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Under this assertion:
|
|
124
|
+
|
|
125
|
+
- `env['otto.via_trusted_proxy']` is `false` for every peer;
|
|
126
|
+
- client IP resolution ignores forwarded chains and uses `REMOTE_ADDR`;
|
|
127
|
+
- forwarded host, scheme, and port carriers are stripped;
|
|
128
|
+
- `Rack::Request#host` resolves from the `Host` header; and
|
|
129
|
+
- trusted geo headers remain disabled because they require enumerated
|
|
130
|
+
trusted-proxy CIDRs.
|
|
131
|
+
|
|
132
|
+
Loopback is not special-cased. A reverse proxy running on `127.0.0.1` in front
|
|
133
|
+
of the application is an untrusted peer under this assertion and its forwarded
|
|
134
|
+
headers are stripped. Use `add_trusted_proxy('127.0.0.1')` for that deployment
|
|
135
|
+
instead. The separate `env['otto.peer_loopback']` signal is derived from the raw
|
|
136
|
+
peer and is unaffected, as is `env['otto.peer_relayed']`, which records whether
|
|
137
|
+
any relay marker header was present before the carriers were stripped so
|
|
138
|
+
`Otto::CaddyTLS::LocalhostGuard` still refuses a relayed request.
|
|
139
|
+
|
|
140
|
+
The assertion is mutually exclusive with an actual trust grant. Combining it
|
|
141
|
+
with trusted-proxy CIDRs or a depth of 1 or more raises at configuration time:
|
|
142
|
+
|
|
143
|
+
```text
|
|
144
|
+
Cannot combine trusted_proxies: :none (trust no proxy) with trusted_proxies
|
|
145
|
+
CIDRs or trusted_proxy_depth >= 1. Assert :none OR grant trust, not both.
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Why the whole Forwarded header is removed
|
|
149
|
+
|
|
150
|
+
For an untrusted peer Otto deletes `Forwarded` entirely rather than editing out
|
|
151
|
+
its `host=` field. Editing would require Otto to parse RFC 7239 itself, and a
|
|
152
|
+
parser that disagrees with Rack's on quoting can let a `host=` survive the edit.
|
|
153
|
+
A value such as `for=a"b;host=evil` is enough to produce that disagreement.
|
|
154
|
+
Deletion has no such failure mode. On this path Otto reads nothing from the
|
|
155
|
+
header itself, so nothing is lost.
|
|
156
|
+
|
|
157
|
+
## Choose the forwarding family for depth mode
|
|
158
|
+
|
|
159
|
+
`Rack::Request.forwarded_priority` is a process-wide setting that selects the
|
|
160
|
+
main forwarding family Rack reads: `X-Forwarded-*`, RFC 7239 `Forwarded`, or
|
|
161
|
+
both. Otto pins it to the family selected by `trusted_proxy_header` so client-IP
|
|
162
|
+
resolution and Rack's main forwarded host, port, and scheme parsing agree.
|
|
163
|
+
|
|
164
|
+
This is not a complete sanitizer for requests from a trusted peer. Rack checks
|
|
165
|
+
some compatibility carriers independently, notably `X-Forwarded-SSL`. Otto
|
|
166
|
+
keeps forwarded carriers from trusted peers because it cannot distinguish
|
|
167
|
+
values created by the proxy from values the proxy passed through. Configure the
|
|
168
|
+
trusted proxy to remove client-supplied forwarding headers before setting its
|
|
169
|
+
own authoritative values.
|
|
170
|
+
|
|
171
|
+
The pin governs host, port, and the `X-Forwarded-Proto` / `proto=` scheme
|
|
172
|
+
lookup. It does not govern `X-Forwarded-SSL`: Rack (3.2.x) honors
|
|
173
|
+
`X-Forwarded-SSL: on` before it consults `forwarded_priority`, in every family.
|
|
174
|
+
Otto covers this by deleting `X-Forwarded-SSL` together with the other
|
|
175
|
+
authority carriers for any untrusted peer, so the header only reaches Rack from
|
|
176
|
+
a trusted proxy or an unconfigured deployment.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
otto = Otto.new(
|
|
180
|
+
'routes',
|
|
181
|
+
trusted_proxy_depth: 1,
|
|
182
|
+
trusted_proxy_header: 'Forwarded' # or 'X-Forwarded-For' (default), or 'Both'
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Because the setting is process-wide, two Otto applications mounted in one
|
|
187
|
+
process that both resolve proxied requests must agree. The later one raises:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
Cannot use forwarding family %s (trusted_proxy_header) because another Otto
|
|
191
|
+
application in this process already uses %s. Rack's forwarded host, port,
|
|
192
|
+
scheme, and IP policy is process-global, so every Otto application in one
|
|
193
|
+
process that resolves proxied requests must use the same forwarding family.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The two placeholders are the requested family and the already committed one.
|
|
197
|
+
|
|
198
|
+
An application that configures no proxy trust, and one that asserts
|
|
199
|
+
`trusted_proxies: :none`, read no forwarded chain and therefore stake no claim
|
|
200
|
+
on the family. Neither can block a later explicit choice, unless it also names
|
|
201
|
+
`trusted_proxy_header` explicitly. Otto only reads the header in depth mode,
|
|
202
|
+
but setting it is always a claim: it pins Rack's `forwarded_priority` and
|
|
203
|
+
registers the family for the process, even under `trusted_proxies: :none`.
|
|
204
|
+
|
|
205
|
+
`trusted_proxy_header` accepts `X-Forwarded-For` (the default), `Forwarded`, or
|
|
206
|
+
`Both`. When configuring proxy trust, `Forwarded` and `Both` require depth mode.
|
|
207
|
+
CIDR filter mode resolves client IPs from the `X-Forwarded-For` family only
|
|
208
|
+
(`X-Forwarded-For`, then `X-Real-IP`, then `X-Client-IP`) and never from RFC
|
|
209
|
+
7239 `Forwarded`, so a non-default family would make Rack read a header that
|
|
210
|
+
Otto ignores:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together with
|
|
214
|
+
trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs from the
|
|
215
|
+
X-Forwarded-For family only (X-Forwarded-For, X-Real-IP, X-Client-IP), never
|
|
216
|
+
RFC 7239 Forwarded. Use trusted_proxy_depth (count mode) to read the RFC 7239
|
|
217
|
+
Forwarded header.
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Use `trusted_proxy_depth` when the deployment requires RFC 7239 `Forwarded`.
|
|
221
|
+
Remember that depth mode also requires origin lockdown because it trusts every
|
|
222
|
+
connecting peer.
|
|
223
|
+
|
|
224
|
+
## Place the middleware before other request consumers
|
|
225
|
+
|
|
226
|
+
Otto performs this filtering in `IPPrivacyMiddleware`, which it installs first
|
|
227
|
+
in its own stack. Downstream middleware, mounted applications, and handlers see
|
|
228
|
+
the filtered environment.
|
|
229
|
+
|
|
230
|
+
If middleware outside the Otto application reads the request first, mount
|
|
231
|
+
`IPPrivacyMiddleware` ahead of it in the common Rack stack. Pass the
|
|
232
|
+
application's security configuration, as shown in
|
|
233
|
+
[Privacy-preserving request data](privacy.md#middleware-placement). Without that
|
|
234
|
+
configuration, the outer middleware instance applies defaults and makes no
|
|
235
|
+
trust decision, so forwarded authority reaches earlier middleware unchanged.
|
|
236
|
+
|
|
237
|
+
The inner instance still enforces the application's own posture when an outer
|
|
238
|
+
pass has already resolved the client IP:
|
|
239
|
+
|
|
240
|
+
- `trusted_proxies: :none` always applies. The inner instance records
|
|
241
|
+
`otto.via_trusted_proxy` as `false` and strips the authority carriers, whatever
|
|
242
|
+
the outer instance did.
|
|
243
|
+
- `trusted_proxy_depth` records `true` unless a configured outer pass already
|
|
244
|
+
recorded a verdict.
|
|
245
|
+
- `trusted_proxies: [...]` keeps the verdict of a configured outer pass. When no
|
|
246
|
+
outer pass recorded one, the connecting peer can no longer be matched, because
|
|
247
|
+
the outer pass rewrote `REMOTE_ADDR`. Otto then treats the peer as untrusted,
|
|
248
|
+
strips the carriers, and logs a warning naming the fix: pass the application's
|
|
249
|
+
security configuration to the outer instance.
|