otto 2.2.0 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0616e034fc5859c42ed2eb48e1d123abdd1b36bca93422cf858d51331843c2c9
4
- data.tar.gz: 667c3067cdd71ed41ef733c479798836522c32d37044edeb5f78d33c04b14818
3
+ metadata.gz: 50aa75316f4a3f73a0784cf78fb29e05c030901fa75ac1b8285a6ec0ad89fc3b
4
+ data.tar.gz: 38c18d2dc357589e1377a5df0cecd0de26370b002061d9bde6d66994b4990610
5
5
  SHA512:
6
- metadata.gz: c219d4864ffc3090983ee50828279914dc052d0d65f5fd75dbaff812a1fee5d32880c325956d778c9a68e39e396e0f855f271eb3355ddee9d846e186586c57a2
7
- data.tar.gz: d32b8d2235fe0d2c95510c4ec5f1623f30a6039bac1a309878af03907458fd9a51ea18f6e5584dfe88754a45cef25469f52ba72bee7847e97b87f2e481672e9d
6
+ metadata.gz: e5f9c0693f83c423a77b20ac88c6af74db6c3d4a9ea345304085b31249a1d20537d64d5c091c5ffb5f6382bfd3fe291d4bcc85f5949928ef09ab8ae74fe09c3b
7
+ data.tar.gz: 29fb704139356b0e0df805567e761593b10faf519f7977087425f9752711dfe4315b41266d864c071779f1e37b55e1a9d40c454c74f5973f728f3f1e1ff1bb97
@@ -63,7 +63,7 @@ jobs:
63
63
  lockfile: "unlocked"
64
64
 
65
65
  steps:
66
- - uses: actions/checkout@v6
66
+ - uses: actions/checkout@v6.0.3
67
67
  - name: Set up Ruby
68
68
  uses: ruby/setup-ruby@v1
69
69
  continue-on-error: ${{ matrix.experimental }}
@@ -74,7 +74,7 @@ jobs:
74
74
  bundler-cache: ${{ !matrix.experimental && matrix.lockfile == 'locked' }}
75
75
 
76
76
  - name: Setup tmate session
77
- uses: mxschmitt/action-tmate@c0afd6f790e3a5564914980036ebf83216678101 # v3
77
+ uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3
78
78
  if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
79
79
  with:
80
80
  detached: true
@@ -27,7 +27,7 @@ jobs:
27
27
 
28
28
  steps:
29
29
  - name: Checkout repository
30
- uses: actions/checkout@v6
30
+ uses: actions/checkout@v6.0.3
31
31
  with:
32
32
  fetch-depth: 1
33
33
 
@@ -37,6 +37,11 @@ jobs:
37
37
  with:
38
38
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39
39
 
40
+ # Fall back to Sonnet when the primary model is unavailable/overloaded.
41
+ # Without this, an unavailable primary surfaces as "Claude encountered
42
+ # an error" and fails the claude-review check.
43
+ fallback_model: "claude-sonnet-4-6"
44
+
40
45
  # Direct prompt for automated review (no @claude mention needed)
41
46
  direct_prompt: |
42
47
  Please review this pull request and provide feedback on:
@@ -26,7 +26,7 @@ jobs:
26
26
  actions: read # Required for Claude to read CI results on PRs
27
27
  steps:
28
28
  - name: Checkout repository
29
- uses: actions/checkout@v6
29
+ uses: actions/checkout@v6.0.3
30
30
  with:
31
31
  fetch-depth: 1
32
32
 
@@ -21,7 +21,7 @@ jobs:
21
21
 
22
22
  steps:
23
23
  - name: Checkout code
24
- uses: actions/checkout@v6
24
+ uses: actions/checkout@v6.0.3
25
25
 
26
26
  - name: Set up Ruby
27
27
  uses: ruby/setup-ruby@v1
@@ -88,7 +88,7 @@ jobs:
88
88
 
89
89
  steps:
90
90
  - name: Checkout code
91
- uses: actions/checkout@v6
91
+ uses: actions/checkout@v6.0.3
92
92
 
93
93
  - name: Set up Ruby
94
94
  uses: ruby/setup-ruby@v1
@@ -124,7 +124,7 @@ jobs:
124
124
 
125
125
  steps:
126
126
  - name: Checkout
127
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
127
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
128
128
  with:
129
129
  persist-credentials: false
