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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. 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.