otto 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0616e034fc5859c42ed2eb48e1d123abdd1b36bca93422cf858d51331843c2c9
4
- data.tar.gz: 667c3067cdd71ed41ef733c479798836522c32d37044edeb5f78d33c04b14818
3
+ metadata.gz: 72c7a8fdbc299b202666d5f94532e4eed6d3fc2b2ee6a58234519deba65bfb0c
4
+ data.tar.gz: d82daec0441d3e9a8c196e6306aa8945292b8a322e3acbdf146294bc55345782
5
5
  SHA512:
6
- metadata.gz: c219d4864ffc3090983ee50828279914dc052d0d65f5fd75dbaff812a1fee5d32880c325956d778c9a68e39e396e0f855f271eb3355ddee9d846e186586c57a2
7
- data.tar.gz: d32b8d2235fe0d2c95510c4ec5f1623f30a6039bac1a309878af03907458fd9a51ea18f6e5584dfe88754a45cef25469f52ba72bee7847e97b87f2e481672e9d
6
+ metadata.gz: 64e1787e35c416585076ba4befa922c4306f0f023cba463877a66b821ab9e1518e3afda4edd6e8a319a04750c3c29d5b79196fc4961f807438b1f27eed4d0687
7
+ data.tar.gz: e6384fc6c848c602ea4c7f2c7eee7aaab7a715df054e404d6660e9c72fb85b52f4ff0a98108121a4995fe401a09bc67bcb459d0e37d00f4897a42df6b90d2d10
@@ -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:
@@ -46,7 +51,7 @@ jobs:
46
51
  - Security concerns
47
52
  - Test coverage
48
53
 
49
- Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
54
+ Use the repository's CLAUDE.md or AGENTS.md for guidance on style and conventions. Be constructive and helpful in your feedback.
50
55
 
51
56
  # Use sticky comments to reuse the same comment on subsequent pushes to the same PR
52
57
  use_sticky_comment: true
@@ -26,16 +26,21 @@ 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
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@v1
35
+ uses: anthropics/claude-code-action@beta
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
38
 
39
+ # Fall back to Sonnet when the primary model is unavailable/overloaded.
40
+ # Without this, an unavailable primary surfaces as "Claude encountered
41
+ # an error" and fails the claude-review check.
42
+ fallback_model: "claude-sonnet-4-6"
43
+
39
44
  # This is an optional setting that allows Claude to read CI results on PRs
40
45
  additional_permissions: |
41
46
  actions: read
