otto 2.7.0 → 2.8.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: d14c212b610b94a8a21abdae0c1d691e342c849e6160dc03615c8c00e30ddca8
4
- data.tar.gz: 0a22e90e372914211b0828eb4c0048edb3171c011407778af73d0be098a79a68
3
+ metadata.gz: 2d4e92c540f41f9cad40b495f8a7106f49a11d8eb9b5011981c97bf585026065
4
+ data.tar.gz: 10c592de40e1b0b35f57379b5dfca20a51b76967898e0cccce33d903fbfcb75d
5
5
  SHA512:
6
- metadata.gz: f1c55db0ffbe490ad295c6d73b1e6012f1e12702dd33f971f3e7a2a42f9b269cceaf0deccdb5e9fb958f56dfe338bbbb71561ea01ec8b2c7e18d71e507ddb065
7
- data.tar.gz: 78f3e09cd71f33caceab30691d562a65552c765f4b1690b4b1ad36776cf89600839bf1fb37ab2a7d4335fd58e2d75ad403f6129f7e879dc12581ded07f1aa3ac
6
+ metadata.gz: 21268aaf0ec8b815f8981691ef76fe9a41347eeb184b8bb04b7403d5c1aefda9339acd97bee280e53845f3acf17a17cd68175cb272153acfd176ff16183cb0c6
7
+ data.tar.gz: 0d30534c168a390f3dec1cf8e35388e0cec48c7285a0c10e1358af71b03032a52043cb0fbad09d9578ccea34c8ee27ac49eb90eefc09d4b5cbe740f530a6b5f2
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,42 @@ 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.8.0:
11
+
12
+ 2.8.0 — 2026-08-07
13
+ ==================
14
+
15
+ Changed
16
+ -------
17
+
18
+ - ``env['otto.via_trusted_proxy']`` is now tri-state: written only when proxy
19
+ trust is configured (CIDR matchers or ``trusted_proxy_depth``). A present
20
+ key is authoritative in both directions; an absent key means
21
+ "unconfigured". Consumers reading the raw env key should presence-check
22
+ (``env.key?``) rather than compare ``== true``. (#228)
23
+
24
+ - Configuring an ip-privacy ``geo_header`` together with
25
+ ``trusted_proxy_depth`` now raises ``ArgumentError`` at configuration time:
26
+ geo headers are only honored for CIDR-verified proxies, so under depth mode
27
+ the header could never be consulted. Database-backed geo (``geo_db_path`` /
28
+ ``geo_db_reader``) remains fully supported under depth. (#228)
29
+
30
+ Fixed
31
+ -----
32
+
33
+ - Depth mode now records a peer-trust verdict in
34
+ ``env['otto.via_trusted_proxy']`` (previously always ``false``, leaving
35
+ downstream middleware with no trust signal);
36
+ ``Otto::Request#forwarded_by_trusted_proxy?`` mirrors the grant on its
37
+ no-middleware fallback path. (#226)
38
+
39
+ Documentation
40
+ -------------
41
+
42
+ - Corrected the v2.3.0 migration guide's depth-porting guidance: map depth
43
+ values directly, not ``+1`` — otto's chain index already accounts for the
44
+ appended ``REMOTE_ADDR``. (#227, #228)
45
+
10
46
  .. _changelog-2.7.0:
11
47
 
12
48
  2.7.0 — 2026-08-03
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.7.0)
4
+ otto (2.8.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -59,7 +59,7 @@ GEM
59
59
  prism (>= 1.3.0)
60
60
  rdoc (>= 4.0.0)
61
61
  reline (>= 0.4.2)
62
- json (2.20.0)
62
+ json (2.21.2)
63
63
  json_schemer (2.5.0)
64
64
  bigdecimal
65
65
  hana (~> 1.3)
data/docs/geo-country.md CHANGED
@@ -152,7 +152,15 @@ Origins Otto cannot verify are **not** trusted:
152
152
  no `trusted_proxies` configured, header steps are skipped and resolution falls
153
153
  to the resolver / database (`'**'` if neither is set).
154
154
  - **Count-based `trusted_proxy_depth` mode.** The header-setting hop cannot be
155
- verified as a geo-CDN, so depth mode does not enable header trust.
155
+ verified as a geo-CDN, so depth mode does not enable header trust. This
156
+ conflict fails loud: configuring a `geo_header` together with a
157
+ `trusted_proxy_depth` raises `ArgumentError` at configuration time (in
158
+ either order) instead of silently ignoring the header per-request.
159
+ Database-backed geo remains fully supported under depth. The built-in
160
+ provider headers stay legal (there is nothing to configure, so nothing
161
+ can raise) but are equally inert — header trust requires CIDR-verified
162
+ proxies — so only an explicitly configured `geo_header` is rejected, and
163
+ depth deployments that want geo should set `geo_db_path`.
156
164
 
157
165
  **Migration:** to keep header-based geo, configure `trusted_proxies` (CIDR
158
166
  matchers) so Otto can verify the proxy origin. Depth-mode and header-only
@@ -55,8 +55,16 @@ return `false` behind a TLS-terminating trusted proxy.
55
55
 
56
56
  The middleware now records the peer-trust decision once (before masking) in a
57
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.
58
+ Since the first release after 2.7.0 the key is **tri-state**: it is written
59
+ only when proxy trust is actually configured (CIDR matchers or a depth), so a
60
+ present key is authoritative in both directions and an *absent* key means "no
61
+ proxy trust configured". When the key is absent (standalone request use, or an
62
+ unconfigured deployment), `secure?` falls back to the same decision the
63
+ middleware would have implied: a CIDR check of the connecting peer — or, since
64
+ the depth-mode peer-trust fix
65
+ ([#226](https://github.com/delano/otto/issues/226), first release after
66
+ 2.7.0), an unconditional grant when `trusted_proxy_depth` is configured. No
67
+ app changes are required.
60
68
 
61
69
  ### 3. Privacy helpers now return values (previously `nil`)
62
70
 
@@ -100,7 +108,7 @@ use `Otto::Request` standalone without the Otto middleware stack.
100
108
  | Key | Type | Meaning |
101
109
  |-----|------|---------|
102
110
  | `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?`. |
111
+ | `otto.via_trusted_proxy` | Boolean (tri-state: may be absent) | Peer trust decided before masking, written **only when proxy trust is configured** (first release after 2.7.0): `true` on a CIDR match — or unconditionally when depth mode is configured ([#226](https://github.com/delano/otto/issues/226)); `false` means trust is configured and the peer failed it (authoritative deny). Absent = no proxy trust configured — the only case for consumer-side fallback heuristics. Read by `Request#secure?`. |
104
112
 
105
113
  The privacy data keys remain `otto.privacy.{fingerprint,masked_ip,hashed_ip,geo_country}`.
106
114
 
@@ -187,15 +195,26 @@ them). Which *multi-hop* header depth counts from — `X-Forwarded-For` (default
187
195
  the RFC 7239 `Forwarded` header, or `Both` — is configurable as of 2.3.1; see
188
196
  *Selecting the forwarded header* below.
189
197
 
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.
198
+ **Depth grants peer trust ([#226](https://github.com/delano/otto/issues/226),
199
+ first release after 2.7.0).** Configuring a depth asserts that the connecting
200
+ peer *is* your proxy tier that is what the setting means — so depth mode
201
+ records `env['otto.via_trusted_proxy'] = true` on every request, and
202
+ `Otto::Request#secure?` (which consults that flag) honors a forwarded
203
+ `X-Forwarded-Proto` / `X-Scheme` in depth mode. Releases up to and including
204
+ 2.7.0 behaved differently: the flag was derived solely from the
205
+ `trusted_proxies` CIDR identity check, which is always empty under depth
206
+ (the modes are mutually exclusive), so it was recorded `false` and `secure?`
207
+ reflected only a direct TLS connection (`HTTPS=on` / port 443). Because the
208
+ grant is unconditional, the *origin lockdown* prerequisite below now covers
209
+ proto trust exactly as it covers IP resolution. Geo headers are unaffected:
210
+ they remain gated on enumerated `trusted_proxies` matchers and are still
211
+ **not** trusted in depth mode (a hop trusted by count cannot be verified as a
212
+ geo-setting CDN). As of the first release after 2.7.0 this carve-out fails
213
+ loud instead of silently: configuring an ip-privacy `geo_header` together
214
+ with a `trusted_proxy_depth` raises `ArgumentError` at configuration time
215
+ (in either order, with a freeze-time backstop) — use filter mode for
216
+ header-based geo, or a geo database (`geo_db_path`) under depth. Database-
217
+ backed geo with depth remains fully supported.
199
218
 
200
219
  ### Selecting the forwarded header (added in 2.3.1)
201
220
 
@@ -249,9 +268,10 @@ Otto.new(routes, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
249
268
  This is the inherent trade-off versus CIDR-walk: depth relies on a fixed network
250
269
  **topology** instead of enumerable proxy **addresses**. If a client can reach
251
270
  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.)
271
+ a forged value lands at `chain[-(N+1)]`, spoofing the resolved client IP. (Since
272
+ [#226](https://github.com/delano/otto/issues/226) this applies to proto trust
273
+ too: depth grants `otto.via_trusted_proxy`, so a directly-reachable origin
274
+ could spoof the scheme via `X-Forwarded-Proto` as well as the client IP.)
255
275
 
256
276
  Before enabling depth, ensure the origin only accepts connections from the proxy
257
277
  tier (private networking, security groups, an authenticating header the proxy
@@ -262,13 +282,26 @@ injects, etc.). If you can enumerate your proxies instead, prefer CIDR-walk.
262
282
  If you are collapsing OneTimeSecret's `ClientIpHelpers` / `ConfigureTrustedProxy`
263
283
  depth logic onto this resolver, note two intentional differences:
264
284
 
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.
285
+ - **Map depth values directly — do NOT add one.** Otto's chain is
286
+ `X-Forwarded-For` **plus** `REMOTE_ADDR`, and the client is selected at
287
+ `chain[-(N+1)]` the appended peer is already accounted for by the index
288
+ arithmetic. `trusted_proxy_depth = N` therefore means "N proxy hops,
289
+ counting the connecting peer as hop 1", which is exactly what the
290
+ operator-facing OTS `depth: N` documents ("1 = standard single reverse
291
+ proxy"). Map **`trusted_proxy_depth = ots_depth`**, and keep a parity
292
+ regression test on the OTS side — including a padded-chain case asserting a
293
+ forged leftmost `X-Forwarded-For` entry is never selected.
294
+
295
+ > **Correction.** Earlier revisions of this guide recommended
296
+ > `trusted_proxy_depth = ots_depth + 1` to "keep existing `depth:` values'
297
+ > meaning". That reproduced an internal off-by-one of the deleted OTS
298
+ > walker (whose indexed path selected `XFF[-(depth+1)]`, one position left
299
+ > of its own documented contract), not the operator-facing meaning. Under
300
+ > the `+1` remap every honest documented-topology request hit the
301
+ > short-chain fallback and resolved the **proxy** address as the client,
302
+ > and a single forged leftmost `X-Forwarded-For` entry re-lengthened the
303
+ > chain so the forged value was selected. The direct mapping resolves the
304
+ > true client on honest chains and is padding-resistant.
272
305
 
273
306
  - **Stricter short-chain behavior (kept on purpose).** When the chain is shorter
274
307
  than `N + 1`, Otto returns `REMOTE_ADDR` (the peer), whereas OTS returned the
data/lib/otto/env_keys.rb CHANGED
@@ -5,10 +5,13 @@
5
5
  # Central registry of all env['otto.*'] keys used throughout Otto framework.
6
6
  # This documentation helps prevent key conflicts and aids multi-app integration.
7
7
  #
8
- # DOCUMENTATION-ONLY MODULE: The constants defined here are intentionally NOT used
9
- # in the codebase. Otto uses string literals (e.g., env['otto.strategy_result'])
10
- # for readibility/simplicity. This module exists as reference documentation but
11
- # may be considered for future use if needed.
8
+ # Otto's own code writes the string literals directly (e.g.
9
+ # env['otto.strategy_result']) for readability/simplicity, so this file is
10
+ # not loaded by `require 'otto'` consumers must `require 'otto/env_keys'`
11
+ # explicitly. The constants are nevertheless PUBLIC API: downstream
12
+ # applications reference them at runtime to pin the cross-gem env contract
13
+ # (OneTimeSecret's Rack::DetectHost reads VIA_TRUSTED_PROXY). Renaming or
14
+ # removing a constant — or this file — is a breaking change.
12
15
  #
13
16
  class Otto
14
17
  # Rack environment keys used by Otto framework
@@ -71,15 +74,26 @@ class Otto
71
74
  # (e.g. 'onetime.nonce'), so the header and views still share one value.
72
75
  NONCE = 'otto.nonce'
73
76
 
74
- # Whether the request arrived via a trusted proxy.
75
- # Type: Boolean
76
- # Set by: IPPrivacyMiddleware (every request, evaluated on the original
77
- # peer BEFORE REMOTE_ADDR is masked). This is the trusted-proxy identity
78
- # check (does REMOTE_ADDR match a configured trusted_proxies CIDR?) — it is
79
- # independent of count-based depth mode, which resolves the client IP but
80
- # never grants proxy trust for forwarded proto.
77
+ # Whether the request arrived via a trusted proxy. TRI-STATE.
78
+ # Type: Boolean when present; the key may be ABSENT.
79
+ # Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
80
+ # REMOTE_ADDR is masked but ONLY when proxy trust is configured
81
+ # (Security::Config#proxy_trust_configured?: CIDR matchers or a depth).
82
+ # True when the peer matches a configured trusted_proxies CIDR (filter
83
+ # mode), or unconditionally when count-based depth mode is active
84
+ # (trusted_proxy_depth >= 1) — the modes are mutually exclusive, and
85
+ # configuring a depth is the operator's assertion that the connecting
86
+ # peer is their proxy tier (#226). False means trust IS configured and
87
+ # this peer failed it — an authoritative deny. When no proxy trust is
88
+ # configured the key is NOT written, so consumers can distinguish
89
+ # "denied" from "unconfigured" and apply legacy heuristics only in the
90
+ # latter case.
81
91
  # Used by: Otto::Request#secure? to authorize X-Forwarded-Proto / X-Scheme
82
- # without depending on the (masked) REMOTE_ADDR
92
+ # without depending on the (masked) REMOTE_ADDR, and by downstream
93
+ # middleware (e.g. forwarded-host handling) as the peer-trust signal now
94
+ # that REMOTE_ADDR no longer identifies the connecting peer. Consumers
95
+ # should treat a PRESENT key as authoritative in both directions and
96
+ # reserve fallback heuristics for the absent case.
83
97
  VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
84
98
 
85
99
  # Whether the connecting peer was the loopback interface.
@@ -106,6 +106,17 @@ class Otto
106
106
  # rubocop:enable Metrics/ParameterLists
107
107
  ensure_not_frozen!
108
108
  config = @security_config.ip_privacy_config
109
+
110
+ # Geo headers are honored only for peers matching enumerated CIDR
111
+ # matchers, never for count-trusted hops, so a geo_header configured
112
+ # alongside depth mode could never be consulted. Fail loud here
113
+ # (depth-then-geo order; the trusted_proxy_depth= setter catches
114
+ # geo-then-depth). A blank geo_header canonicalizes to nil ("clear"),
115
+ # which stays legal under depth.
116
+ if Otto::Privacy::Config.canonicalize_geo_header(geo_header) &&
117
+ @security_config.trusted_proxy_depth_mode?
118
+ raise ArgumentError, Otto::Security::Config::GEO_HEADER_DEPTH_CONFLICT_MESSAGE
119
+ end
109
120
  knobs = { profile: profile, octet_precision: octet_precision,
110
121
  hash_rotation: hash_rotation, geo: geo,
111
122
  correlation_secret: correlation_secret, redis: redis }
data/lib/otto/request.rb CHANGED
@@ -241,19 +241,28 @@ class Otto
241
241
  #
242
242
  # Prefers the canonical decision recorded once by IPPrivacyMiddleware in
243
243
  # env['otto.via_trusted_proxy'] — evaluated against the original peer before
244
- # REMOTE_ADDR is masked, so it stays correct even after masking. Falls back
245
- # to evaluating the current REMOTE_ADDR when the middleware has not run
246
- # (standalone request use).
247
- #
248
- # This is the trusted-proxy *identity* check only and is independent of
249
- # count-based depth mode: depth resolves the client IP but never grants
250
- # proxy trust for X-Forwarded-Proto.
244
+ # REMOTE_ADDR is masked, so it stays correct even after masking. The key is
245
+ # tri-state: written only when proxy trust is configured, so its absence
246
+ # covers both "middleware not mounted" (standalone request use) and "no
247
+ # proxy trust configured" — the fallback below answers both by evaluating
248
+ # the config directly against the current REMOTE_ADDR (an unconfigured
249
+ # config yields false, matching what the middleware would have implied).
250
+ #
251
+ # A peer earns trust two ways (#226): its identity matches a configured
252
+ # trusted-proxy CIDR (filter mode), or count-based depth mode is active —
253
+ # configuring a depth asserts the connecting peer is the operator's
254
+ # (non-enumerable) proxy tier. The fallback mirrors the grant
255
+ # IPPrivacyMiddleware records so the two paths cannot disagree.
251
256
  #
252
257
  # @return [Boolean]
253
258
  def forwarded_by_trusted_proxy?
254
259
  return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')
255
260
 
256
- otto_security_config ? trusted_proxy?(env['REMOTE_ADDR']) : false
261
+ config = otto_security_config
262
+ return false unless config
263
+ return true if config.trusted_proxy_depth_mode?
264
+
265
+ trusted_proxy?(env['REMOTE_ADDR'])
257
266
  end
258
267
 
259
268
  # See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post
@@ -38,6 +38,23 @@ class Otto
38
38
  hop count, not both.
39
39
  MSG
40
40
 
41
+ # Error raised when an app-configured trusted geo header (ip_privacy
42
+ # geo_header) is combined with count-based depth mode. Geo headers are
43
+ # honored only for peers matching enumerated trusted_proxies CIDRs
44
+ # (geo_headers_trusted? gates on trusted_proxies_configured?) — a hop
45
+ # trusted by count cannot be verified as the geo-setting CDN — so a
46
+ # geo_header configured alongside a depth could never be consulted.
47
+ # Failing loud at config time replaces a silent database/'**' fallback
48
+ # at request time.
49
+ GEO_HEADER_DEPTH_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
50
+ Cannot configure a trusted geo header (ip_privacy geo_header) together
51
+ with trusted_proxy_depth (count mode): geo headers are only honored
52
+ for peers matching enumerated trusted_proxies CIDRs, so the header
53
+ would be silently ignored. Use filter mode (add_trusted_proxy) for
54
+ header-based geo, or drop geo_header and use database-backed geo
55
+ (geo_db_path or geo_db_reader).
56
+ MSG
57
+
41
58
  # Forwarded-header sources depth mode (#trusted_proxy_depth) can count
42
59
  # hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
43
60
  # Both (Forwarded when present, else X-Forwarded-For). Mirrors
@@ -219,18 +236,30 @@ class Otto
219
236
 
220
237
  # Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
221
238
  #
222
- # This mirrors {#trusted_proxy?}, which consults the same matcher list, so
223
- # the two answers stay consistent: a request can only be "via a trusted
224
- # proxy" when matchers exist. It deliberately EXCLUDES count-based depth
225
- # mode depth resolves the client IP but never confers proxy identity
226
- # trust (the same decoupling {Otto::Request#forwarded_by_trusted_proxy?}
227
- # applies to X-Forwarded-Proto). Used to gate geo-header trust.
239
+ # This mirrors {#trusted_proxy?}, which consults the same matcher list.
240
+ # It deliberately EXCLUDES count-based depth mode: depth grants the peer
241
+ # blanket trust for otto.via_trusted_proxy (#226), but it cannot verify
242
+ # that the hop is a geo-setting CDN, so header-based geo stays gated on
243
+ # enumerated matchers only. Used to gate geo-header trust.
228
244
  #
229
245
  # @return [Boolean] true when at least one trusted-proxy matcher exists
230
246
  def trusted_proxies_configured?
231
247
  @trusted_proxy_matchers.any?
232
248
  end
233
249
 
250
+ # Whether ANY proxy-trust mode is configured — CIDR matchers (filter
251
+ # mode) or count-based depth. This is the gate for writing
252
+ # env['otto.via_trusted_proxy'] at all: when neither mode is configured
253
+ # the key is left ABSENT (tri-state contract), so downstream consumers
254
+ # can distinguish "operator configured trust and this peer failed it"
255
+ # (false) from "no proxy trust configured" (absent) and apply their own
256
+ # legacy heuristics only in the latter case.
257
+ #
258
+ # @return [Boolean] true when filter or depth mode is configured
259
+ def proxy_trust_configured?
260
+ trusted_proxies_configured? || trusted_proxy_depth_mode?
261
+ end
262
+
234
263
  # Whether count-based ("trust the last N hops") proxy resolution is active.
235
264
  #
236
265
  # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
@@ -260,6 +289,9 @@ class Otto
260
289
 
261
290
  validate_trusted_proxy_depth!(depth)
262
291
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
292
+ # Depth-then-geo assignment order is caught by configure_ip_privacy;
293
+ # this catches geo-then-depth so both orders fail eagerly.
294
+ raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
263
295
 
264
296
  @trusted_proxy_depth = depth
265
297
  end
@@ -763,6 +795,12 @@ class Otto
763
795
  return if @trusted_proxy_depth.nil?
764
796
 
765
797
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
798
+
799
+ # Backstop for the direct path (ip_privacy_config.geo_header=) that
800
+ # bypasses both eager checks; the setters cover the common orders.
801
+ return unless @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
802
+
803
+ raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE
766
804
  end
767
805
 
768
806
  # Parse a value into an IPAddr, returning nil for invalid / non-IP input.
@@ -21,7 +21,9 @@ class Otto
21
21
  # Because it now runs ahead of everything, facts about the ORIGINAL peer
22
22
  # that downstream code can no longer derive from the (masked) REMOTE_ADDR
23
23
  # are recorded first, as leak-free booleans — never as addresses:
24
- # env['otto.via_trusted_proxy'] and env['otto.peer_loopback'].
24
+ # env['otto.via_trusted_proxy'] (only when proxy trust is configured —
25
+ # absent otherwise, see the tri-state note in #call) and
26
+ # env['otto.peer_loopback'].
25
27
  #
26
28
  # @example Default behavior (privacy enabled)
27
29
  # # env['REMOTE_ADDR'] is masked to 192.168.1.0
@@ -62,11 +64,22 @@ class Otto
62
64
  # secure? can authorize X-Forwarded-Proto canonically even after
63
65
  # REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
64
66
  #
65
- # This is the trusted-proxy *identity* check only it is deliberately
66
- # independent of count-based depth mode. Depth resolves the client IP;
67
- # it never grants proxy trust for X-Forwarded-Proto (matching the
68
- # downstream OneTimeSecret behavior).
69
- env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
67
+ # TRI-STATE: the key is written ONLY when the operator configured
68
+ # proxy trust (CIDR matchers or a depth). Present, its value is
69
+ # authoritative in both directions true means the peer matched a
70
+ # CIDR (filter mode) or depth mode is active (configuring a depth
71
+ # asserts the connecting peer IS the operator's proxy tier, #226);
72
+ # false means trust IS configured and this peer failed it. Absent
73
+ # means no proxy trust is configured at all, so downstream consumers
74
+ # may fall back to their own heuristics without this key vetoing
75
+ # them. Writing false on unconfigured deployments made false
76
+ # ambiguous between "untrusted peer" and "nothing configured", which
77
+ # forced consumers into grant-only reads (#228).
78
+ # respond_to?: like geo_headers_trusted?, a partial/duck-typed
79
+ # config (or nil) that cannot report trust state is "unconfigured".
80
+ if @security_config.respond_to?(:proxy_trust_configured?) && @security_config.proxy_trust_configured?
81
+ env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
82
+ end
70
83
 
71
84
  # Same rationale, for loopback: this middleware runs outermost, so a
72
85
  # downstream middleware that must authenticate a DIRECT LOCAL CALL
@@ -420,12 +433,20 @@ class Otto
420
433
  Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
421
434
  end
422
435
 
423
- # Check if an IP is from a trusted proxy
436
+ # Check if the connecting peer counts as a trusted proxy
437
+ #
438
+ # CIDR filter mode checks the peer's identity against the configured
439
+ # matchers. Count-based depth mode has no enumerable matchers — the
440
+ # depth setting itself is the operator's assertion that the peer is
441
+ # their proxy tier — so an active depth grants peer trust outright
442
+ # (#226). Geo-header trust is unaffected: geo_headers_trusted? gates on
443
+ # trusted_proxies_configured?, which stays matcher-only.
424
444
  #
425
445
  # @param ip [String] IP address to check
426
446
  # @return [Boolean] true if IP is from a trusted proxy
427
447
  def trusted_proxy?(ip)
428
448
  return false unless @security_config
449
+ return true if @security_config.trusted_proxy_depth_mode?
429
450
 
430
451
  @security_config.trusted_proxy?(ip)
431
452
  end
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.7.0'
6
+ VERSION = '2.8.0'
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.0
4
+ version: 2.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-07 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: concurrent-ruby
@@ -329,6 +330,7 @@ licenses:
329
330
  - MIT
330
331
  metadata:
331
332
  rubygems_mfa_required: 'true'
333
+ post_install_message:
332
334
  rdoc_options: []
333
335
  require_paths:
334
336
  - lib
@@ -346,7 +348,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
346
348
  - !ruby/object:Gem::Version
347
349
  version: '0'
348
350
  requirements: []
349
- rubygems_version: 3.6.9
351
+ rubygems_version: 3.5.22
352
+ signing_key:
350
353
  specification_version: 4
351
354
  summary: Define your rack-apps in plaintext.
352
355
  test_files: []