130
130
 
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,170 @@ 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.3.0:
11
+
12
+ 2.3.0 — 2026-06-21
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Count-based trusted-proxy resolution ("trust the last N hops"), the Express
19
+ ``trust proxy = N`` primitive, via a new
20
+ ``Otto::Security::Config#trusted_proxy_depth`` accessor (Integer, default
21
+ ``nil``). ``nil`` / ``0`` keeps the existing CIDR-walk; ``>= 1`` enables depth
22
+ mode. This is the sound model for non-enumerable proxy tiers (Fly, cloud load
23
+ balancers, dynamic reverse proxies) whose addresses cannot be listed as CIDRs.
24
+ Resolution flows through the shared ``Otto::Utils.resolve_client_ip``, so the
25
+ canonical ``env['otto.client_ip']`` (masking, idempotency, "read everywhere")
26
+ and the standalone ``Request#client_ipaddress`` fallback both honor depth with
27
+ no further wiring. Settable through ``Otto::Security::Configurator#configure``
28
+ (``trusted_proxy_depth:``) and the ``trusted_proxy_depth`` option of
29
+ ``configure_security``. Depth resolves the client *IP* only; it is decoupled
30
+ from proxy proto-trust — ``env['otto.via_trusted_proxy']`` (and therefore
31
+ ``Otto::Request#secure?`` honoring ``X-Forwarded-Proto`` / ``X-Scheme``) remains
32
+ the trusted-proxy *identity* check and is never derived from hop depth,
33
+ matching the downstream OneTimeSecret behavior. (onetimesecret#3436,
34
+ onetimesecret#3116)
35
+
36
+ Changed
37
+ -------
38
+
39
+ - ``Otto::Security::Config#trusted_proxy?`` now matches string entries with
40
+ proper ``IPAddr`` CIDR containment for both IPv4 and IPv6, replacing the
41
+ previous ``==`` / ``start_with?`` text matching. Bare hosts (e.g.
42
+ ``192.168.1.1``) match only exactly, and CIDR ranges (e.g. ``10.0.0.0/8``)
43
+ now actually match contained addresses. Non-IP entries (e.g. ``172.16.``)
44
+ still fall back to the legacy prefix match, and ``Regexp`` entries are
45
+ unchanged. This is a behavior change: addresses that were previously
46
+ matched only because they shared a textual prefix are no longer treated as
47
+ trusted. (otto#58, onetimesecret#3436)
48
+ - ``IPPrivacyMiddleware`` now resolves the client IP once into a canonical
49
+ ``env['otto.client_ip']`` ("resolve once, read everywhere") and is
50
+ idempotent: a second pass (e.g. when the middleware is mounted both at the
51
+ app and router levels) yields instead of re-resolving and double-masking.
52
+ - ``Otto::Request#ip`` and ``#client_ipaddress`` now prefer
53
+ ``env['otto.client_ip']`` when present, falling back to Rack's native
54
+ resolution when the middleware has not run. Downstream code no longer
55
+ depends on ``REMOTE_ADDR`` / ``X-Forwarded-For`` rewriting being
56
+ load-bearing.
57
+ - ``Otto::Request#secure?`` now authorizes ``X-Forwarded-Proto`` /
58
+ ``X-Scheme`` from a canonical, leak-free ``env['otto.via_trusted_proxy']``
59
+ flag recorded by ``IPPrivacyMiddleware`` before masking, instead of
60
+ re-deriving trust from the (now masked) ``REMOTE_ADDR``. It falls back to
61
+ the previous behavior when the middleware has not run.
62
+ - ``add_trusted_proxy`` now logs a warning when given a string that is not a
63
+ valid IP or CIDR (e.g. ``'172.16.'``), since such entries use legacy
64
+ string-prefix matching; prefer a CIDR range.
65
+ - IP validation and port-stripping were consolidated into
66
+ ``Otto::Utils.normalize_ip`` / ``strip_ip_port`` (previously duplicated in
67
+ ``IPPrivacyMiddleware`` and ``Otto::Request``).
68
+ - Trusted-proxy string entries are now parsed to ``IPAddr`` once at
69
+ registration (in ``add_trusted_proxy``) and cached, so ``trusted_proxy?``
70
+ no longer re-parses each entry on every request.
71
+ - Client-IP resolution from forwarded headers is now a single shared
72
+ ``Otto::Utils.resolve_client_ip`` used by both ``IPPrivacyMiddleware``
73
+ ("resolve once") and ``Otto::Request#client_ipaddress`` (its no-middleware
74
+ fallback), so the two paths can no longer disagree on which headers to trust
75
+ or how to walk a proxy chain. The standalone ``Request`` fallback now walks
76
+ the forwarded chain skipping trusted proxies (matching the middleware) and
77
+ consults ``X-Client-IP`` instead of the legacy ``Client-IP`` header.
78
+
79
+ - ``RouteHandlers::BaseHandler`` raises ``ArgumentError`` (was ``NameError``)
80
+ for an unresolvable handler class name. (otto#147)
81
+ - ``Otto.logger`` never returns ``nil`` (lazy ``$stdout`` default); assign
82
+ ``Otto.logger=`` to override or silence. (otto#147)
83
+
84
+ Removed
85
+ -------
86
+
87
+ - SQL-injection pattern matching from input validation
88
+ (``ValidationMiddleware::SQL_INJECTION_PATTERNS`` and related checks). It
89
+ produced false positives and was trivially bypassable; defend against SQL
90
+ injection with parameterized queries at the data-access layer. (otto#147)
91
+
92
+ Fixed
93
+ -----
94
+
95
+ - IPv6 addresses are no longer truncated during proxy resolution.
96
+ ``validate_ip_address`` previously did ``ip.split(':').first``, collapsing
97
+ an IPv6 address to its first hextet; it now uses ``IPAddr`` validation with
98
+ IPv6-safe port stripping (bracketed ``[2001:db8::1]:443`` and IPv4
99
+ ``host:port``). IPv6 clients behind trusted proxies now resolve and mask
100
+ correctly. (onetimesecret#3436)
101
+ - ``Otto::Request#redacted_fingerprint``, ``#geo_country``, ``#hashed_ip``
102
+ and ``#masked_ip`` (plus ``NoAuthStrategy`` metadata and
103
+ ``LoggingHelpers`` country) read the canonical ``otto.privacy.*`` env keys
104
+ the middleware actually writes; they previously read un-namespaced keys
105
+ that were never set and so always returned ``nil``.
106
+ - ``Otto::Request#private_ip?`` (and therefore ``#local_or_private_ip?`` /
107
+ ``#local?``) is now IPv4- **and** IPv6-aware via ``Otto::Utils.private_ip?``.
108
+ It recognizes IPv6 loopback (``::1``), unique-local (``fc00::/7``),
109
+ link-local (``fe80::/10``), multicast and unspecified addresses; the previous
110
+ IPv4-only regex silently classified every IPv6 address as public.
111
+ - Anonymous and auth-failure metadata (``NoAuthStrategy``,
112
+ ``RouteAuthWrapper``) and ``LoggingHelpers.request_context`` now record the
113
+ canonical ``otto.client_ip`` (falling back to ``REMOTE_ADDR``), so the real
114
+ client — not the connecting proxy — is logged when IP privacy is disabled
115
+ behind a trusted proxy.
116
+
117
+ - The CSRF ``<meta>`` tag is now injected into ``<head>`` tags that carry
118
+ attributes, not only a bare ``<head>``. (otto#147)
119
+
120
+ Security
121
+ --------
122
+
123
+ - Trusted-proxy matching is now correct CIDR containment rather than text
124
+ prefix matching, removing both false positives (e.g. ``192.168.1.100``
125
+ matching the host ``192.168.1.1``) and false negatives (CIDR ranges that
126
+ never matched). ``secure?`` no longer silently fails to trust
127
+ ``X-Forwarded-Proto`` behind a TLS-terminating trusted proxy when IP
128
+ privacy is enabled. (onetimesecret#3436)
129
+
130
+ - CSRF tokens are now signed with HMAC-SHA256 keyed by a server-side secret and
131
+ bound to the session id, so they can no longer be self-minted or replayed
132
+ across sessions. Set the secret via ``OTTO_CSRF_SECRET`` or
133
+ ``Otto::Security::Config#csrf_secret=``; enabling CSRF in production without
134
+ one now raises instead of silently using a per-process secret. (otto#147)
135
+ - All route/handler class-name resolution goes through
136
+ ``Otto::Security::ConstantResolver``, extending the existing format check and
137
+ forbidden-class blocklist to ``RouteHandlers::BaseHandler`` and the MCP
138
+ registry/server (previously unguarded). Forbidden classes reached via a
139
+ namespace prefix or constant inheritance (e.g. ``Object::Kernel``) are now
140
+ rejected as well. (otto#147)
141
+ - MCP bearer tokens and API keys are compared in constant time. (otto#147)
142
+
143
+ - Depth resolution trusts exactly N hops counted from the right of
144
+ ``X-Forwarded-For`` plus ``REMOTE_ADDR``, so a forged leftmost forwarded entry
145
+ is never reached. Positions are counted raw (never dropped) so junk padding
146
+ cannot shift the index, and only the selected entry is validated. A chain
147
+ shorter than ``N + 1`` (a request that may have bypassed the proxy tier) or an
148
+ invalid target entry falls back to ``REMOTE_ADDR`` rather than a spoofable
149
+ forwarded value. Depth mode is XFF-only (single-value ``X-Real-IP`` /
150
+ ``X-Client-IP`` cannot express a hop chain) and **assumes origin lockdown** —
151
+ the app must be unreachable except through the proxy tier. CIDR-walk and depth
152
+ are mutually exclusive, and ``trusted_proxy_depth`` must be a non-negative
153
+ Integer or ``nil``; both are validated immediately at configuration time (with
154
+ a freeze-time backstop), so an invalid or contradictory setup fails fast.
155
+
156
+ Documentation
157
+ -------------
158
+
159
+ - Extended ``docs/migrating/v2.3.0.md`` with a count-based depth section covering
160
+ when to use depth vs CIDR-walk, the origin-lockdown prerequisite, configuration
161
+ examples, Express parity, and the XFF-only / short-chain / mutual-exclusivity
162
+ semantics.
163
+
164
+ AI Assistance
165
+ -------------
166
+
167
+ - Issue #147 findings triaged, fixed, and verified with AI assistance, including
168
+ an adversarial review that surfaced the namespace-prefix blocklist bypass.
169
+
170
+ - Trusted-proxy depth design review, threat-model analysis (origin-lockdown
171
+ trade vs CIDR enumerability, raw-position counting to defeat XFF padding),
172
+ implementation and test coverage developed with AI pair programming.
173
+
10
174
  .. _changelog-2.2.0:
11
175
 
12
176
  2.2.0 — 2026-06-09
data/Gemfile.lock CHANGED
@@ -1,9 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.2.0)
4
+ otto (2.3.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
- ipaddr (~> 1, < 2.0)
7
6
  logger (~> 1, < 2.0)
8
7
  loofah (~> 2.20)
9
8
  rack (~> 3.1, < 4.0)
@@ -55,7 +54,6 @@ GEM
55
54
  erb (6.0.4)
56
55
  hana (1.3.7)
57
56
  io-console (0.8.2)
58
- ipaddr (1.2.9)
59
57
  irb (1.18.0)
60
58
  pp (>= 0.6.0)
61
59
  prism (>= 1.3.0)
@@ -0,0 +1,241 @@
1
+ # Otto v2.3.0 Migration Guide
2
+
3
+ ## Overview
4
+
5
+ This release harmonizes IP address and trusted-proxy resolution (the upstream
6
+ work for [onetimesecret#3436](https://github.com/onetimesecret/onetimesecret/issues/3436)).
7
+ The client IP is now resolved **once** into a canonical `env['otto.client_ip']`
8
+ that downstream code reads, trusted-proxy matching uses real CIDR containment,
9
+ and several privacy helpers that silently returned `nil` now work. It also adds
10
+ an additive, opt-in **count-based trusted-proxy depth** mode for proxy tiers
11
+ whose addresses cannot be enumerated as CIDRs (see the *New feature* section
12
+ below). Most apps need no changes; the one behavior change to review is
13
+ trusted-proxy matching.
14
+
15
+ ## Breaking / behavior changes
16
+
17
+ ### 1. Trusted-proxy matching is now real CIDR containment
18
+
19
+ `Otto::Security::Config#trusted_proxy?` previously compared entries with
20
+ `==` / `String#start_with?`. It now parses entries with `IPAddr` and matches
21
+ by containment for both IPv4 and IPv6.
22
+
23
+ What changes in practice:
24
+
25
+ ```ruby
26
+ config.add_trusted_proxy('10.0.0.0/8')
27
+ config.trusted_proxy?('10.1.2.3') # was false (CIDR never matched), now true
28
+
29
+ config.add_trusted_proxy('192.168.1.1')
30
+ config.trusted_proxy?('192.168.1.100') # was true (textual prefix), now false
31
+ ```
32
+
33
+ - **CIDR ranges now actually work.** Previously `add_trusted_proxy('10.0.0.0/8')`
34
+ was effectively a no-op because no real IP literally starts with the string
35
+ `'10.0.0.0/8'`. If you relied on a single-IP entry acting as a prefix to
36
+ cover a range, replace it with an explicit CIDR.
37
+ - **Bare hosts match only themselves.** `192.168.1.1` no longer matches
38
+ `192.168.1.100`, `192.168.1.10`, etc.
39
+ - **Non-IP strings still work via the legacy path.** An entry like `'172.16.'`
40
+ that does not parse as an IP/CIDR falls back to the old prefix match, and
41
+ `add_trusted_proxy` now logs a warning suggesting a CIDR (`172.16.0.0/12`).
42
+ - **`Regexp` entries are unchanged.**
43
+
44
+ **Action:** audit your `trusted_proxies` configuration. Convert any single-IP
45
+ or bare-prefix entries that were meant to cover a range into proper CIDR
46
+ notation.
47
+
48
+ ### 2. `secure?` trusts forwarded proto via a canonical flag
49
+
50
+ `Otto::Request#secure?` previously decided whether to honor `X-Forwarded-Proto`
51
+ / `X-Scheme` by checking `trusted_proxy?(REMOTE_ADDR)`. With IP privacy enabled,
52
+ `IPPrivacyMiddleware` rewrites `REMOTE_ADDR` to the masked client IP, so that
53
+ check could no longer see the connecting proxy and `secure?` could wrongly
54
+ return `false` behind a TLS-terminating trusted proxy.
55
+
56
+ The middleware now records the peer-trust decision once (before masking) in a
57
+ leak-free boolean `env['otto.via_trusted_proxy']`, and `secure?` reads it.
58
+ When the middleware has not run (standalone request use), `secure?` falls back
59
+ to its previous behavior. No app changes are required.
60
+
61
+ ### 3. Privacy helpers now return values (previously `nil`)
62
+
63
+ These helpers read the un-namespaced env keys that the middleware never set,
64
+ so they always returned `nil`. They now read the canonical `otto.privacy.*`
65
+ keys and return real values when IP privacy is enabled:
66
+
67
+ - `Otto::Request#redacted_fingerprint`
68
+ - `Otto::Request#geo_country`
69
+ - `Otto::Request#hashed_ip`
70
+ - `Otto::Request#masked_ip`
71
+
72
+ If you worked around these returning `nil`, you can now use them directly.
73
+
74
+ ### 4. `private_ip?` is now IPv6-aware
75
+
76
+ `Otto::Request#private_ip?` (used by `#local_or_private_ip?` and `#local?`) was
77
+ an IPv4-only regex that classified **every** IPv6 address — including `::1` and
78
+ ULA `fc00::/7` — as public. It now delegates to `Otto::Utils.private_ip?`, which
79
+ recognizes IPv6 loopback, unique-local, link-local, multicast and unspecified
80
+ addresses (and folds IPv4-mapped IPv6). IPv4 behavior is preserved for the
81
+ RFC1918, link-local and unspecified ranges; two IPv4 cases are now *also*
82
+ classified non-public that the old regex missed: loopback `127.0.0.0/8` and the
83
+ full multicast block `224.0.0.0/4` (the old regex only matched `224.0.0.0/8`).
84
+ Both are harmless — `#local_or_private_ip?` already special-cased `127.0.0.1`,
85
+ and `private_ip?` no longer participates in client-IP resolution.
86
+
87
+ ### 5. Standalone `client_ipaddress` matches the middleware
88
+
89
+ `Otto::Request#client_ipaddress` and `IPPrivacyMiddleware` now share one
90
+ resolver (`Otto::Utils.resolve_client_ip`). With the middleware mounted (the
91
+ normal case) `client_ipaddress` returns the canonical `env['otto.client_ip']`
92
+ unchanged. **Without** the middleware, its fallback now walks the
93
+ `X-Forwarded-For` chain skipping trusted proxies (instead of skipping private
94
+ IPs) and consults `X-Client-IP` rather than the legacy `Client-IP` header —
95
+ making the no-middleware path agree with production. This only affects apps that
96
+ use `Otto::Request` standalone without the Otto middleware stack.
97
+
98
+ ## New / canonical env keys
99
+
100
+ | Key | Type | Meaning |
101
+ |-----|------|---------|
102
+ | `otto.client_ip` | String | Canonical client IP, resolved once. Masked when privacy is enabled; resolved real IP when disabled or exempt. Read by `Request#ip` / `#client_ipaddress`. |
103
+ | `otto.via_trusted_proxy` | Boolean | Whether the request arrived via a trusted proxy, decided before masking. Read by `Request#secure?`. |
104
+
105
+ The privacy data keys remain `otto.privacy.{fingerprint,masked_ip,hashed_ip,geo_country}`.
106
+
107
+ ## New feature: count-based trusted-proxy depth (additive, opt-in)
108
+
109
+ New in 2.3.0 alongside the harmonization above: count-based trusted-proxy
110
+ resolution ("trust the last N hops"), the Express `trust proxy = N` primitive,
111
+ for proxy tiers whose addresses cannot be enumerated as CIDRs. It is
112
+ **additive** — `trusted_proxy_depth` defaults to `nil`, leaving CIDR-walk
113
+ behavior unchanged. This is the upstream landing point for the depth logic
114
+ previously carried in
115
+ [onetimesecret#3436](https://github.com/onetimesecret/onetimesecret/issues/3436)
116
+ and the `ConfigureTrustedProxy` monkeypatch
117
+ ([onetimesecret#3116](https://github.com/onetimesecret/onetimesecret/issues/3116)).
118
+
119
+ ### When to use depth vs CIDR-walk
120
+
121
+ | Use… | When… |
122
+ |------|-------|
123
+ | **CIDR-walk** (`add_trusted_proxy`, the default) | Your proxy IPs are **enumerable** — a fixed set of load balancers / reverse proxies you can list as IPs or CIDR ranges. The client is the first address in the forwarded chain that is not itself a trusted proxy. |
124
+ | **Depth** (`trusted_proxy_depth = N`) | Your proxy tier is **non-enumerable** — Fly, cloud load balancers, dynamic/autoscaling reverse proxies whose addresses you cannot pin down. You instead know the **fixed number of hops** between the client and your app. |
125
+
126
+ The two modes are **mutually exclusive**. Configuring both `trusted_proxies` and
127
+ `trusted_proxy_depth >= 1` raises an `ArgumentError` immediately (the moment the
128
+ second of the two is set), with a freeze-time backstop, so a contradictory setup
129
+ fails fast rather than silently picking one.
130
+
131
+ ### Configuration
132
+
133
+ ```ruby
134
+ # Non-enumerable single-proxy edge (e.g. one reverse proxy in front of the app):
135
+ otto.security_config.trusted_proxy_depth = 1
136
+
137
+ # Two hops (e.g. cloud LB -> app proxy -> app):
138
+ otto.security_config.trusted_proxy_depth = 2
139
+ ```
140
+
141
+ Or via the security facade / options, alongside the other security knobs:
142
+
143
+ ```ruby
144
+ otto.security.configure(trusted_proxy_depth: 1)
145
+
146
+ # or at construction (a top-level option, like `trusted_proxies`):
147
+ Otto.new(routes, trusted_proxy_depth: 1)
148
+ ```
149
+
150
+ `nil` or `0` disables depth mode (CIDR-walk applies). The accessor validates the
151
+ mutual-exclusion rule on assignment and raises `FrozenError` once the
152
+ configuration is frozen, like the other security scalars.
153
+
154
+ ### How depth resolution works
155
+
156
+ The resolver builds a chain of `X-Forwarded-For` (left = client … right =
157
+ nearest proxy) followed by `REMOTE_ADDR` (the direct peer), and trusts exactly
158
+ `N` hops from the **right**:
159
+
160
+ ```
161
+ chain = X-Forwarded-For (left → right) + [REMOTE_ADDR]
162
+ client = chain[-(N + 1)] # == Express addrs[N]
163
+ ```
164
+
165
+ **Express parity.** `trusted_proxy_depth = N` matches Express / `proxy-addr`
166
+ `trust proxy = N`: both trust `N` hops from the connecting peer inward and return
167
+ the next address as the client. When the chain is long enough, Otto's
168
+ `chain[-(N+1)]` is exactly Express's `addrs[N]`.
169
+
170
+ **Robust against `X-Forwarded-For` padding.** Because hops are counted from the
171
+ right, a forged **leftmost** entry is never reached. With `depth = 1`, a request
172
+ arriving as `X-Forwarded-For: 9.9.9.9, 203.0.113.50` (where `9.9.9.9` is
173
+ attacker-supplied and `203.0.113.50` is appended by the proxy) resolves to
174
+ `203.0.113.50`. Positions are counted **raw** — entries are never dropped before
175
+ counting, so an attacker cannot pad the header with junk/invalid entries to shift
176
+ the index. Only the single selected entry is validated.
177
+
178
+ **Short-chain and invalid-target fallback.** If the chain is shorter than
179
+ `N + 1` (a request that may have **bypassed** the proxy tier), or the selected
180
+ entry is not a valid IP, the resolver returns `REMOTE_ADDR` rather than a
181
+ spoofable forwarded value. This is intentionally **stricter than Express**, which
182
+ returns the leftmost (most spoofable) forwarded entry in that case.
183
+
184
+ **`X-Forwarded-For` only.** Depth mode consults **only** `X-Forwarded-For`.
185
+ `X-Real-IP` and `X-Client-IP` are single-value headers that cannot express a hop
186
+ chain, so they are ignored in depth mode (CIDR-walk still consults them).
187
+
188
+ **`secure?` is independent of depth.** Depth mode resolves the client **IP**
189
+ only; it does **not** grant proxy trust for `X-Forwarded-Proto` / `X-Scheme`.
190
+ `env['otto.via_trusted_proxy']` — which `Otto::Request#secure?` consults to honor
191
+ a forwarded proto — is derived solely from the trusted-proxy *identity* check
192
+ (does `REMOTE_ADDR` match a configured `trusted_proxies` CIDR?), never from hop
193
+ depth. Because depth mode and `trusted_proxies` are mutually exclusive, that
194
+ check is `false` under depth, so `secure?` does not honor a forwarded proto and
195
+ reflects only a direct TLS connection (`HTTPS=on` / port 443). This mirrors the
196
+ downstream (OneTimeSecret) behavior: proto-trust is never derived from depth.
197
+
198
+ ### Security prerequisite: origin lockdown
199
+
200
+ > **Depth trust assumes your app is unreachable except through the proxy tier.**
201
+
202
+ This is the inherent trade-off versus CIDR-walk: depth relies on a fixed network
203
+ **topology** instead of enumerable proxy **addresses**. If a client can reach
204
+ your app directly (origin not locked down), it can pad `X-Forwarded-For` so that
205
+ a forged value lands at `chain[-(N+1)]`, spoofing the resolved client IP. (Proto
206
+ trust is unaffected — depth never feeds `secure?` — but the resolved IP is only
207
+ as trustworthy as the lockdown.)
208
+
209
+ Before enabling depth, ensure the origin only accepts connections from the proxy
210
+ tier (private networking, security groups, an authenticating header the proxy
211
+ injects, etc.). If you can enumerate your proxies instead, prefer CIDR-walk.
212
+
213
+ ### Migrating from OneTimeSecret depth (`ConfigureTrustedProxy`)
214
+
215
+ If you are collapsing OneTimeSecret's `ClientIpHelpers` / `ConfigureTrustedProxy`
216
+ depth logic onto this resolver, note two intentional differences:
217
+
218
+ - **Off-by-one (Otto counts the peer).** Otto's chain is `X-Forwarded-For`
219
+ **plus** `REMOTE_ADDR`, so it is one hop longer than OTS's XFF-only chain. To
220
+ resolve the same client, Otto's depth must be **one higher** than the
221
+ operator's OTS `depth:`. When the YAML→Otto translator is built, map
222
+ **`trusted_proxy_depth = ots_depth + 1`** so existing `depth:` values keep
223
+ their meaning. Keep a parity regression test on the OTS side to lock this
224
+ mapping.
225
+
226
+ - **Stricter short-chain behavior (kept on purpose).** When the chain is shorter
227
+ than `N + 1`, Otto returns `REMOTE_ADDR` (the peer), whereas OTS returned the
228
+ leftmost — spoofable — `X-Forwarded-For` entry. Otto's behavior is the safer
229
+ one and is **not** reconciled down to OTS; expect a short-chain request to
230
+ resolve to the proxy peer rather than a forwarded value, and document this when
231
+ migrating.
232
+
233
+ ## No action required for
234
+
235
+ - Apps that pass `trusted_proxies` as CIDR ranges or `Regexp`.
236
+ - Apps that read `req.ip` / `req.client_ipaddress` (now backed by the canonical
237
+ value, same masked result).
238
+ - Apps relying on IPv6 — IPv6 client resolution behind trusted proxies is now
239
+ correct (it was previously truncated to the first hextet).
240
+ - Apps that do not set `trusted_proxy_depth` (default `nil` → unchanged
241
+ CIDR-walk behavior; depth mode is entirely opt-in).
@@ -57,6 +57,10 @@ class Otto
57
57
  # Add trusted proxies if provided
58
58
  Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) } if opts[:trusted_proxies]
59
59
 
60
+ # Set count-based trusted-proxy depth if provided (mutually exclusive
61
+ # with trusted_proxies; conflict validated at configuration freeze).
62
+ @security_config.trusted_proxy_depth = opts[:trusted_proxy_depth] if opts[:trusted_proxy_depth]
63
+
60
64
  # Set custom security headers
61
65
  return unless opts[:security_headers]
62
66
 
data/lib/otto/env_keys.rb CHANGED
@@ -61,6 +61,17 @@ class Otto
61
61
  # Used by: All security middleware (CSRF, Headers, Validation)
62
62
  SECURITY_CONFIG = 'otto.security_config'
63
63
 
64
+ # Whether the request arrived via a trusted proxy.
65
+ # Type: Boolean
66
+ # Set by: IPPrivacyMiddleware (every request, evaluated on the original
67
+ # peer BEFORE REMOTE_ADDR is masked). This is the trusted-proxy identity
68
+ # check (does REMOTE_ADDR match a configured trusted_proxies CIDR?) — it is
69
+ # independent of count-based depth mode, which resolves the client IP but
70
+ # never grants proxy trust for forwarded proto.
71
+ # Used by: Otto::Request#secure? to authorize X-Forwarded-Proto / X-Scheme
72
+ # without depending on the (masked) REMOTE_ADDR
73
+ VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
74
+
64
75
  # =========================================================================
65
76
  # LOCALIZATION (I18N)
66
77
  # =========================================================================
@@ -103,6 +114,16 @@ class Otto
103
114
  # PRIVACY (IP MASKING)
104
115
  # =========================================================================
105
116
 
117
+ # Canonical client IP, resolved once early by IPPrivacyMiddleware
118
+ # ("resolve once, read everywhere"). Downstream code (client_ipaddress,
119
+ # Request#ip) reads this instead of re-deriving from REMOTE_ADDR / XFF.
120
+ # Type: String
121
+ # Set by: IPPrivacyMiddleware (every request, all modes)
122
+ # Value: masked IP when privacy enabled; resolved real IP when privacy
123
+ # disabled or the address is exempt (private/localhost)
124
+ # Note: presence also acts as the idempotency guard for the middleware
125
+ CLIENT_IP = 'otto.client_ip'
126
+
106
127
  # Privacy-safe masked IP address
107
128
  # Type: String (e.g., '192.168.1.0')
108
129
  # Set by: IPPrivacyMiddleware
@@ -38,11 +38,9 @@ class Otto
38
38
  input_str = sanitized_input
39
39
  end
40
40
 
41
- # Always check for SQL injection
42
- ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
43
- raise Otto::Security::ValidationError, 'Potential SQL injection detected' if input_str.match?(pattern)
44
- end
45
-
41
+ # NOTE: no SQL-injection inspection here on purpose — see
42
+ # ValidationMiddleware. Defend against SQL injection with parameterized
43
+ # queries at the data-access layer, not input blocklists.
46
44
  input_str
47
45
  end
48
46
 
@@ -76,8 +76,8 @@ class Otto
76
76
  {
77
77
  method: env['REQUEST_METHOD'],
78
78
  path: env['PATH_INFO'],
79
- ip: env['REMOTE_ADDR'], # Already masked by IPPrivacyMiddleware for public IPs
80
- country: env['otto.geo_country'],
79
+ ip: env['otto.client_ip'] || env['REMOTE_ADDR'], # Canonical client IP (masked when privacy on)
80
+ country: env['otto.privacy.geo_country'],
81
81
  user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
82
82
  }.compact
83
83
  end
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
+ require 'rack/utils'
6
7
 
7
8
  class Otto
8
9
  module MCP
@@ -17,11 +18,21 @@ class Otto
17
18
  token = extract_token(env)
18
19
  return false unless token
19
20
 
20
- @tokens.include?(token)
21
+ valid_token?(token)
21
22
  end
22
23
 
23
24
  private
24
25
 
26
+ def valid_token?(candidate)
27
+ # Constant-time membership: compare against every configured token without
28
+ # short-circuiting on the first match, so neither match position nor
29
+ # membership leaks via timing. Rack::Utils.secure_compare itself is
30
+ # constant-time for equal-length strings.
31
+ @tokens.reduce(false) do |matched, token|
32
+ Rack::Utils.secure_compare(token, candidate) || matched
33
+ end
34
+ end
35
+
25
36
  def extract_token(env)
26
37
  # Try Authorization header first (Bearer token)
27
38
  auth_header = env['HTTP_AUTHORIZATION']
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative '../security/constant_resolver'
6
+
5
7
  class Otto
6
8
  module MCP
7
9
  # Registry for managing MCP resources and tools
@@ -82,7 +84,7 @@ class Otto
82
84
  klass_name = klass_method[0..-2].join('::')
83
85
  method_name = klass_method.last
84
86
 
85
- klass = Object.const_get(klass_name)
87
+ klass = Otto::Security::ConstantResolver.safe_const_get(klass_name)
86
88
  result = klass.public_send(method_name, arguments, env)
87
89
  else
88
90
  raise "Invalid tool handler: #{handler}"
@@ -8,6 +8,7 @@ require_relative 'route_parser'
8
8
  require_relative 'auth/token'
9
9
  require_relative 'schema_validation'
10
10
  require_relative 'rate_limiting'
11
+ require_relative '../security/constant_resolver'
11
12
 
12
13
  class Otto
13
14
  module MCP
@@ -124,7 +125,7 @@ class Otto
124
125
 
125
126
  # Create resource handler
126
127
  handler = lambda do
127
- klass = Object.const_get(klass_name)
128
+ klass = Otto::Security::ConstantResolver.safe_const_get(klass_name)
128
129
  method = klass.method(method_name)
129
130
  if method.arity != 0
130
131
  raise ArgumentError, "Handler #{klass_name}.#{method_name} must be a zero-arity method for resource #{uri}"