@@ -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,195 @@ 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.1:
11
+
12
+ 2.3.1 — 2026-06-22
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Depth mode (``Otto::Security::Config#trusted_proxy_depth``) can now count hops
19
+ from a configurable forwarded header, via a new ``#trusted_proxy_header``
20
+ accessor: ``X-Forwarded-For`` (default), ``Forwarded`` (RFC 7239), or ``Both``
21
+ (``Forwarded`` when it carries a ``for=``, otherwise ``X-Forwarded-For``).
22
+ Settable through ``Otto::Security::Configurator#configure`` and the
23
+ ``trusted_proxy_header`` option of ``Otto.new`` / ``configure_security``; an
24
+ unrecognized value raises ``ArgumentError`` at assignment. This reaches parity
25
+ with OneTimeSecret's ``site.network.trusted_proxy.header``. (delano/otto#150,
26
+ onetimesecret#3436)
27
+
28
+ AI Assistance
29
+ -------------
30
+
31
+ - RFC 7239 ``Forwarded`` / ``Both`` depth-header support designed and implemented
32
+ with AI pair programming, with per-header, quoted-IPv6, and ``Both``-precedence
33
+ test coverage.
34
+
35
+ .. _changelog-2.3.0:
36
+
37
+ 2.3.0 — 2026-06-21
38
+ ==================
39
+
40
+ Added
41
+ -----
42
+
43
+ - Count-based trusted-proxy resolution ("trust the last N hops"), the Express
44
+ ``trust proxy = N`` primitive, via a new
45
+ ``Otto::Security::Config#trusted_proxy_depth`` accessor (Integer, default
46
+ ``nil``). ``nil`` / ``0`` keeps the existing CIDR-walk; ``>= 1`` enables depth
47
+ mode. This is the sound model for non-enumerable proxy tiers (Fly, cloud load
48
+ balancers, dynamic reverse proxies) whose addresses cannot be listed as CIDRs.
49
+ Resolution flows through the shared ``Otto::Utils.resolve_client_ip``, so the
50
+ canonical ``env['otto.client_ip']`` (masking, idempotency, "read everywhere")
51
+ and the standalone ``Request#client_ipaddress`` fallback both honor depth with
52
+ no further wiring. Settable through ``Otto::Security::Configurator#configure``
53
+ (``trusted_proxy_depth:``) and the ``trusted_proxy_depth`` option of
54
+ ``configure_security``. Depth resolves the client *IP* only; it is decoupled
55
+ from proxy proto-trust — ``env['otto.via_trusted_proxy']`` (and therefore
56
+ ``Otto::Request#secure?`` honoring ``X-Forwarded-Proto`` / ``X-Scheme``) remains
57
+ the trusted-proxy *identity* check and is never derived from hop depth,
58
+ matching the downstream OneTimeSecret behavior. (onetimesecret#3436,
59
+ onetimesecret#3116)
60
+
61
+ Changed
62
+ -------
63
+
64
+ - ``Otto::Security::Config#trusted_proxy?`` now matches string entries with
65
+ proper ``IPAddr`` CIDR containment for both IPv4 and IPv6, replacing the
66
+ previous ``==`` / ``start_with?`` text matching. Bare hosts (e.g.
67
+ ``192.168.1.1``) match only exactly, and CIDR ranges (e.g. ``10.0.0.0/8``)
68
+ now actually match contained addresses. Non-IP entries (e.g. ``172.16.``)
69
+ still fall back to the legacy prefix match, and ``Regexp`` entries are
70
+ unchanged. This is a behavior change: addresses that were previously
71
+ matched only because they shared a textual prefix are no longer treated as
72
+ trusted. (otto#58, onetimesecret#3436)
73
+ - ``IPPrivacyMiddleware`` now resolves the client IP once into a canonical
74
+ ``env['otto.client_ip']`` ("resolve once, read everywhere") and is
75
+ idempotent: a second pass (e.g. when the middleware is mounted both at the
76
+ app and router levels) yields instead of re-resolving and double-masking.
77
+ - ``Otto::Request#ip`` and ``#client_ipaddress`` now prefer
78
+ ``env['otto.client_ip']`` when present, falling back to Rack's native
79
+ resolution when the middleware has not run. Downstream code no longer
80
+ depends on ``REMOTE_ADDR`` / ``X-Forwarded-For`` rewriting being
81
+ load-bearing.
82
+ - ``Otto::Request#secure?`` now authorizes ``X-Forwarded-Proto`` /
83
+ ``X-Scheme`` from a canonical, leak-free ``env['otto.via_trusted_proxy']``
84
+ flag recorded by ``IPPrivacyMiddleware`` before masking, instead of
85
+ re-deriving trust from the (now masked) ``REMOTE_ADDR``. It falls back to
86
+ the previous behavior when the middleware has not run.
87
+ - ``add_trusted_proxy`` now logs a warning when given a string that is not a
88
+ valid IP or CIDR (e.g. ``'172.16.'``), since such entries use legacy
89
+ string-prefix matching; prefer a CIDR range.
90
+ - IP validation and port-stripping were consolidated into
91
+ ``Otto::Utils.normalize_ip`` / ``strip_ip_port`` (previously duplicated in
92
+ ``IPPrivacyMiddleware`` and ``Otto::Request``).
93
+ - Trusted-proxy string entries are now parsed to ``IPAddr`` once at
94
+ registration (in ``add_trusted_proxy``) and cached, so ``trusted_proxy?``
95
+ no longer re-parses each entry on every request.
96
+ - Client-IP resolution from forwarded headers is now a single shared
97
+ ``Otto::Utils.resolve_client_ip`` used by both ``IPPrivacyMiddleware``
98
+ ("resolve once") and ``Otto::Request#client_ipaddress`` (its no-middleware
99
+ fallback), so the two paths can no longer disagree on which headers to trust
100
+ or how to walk a proxy chain. The standalone ``Request`` fallback now walks
101
+ the forwarded chain skipping trusted proxies (matching the middleware) and
102
+ consults ``X-Client-IP`` instead of the legacy ``Client-IP`` header.
103
+
104
+ - ``RouteHandlers::BaseHandler`` raises ``ArgumentError`` (was ``NameError``)
105
+ for an unresolvable handler class name. (otto#147)
106
+ - ``Otto.logger`` never returns ``nil`` (lazy ``$stdout`` default); assign
107
+ ``Otto.logger=`` to override or silence. (otto#147)
108
+
109
+ Removed
110
+ -------
111
+
112
+ - SQL-injection pattern matching from input validation
113
+ (``ValidationMiddleware::SQL_INJECTION_PATTERNS`` and related checks). It
114
+ produced false positives and was trivially bypassable; defend against SQL
115
+ injection with parameterized queries at the data-access layer. (otto#147)
116
+
117
+ Fixed
118
+ -----
119
+
120
+ - IPv6 addresses are no longer truncated during proxy resolution.
121
+ ``validate_ip_address`` previously did ``ip.split(':').first``, collapsing
122
+ an IPv6 address to its first hextet; it now uses ``IPAddr`` validation with
123
+ IPv6-safe port stripping (bracketed ``[2001:db8::1]:443`` and IPv4
124
+ ``host:port``). IPv6 clients behind trusted proxies now resolve and mask
125
+ correctly. (onetimesecret#3436)
126
+ - ``Otto::Request#redacted_fingerprint``, ``#geo_country``, ``#hashed_ip``
127
+ and ``#masked_ip`` (plus ``NoAuthStrategy`` metadata and
128
+ ``LoggingHelpers`` country) read the canonical ``otto.privacy.*`` env keys
129
+ the middleware actually writes; they previously read un-namespaced keys
130
+ that were never set and so always returned ``nil``.
131
+ - ``Otto::Request#private_ip?`` (and therefore ``#local_or_private_ip?`` /
132
+ ``#local?``) is now IPv4- **and** IPv6-aware via ``Otto::Utils.private_ip?``.
133
+ It recognizes IPv6 loopback (``::1``), unique-local (``fc00::/7``),
134
+ link-local (``fe80::/10``), multicast and unspecified addresses; the previous
135
+ IPv4-only regex silently classified every IPv6 address as public.
136
+ - Anonymous and auth-failure metadata (``NoAuthStrategy``,
137
+ ``RouteAuthWrapper``) and ``LoggingHelpers.request_context`` now record the
138
+ canonical ``otto.client_ip`` (falling back to ``REMOTE_ADDR``), so the real
139
+ client — not the connecting proxy — is logged when IP privacy is disabled
140
+ behind a trusted proxy.
141
+
142
+ - The CSRF ``<meta>`` tag is now injected into ``<head>`` tags that carry
143
+ attributes, not only a bare ``<head>``. (otto#147)
144
+
145
+ Security
146
+ --------
147
+
148
+ - Trusted-proxy matching is now correct CIDR containment rather than text
149
+ prefix matching, removing both false positives (e.g. ``192.168.1.100``
150
+ matching the host ``192.168.1.1``) and false negatives (CIDR ranges that
151
+ never matched). ``secure?`` no longer silently fails to trust
152
+ ``X-Forwarded-Proto`` behind a TLS-terminating trusted proxy when IP
153
+ privacy is enabled. (onetimesecret#3436)
154
+
155
+ - CSRF tokens are now signed with HMAC-SHA256 keyed by a server-side secret and
156
+ bound to the session id, so they can no longer be self-minted or replayed
157
+ across sessions. Set the secret via ``OTTO_CSRF_SECRET`` or
158
+ ``Otto::Security::Config#csrf_secret=``; enabling CSRF in production without
159
+ one now raises instead of silently using a per-process secret. (otto#147)
160
+ - All route/handler class-name resolution goes through
161
+ ``Otto::Security::ConstantResolver``, extending the existing format check and
162
+ forbidden-class blocklist to ``RouteHandlers::BaseHandler`` and the MCP
163
+ registry/server (previously unguarded). Forbidden classes reached via a
164
+ namespace prefix or constant inheritance (e.g. ``Object::Kernel``) are now
165
+ rejected as well. (otto#147)
166
+ - MCP bearer tokens and API keys are compared in constant time. (otto#147)
167
+
168
+ - Depth resolution trusts exactly N hops counted from the right of
169
+ ``X-Forwarded-For`` plus ``REMOTE_ADDR``, so a forged leftmost forwarded entry
170
+ is never reached. Positions are counted raw (never dropped) so junk padding
171
+ cannot shift the index, and only the selected entry is validated. A chain
172
+ shorter than ``N + 1`` (a request that may have bypassed the proxy tier) or an
173
+ invalid target entry falls back to ``REMOTE_ADDR`` rather than a spoofable
174
+ forwarded value. Depth mode is XFF-only (single-value ``X-Real-IP`` /
175
+ ``X-Client-IP`` cannot express a hop chain) and **assumes origin lockdown** —
176
+ the app must be unreachable except through the proxy tier. CIDR-walk and depth
177
+ are mutually exclusive, and ``trusted_proxy_depth`` must be a non-negative
178
+ Integer or ``nil``; both are validated immediately at configuration time (with
179
+ a freeze-time backstop), so an invalid or contradictory setup fails fast.
180
+
181
+ Documentation
182
+ -------------
183
+
184
+ - Extended ``docs/migrating/v2.3.0.md`` with a count-based depth section covering
185
+ when to use depth vs CIDR-walk, the origin-lockdown prerequisite, configuration
186
+ examples, Express parity, and the XFF-only / short-chain / mutual-exclusivity
187
+ semantics.
188
+
189
+ AI Assistance
190
+ -------------
191
+
192
+ - Issue #147 findings triaged, fixed, and verified with AI assistance, including
193
+ an adversarial review that surfaced the namespace-prefix blocklist bypass.
194
+
195
+ - Trusted-proxy depth design review, threat-model analysis (origin-lockdown
196
+ trade vs CIDR enumerability, raw-position counting to defeat XFF padding),
197
+ implementation and test coverage developed with AI pair programming.
198
+
10
199
  .. _changelog-2.2.0:
11
200
 
12
201
  2.2.0 — 2026-06-09
data/Gemfile CHANGED
@@ -27,6 +27,7 @@ group :development do
27
27
  gem 'benchmark'
28
28
  gem 'debug'
29
29
  gem 'rackup' # Used to boot examples/ apps; not needed by specs
30
+ gem 'rake', '~> 13.0', require: false # Provides `rake release` for release-gem.yml
30
31
  gem 'rubocop', '~> 1.86.2', require: false
31
32
  gem 'rubocop-performance', require: false
32
33
  gem 'rubocop-rspec', require: false
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.1)
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)
@@ -115,6 +113,7 @@ GEM
115
113
  rackup (2.3.1)
116
114
  rack (>= 3)
117
115
  rainbow (3.1.1)
116
+ rake (13.1.0)
118
117
  rbs (4.0.2)
119
118
  logger
120
119
  prism (>= 1.6.0)
@@ -218,6 +217,7 @@ DEPENDENCIES
218
217
  rack-attack
219
218
  rack-test
220
219
  rackup
220
+ rake (~> 13.0)
221
221
  reek (~> 6.5)
222
222
  rspec (~> 3.13)
223
223
  rubocop (~> 1.86.2)
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # Rakefile
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # `bundler/gem_tasks` defines the build/install/release tasks the
6
+ # release-gem.yml workflow drives via `bundle exec rake release`
7
+ # (RubyGems Trusted Publishing). `rake release` builds the gem, pushes the
8
+ # git tag (a no-op when the release tag already exists), and publishes to
9
+ # RubyGems.
10
+ require 'bundler/gem_tasks'
11
+
12
+ # Make `rake` (no task) run the specs, mirroring CI's `bundle exec rspec`.
13
+ # Guarded so `rake release` still works in an install without the test
14
+ # group (rspec lives in the :test group).
15
+ begin
16
+ require 'rspec/core/rake_task'
17
+ RSpec::Core::RakeTask.new(:spec)
18
+ task default: :spec
19
+ rescue LoadError
20
+ # rspec unavailable (e.g. a production/release-only bundle); skip the task.
21
+ end
@@ -0,0 +1,288 @@
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
+ **Single-value headers are never consulted.** `X-Real-IP` and `X-Client-IP`
185
+ cannot express a hop chain, so depth mode ignores them (CIDR-walk still consults
186
+ them). Which *multi-hop* header depth counts from — `X-Forwarded-For` (default),
187
+ the RFC 7239 `Forwarded` header, or `Both` — is configurable as of 2.3.1; see
188
+ *Selecting the forwarded header* below.
189
+
190
+ **`secure?` is independent of depth.** Depth mode resolves the client **IP**
191
+ only; it does **not** grant proxy trust for `X-Forwarded-Proto` / `X-Scheme`.
192
+ `env['otto.via_trusted_proxy']` — which `Otto::Request#secure?` consults to honor
193
+ a forwarded proto — is derived solely from the trusted-proxy *identity* check
194
+ (does `REMOTE_ADDR` match a configured `trusted_proxies` CIDR?), never from hop
195
+ depth. Because depth mode and `trusted_proxies` are mutually exclusive, that
196
+ check is `false` under depth, so `secure?` does not honor a forwarded proto and
197
+ reflects only a direct TLS connection (`HTTPS=on` / port 443). This mirrors the
198
+ downstream (OneTimeSecret) behavior: proto-trust is never derived from depth.
199
+
200
+ ### Selecting the forwarded header (added in 2.3.1)
201
+
202
+ By default depth counts hops from `X-Forwarded-For`. Deployments fronted by a
203
+ proxy that emits the RFC 7239 `Forwarded` header can change the source via
204
+ `trusted_proxy_header`, mirroring OneTimeSecret's
205
+ `site.network.trusted_proxy.header`:
206
+
207
+ ```ruby
208
+ otto.security_config.trusted_proxy_header = 'Forwarded' # RFC 7239 only
209
+
210
+ # or via the facade / construction options, alongside the depth:
211
+ otto.security.configure(trusted_proxy_depth: 1, trusted_proxy_header: 'Both')
212
+ Otto.new(routes, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
213
+ ```
214
+
215
+ | Value | Chain source |
216
+ |-------|--------------|
217
+ | `'X-Forwarded-For'` (default) | `X-Forwarded-For` |
218
+ | `'Forwarded'` | RFC 7239 `Forwarded` — the `for=` of each forwarded-element |
219
+ | `'Both'` | `Forwarded` when it carries a `for=`, otherwise `X-Forwarded-For` |
220
+
221
+ - **`Both` precedence is fallback, not merge.** If the `Forwarded` header carries
222
+ at least one `for=`, only its chain is used and `X-Forwarded-For` is ignored;
223
+ otherwise the `X-Forwarded-For` chain is used. The two chains are never
224
+ concatenated. (Matches OTS's
225
+ `extract_rfc7239_forwarded(env) || extract_x_forwarded_for(env)`.)
226
+ - **RFC 7239 parsing.** Each comma-separated forwarded-element is one hop; its
227
+ `for=` parameter is read case-insensitively and unquoted. Quoted IPv6 with a
228
+ port (`for="[2001:db8::1]:443"`) resolves to `2001:db8::1`. Obfuscated
229
+ (`for=_hidden`) and `for=unknown` identifiers still occupy a hop position but
230
+ are not valid IPs, so if one is the selected hop the resolver falls back to
231
+ `REMOTE_ADDR`. Multiple `Forwarded` headers (joined by Rack into one
232
+ comma-separated value) are handled.
233
+ - **Raw position counting is preserved** in all three modes: elements are never
234
+ dropped before counting (an element without a `for=` still counts as a hop),
235
+ so padding cannot shift the index; only the selected hop is validated.
236
+ - **Depth mode only.** `trusted_proxy_header` is consulted solely in depth mode;
237
+ CIDR-walk is unaffected.
238
+ - **Lenient spelling, strict validation.** The value is matched
239
+ case-insensitively with surrounding whitespace ignored (`forwarded`, `both`,
240
+ ` X-Forwarded-For ` all work) and stored canonicalized. A genuinely
241
+ unrecognized value raises `ArgumentError` at assignment rather than silently
242
+ falling back to a default header — a typo surfaces at config time instead of
243
+ as subtly-wrong client IPs at request time.
244
+
245
+ ### Security prerequisite: origin lockdown
246
+
247
+ > **Depth trust assumes your app is unreachable except through the proxy tier.**
248
+
249
+ This is the inherent trade-off versus CIDR-walk: depth relies on a fixed network
250
+ **topology** instead of enumerable proxy **addresses**. If a client can reach
251
+ your app directly (origin not locked down), it can pad `X-Forwarded-For` so that
252
+ a forged value lands at `chain[-(N+1)]`, spoofing the resolved client IP. (Proto
253
+ trust is unaffected — depth never feeds `secure?` — but the resolved IP is only
254
+ as trustworthy as the lockdown.)
255
+
256
+ Before enabling depth, ensure the origin only accepts connections from the proxy
257
+ tier (private networking, security groups, an authenticating header the proxy
258
+ injects, etc.). If you can enumerate your proxies instead, prefer CIDR-walk.
259
+
260
+ ### Migrating from OneTimeSecret depth (`ConfigureTrustedProxy`)
261
+
262
+ If you are collapsing OneTimeSecret's `ClientIpHelpers` / `ConfigureTrustedProxy`
263
+ depth logic onto this resolver, note two intentional differences:
264
+
265
+ - **Off-by-one (Otto counts the peer).** Otto's chain is `X-Forwarded-For`
266
+ **plus** `REMOTE_ADDR`, so it is one hop longer than OTS's XFF-only chain. To
267
+ resolve the same client, Otto's depth must be **one higher** than the
268
+ operator's OTS `depth:`. When the YAML→Otto translator is built, map
269
+ **`trusted_proxy_depth = ots_depth + 1`** so existing `depth:` values keep
270
+ their meaning. Keep a parity regression test on the OTS side to lock this
271
+ mapping.
272
+
273
+ - **Stricter short-chain behavior (kept on purpose).** When the chain is shorter
274
+ than `N + 1`, Otto returns `REMOTE_ADDR` (the peer), whereas OTS returned the
275
+ leftmost — spoofable — `X-Forwarded-For` entry. Otto's behavior is the safer
276
+ one and is **not** reconciled down to OTS; expect a short-chain request to
277
+ resolve to the proxy peer rather than a forwarded value, and document this when
278
+ migrating.
279
+
280
+ ## No action required for
281
+
282
+ - Apps that pass `trusted_proxies` as CIDR ranges or `Regexp`.
283
+ - Apps that read `req.ip` / `req.client_ipaddress` (now backed by the canonical
284
+ value, same masked result).
285
+ - Apps relying on IPv6 — IPv6 client resolution behind trusted proxies is now
286
+ correct (it was previously truncated to the first hextet).
287
+ - Apps that do not set `trusted_proxy_depth` (default `nil` → unchanged
288
+ CIDR-walk behavior; depth mode is entirely opt-in).