otto 2.3.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: 50aa75316f4a3f73a0784cf78fb29e05c030901fa75ac1b8285a6ec0ad89fc3b
4
- data.tar.gz: 38c18d2dc357589e1377a5df0cecd0de26370b002061d9bde6d66994b4990610
3
+ metadata.gz: 72c7a8fdbc299b202666d5f94532e4eed6d3fc2b2ee6a58234519deba65bfb0c
4
+ data.tar.gz: d82daec0441d3e9a8c196e6306aa8945292b8a322e3acbdf146294bc55345782
5
5
  SHA512:
6
- metadata.gz: e5f9c0693f83c423a77b20ac88c6af74db6c3d4a9ea345304085b31249a1d20537d64d5c091c5ffb5f6382bfd3fe291d4bcc85f5949928ef09ab8ae74fe09c3b
7
- data.tar.gz: 29fb704139356b0e0df805567e761593b10faf519f7977087425f9752711dfe4315b41266d864c071779f1e37b55e1a9d40c454c74f5973f728f3f1e1ff1bb97
6
+ metadata.gz: 64e1787e35c416585076ba4befa922c4306f0f023cba463877a66b821ab9e1518e3afda4edd6e8a319a04750c3c29d5b79196fc4961f807438b1f27eed4d0687
7
+ data.tar.gz: e6384fc6c848c602ea4c7f2c7eee7aaab7a715df054e404d6660e9c72fb85b52f4ff0a98108121a4995fe401a09bc67bcb459d0e37d00f4897a42df6b90d2d10
@@ -51,7 +51,7 @@ jobs:
51
51
  - Security concerns
52
52
  - Test coverage
53
53
 
54
- 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.
55
55
 
56
56
  # Use sticky comments to reuse the same comment on subsequent pushes to the same PR
57
57
  use_sticky_comment: true
@@ -32,10 +32,15 @@ jobs:
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
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,31 @@ 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
+
10
35
  .. _changelog-2.3.0:
11
36
 
12
37
  2.3.0 — 2026-06-21
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,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.3.0)
4
+ otto (2.3.1)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -113,6 +113,7 @@ GEM
113
113
  rackup (2.3.1)
114
114
  rack (>= 3)
115
115
  rainbow (3.1.1)
116
+ rake (13.1.0)
116
117
  rbs (4.0.2)
117
118
  logger
118
119
  prism (>= 1.6.0)
@@ -216,6 +217,7 @@ DEPENDENCIES
216
217
  rack-attack
217
218
  rack-test
218
219
  rackup
220
+ rake (~> 13.0)
219
221
  reek (~> 6.5)
220
222
  rspec (~> 3.13)
221
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
@@ -181,9 +181,11 @@ entry is not a valid IP, the resolver returns `REMOTE_ADDR` rather than a
181
181
  spoofable forwarded value. This is intentionally **stricter than Express**, which
182
182
  returns the leftmost (most spoofable) forwarded entry in that case.
183
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).
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.
187
189
 
188
190
  **`secure?` is independent of depth.** Depth mode resolves the client **IP**
189
191
  only; it does **not** grant proxy trust for `X-Forwarded-Proto` / `X-Scheme`.
@@ -195,6 +197,51 @@ check is `false` under depth, so `secure?` does not honor a forwarded proto and
195
197
  reflects only a direct TLS connection (`HTTPS=on` / port 443). This mirrors the
196
198
  downstream (OneTimeSecret) behavior: proto-trust is never derived from depth.
197
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
+
198
245
  ### Security prerequisite: origin lockdown
199
246
 
200
247
  > **Depth trust assumes your app is unreachable except through the proxy tier.**
@@ -59,7 +59,15 @@ class Otto
59
59
 
60
60
  # Set count-based trusted-proxy depth if provided (mutually exclusive
61
61
  # with trusted_proxies; conflict validated at configuration freeze).
62
- @security_config.trusted_proxy_depth = opts[:trusted_proxy_depth] if opts[:trusted_proxy_depth]
62
+ # Guard on presence (`unless nil?`), not truthiness, so an explicitly
63
+ # provided invalid value (e.g. `false`) reaches the validating setter
64
+ # and fails loud instead of being silently dropped.
65
+ @security_config.trusted_proxy_depth = opts[:trusted_proxy_depth] unless opts[:trusted_proxy_depth].nil?
66
+
67
+ # Select the forwarded header depth mode reads from ('X-Forwarded-For',
68
+ # 'Forwarded', or 'Both'). Only consulted in depth mode. Same presence
69
+ # guard: a provided-but-invalid value is validated, not ignored.
70
+ @security_config.trusted_proxy_header = opts[:trusted_proxy_header] unless opts[:trusted_proxy_header].nil?
63
71
 
64
72
  # Set custom security headers
65
73
  return unless opts[:security_headers]
@@ -37,6 +37,13 @@ class Otto
37
37
  hop count, not both.
38
38
  MSG
39
39
 
40
+ # Forwarded-header sources depth mode (#trusted_proxy_depth) can count
41
+ # hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
42
+ # Both (Forwarded when present, else X-Forwarded-For). Mirrors
43
+ # OneTimeSecret's site.network.trusted_proxy.header. Only consulted in
44
+ # depth mode; CIDR-walk is unaffected.
45
+ TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
46
+
40
47
  # Error raised when CSRF protection is enabled in production without an
41
48
  # explicitly configured secret. A randomly-generated per-process secret
42
49
  # silently breaks token verification across workers and restarts, so we
@@ -56,7 +63,7 @@ class Otto
56
63
  :trusted_proxies, :require_secure_cookies,
57
64
  :security_headers,
58
65
  :csp_nonce_enabled, :debug_csp, :mcp_auth,
59
- :ip_privacy_config, :trusted_proxy_depth
66
+ :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header
60
67
 
61
68
  # Initialize security configuration with safe defaults
62
69
  #
@@ -73,6 +80,7 @@ class Otto
73
80
  @trusted_proxies = []
74
81
  @trusted_proxy_matchers = []
75
82
  @trusted_proxy_depth = nil
83
+ @trusted_proxy_header = 'X-Forwarded-For'
76
84
  @require_secure_cookies = false
77
85
  @security_headers = default_security_headers
78
86
  @input_validation = true
@@ -225,6 +233,27 @@ class Otto
225
233
  @trusted_proxy_depth = depth
226
234
  end
227
235
 
236
+ # Select which forwarded header depth mode counts hops from:
237
+ # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
238
+ # consulted when depth mode is active (#trusted_proxy_depth_mode?);
239
+ # CIDR-walk always uses X-Forwarded-For / X-Real-IP / X-Client-IP.
240
+ #
241
+ # The value is matched case-insensitively (surrounding whitespace ignored)
242
+ # and stored in its canonical spelling, so a hand-edited config can write
243
+ # `forwarded` or `both` without surprise. A genuinely unrecognized value
244
+ # fails loud at assignment (rather than silently resolving from the wrong
245
+ # header, the way a permissive default would), so a typo surfaces at config
246
+ # time instead of as subtly-wrong client IPs at request time.
247
+ #
248
+ # @param header [String] one of TRUSTED_PROXY_HEADERS (case-insensitive)
249
+ # @raise [FrozenError] if configuration is frozen
250
+ # @raise [ArgumentError] if header is not a recognized value
251
+ def trusted_proxy_header=(header)
252
+ ensure_not_frozen!
253
+
254
+ @trusted_proxy_header = canonicalize_trusted_proxy_header(header)
255
+ end
256
+
228
257
  # Validate that a request size is within acceptable limits
229
258
  #
230
259
  # @param content_length [String, Integer, nil] Content-Length header value
@@ -453,6 +482,42 @@ class Otto
453
482
  raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
454
483
  end
455
484
 
485
+ # Canonicalize a candidate trusted_proxy_header value: match it
486
+ # case-insensitively (ignoring surrounding whitespace) against the
487
+ # recognized set and return the canonical spelling. Liberal in the spelling
488
+ # it accepts (e.g. 'forwarded' => 'Forwarded') but fail-loud on a genuinely
489
+ # unrecognized value, so a typo is caught at config time rather than
490
+ # silently resolving the client IP from the wrong header.
491
+ #
492
+ # @param header [Object] candidate value
493
+ # @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
494
+ # @return [String] the canonical header value
495
+ def canonicalize_trusted_proxy_header(header)
496
+ candidate = header.to_s.strip
497
+ canonical = TRUSTED_PROXY_HEADERS.find { |allowed| allowed.casecmp?(candidate) }
498
+ return canonical if canonical
499
+
500
+ raise ArgumentError,
501
+ "trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
502
+ end
503
+
504
+ # Strictly validate a stored trusted_proxy_header value against the allowed
505
+ # set. The eager #trusted_proxy_header= setter already canonicalizes, so by
506
+ # freeze time the value is canonical; this freeze-time backstop catches a
507
+ # value smuggled in through a direct-ivar path that bypassed the setter,
508
+ # failing loud rather than silently mis-resolving the client IP at request
509
+ # time.
510
+ #
511
+ # @param header [Object] candidate value
512
+ # @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
513
+ # @return [void]
514
+ def validate_trusted_proxy_header!(header)
515
+ return if TRUSTED_PROXY_HEADERS.include?(header)
516
+
517
+ raise ArgumentError,
518
+ "trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
519
+ end
520
+
456
521
  # Validate trusted-proxy configuration coherence at freeze time.
457
522
  #
458
523
  # The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
@@ -464,6 +529,7 @@ class Otto
464
529
  # trusted_proxies and a depth >= 1 are configured
465
530
  # @return [void]
466
531
  def validate_trusted_proxy_config!
532
+ validate_trusted_proxy_header!(@trusted_proxy_header)
467
533
  validate_trusted_proxy_depth!(@trusted_proxy_depth)
468
534
  return if @trusted_proxy_depth.nil?
469
535
 
@@ -39,6 +39,9 @@ class Otto
39
39
  # @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
40
40
  # the last N hops") for non-enumerable proxy tiers; mutually exclusive
41
41
  # with trusted_proxies (validated at configuration freeze)
42
+ # @param trusted_proxy_header [String, nil] Forwarded header depth mode
43
+ # counts hops from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239),
44
+ # or 'Both'. Only consulted in depth mode.
42
45
  # @param security_headers [Hash] Custom security headers to merge with defaults
43
46
  # @param hsts [Boolean] Enable HTTP Strict Transport Security
44
47
  # @param csp [Boolean, String] Enable Content Security Policy
@@ -62,6 +65,7 @@ class Otto
62
65
  rate_limiting: false,
63
66
  trusted_proxies: [],
64
67
  trusted_proxy_depth: nil,
68
+ trusted_proxy_header: nil,
65
69
  security_headers: {},
66
70
  hsts: false,
67
71
  csp: false,
@@ -74,6 +78,7 @@ class Otto
74
78
 
75
79
  Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
76
80
  self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
81
+ self.trusted_proxy_header = trusted_proxy_header unless trusted_proxy_header.nil?
77
82
  self.security_headers = security_headers unless security_headers.empty?
78
83
 
79
84
  enable_hsts! if hsts
@@ -142,6 +147,16 @@ class Otto
142
147
  @security_config.trusted_proxy_depth = depth
143
148
  end
144
149
 
150
+ # Select which forwarded header depth mode counts hops from:
151
+ # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
152
+ # consulted when depth mode is active. Mirrors OneTimeSecret's
153
+ # site.network.trusted_proxy.header.
154
+ #
155
+ # @param header [String] one of Otto::Security::Config::TRUSTED_PROXY_HEADERS
156
+ def trusted_proxy_header=(header)
157
+ @security_config.trusted_proxy_header = header
158
+ end
159
+
145
160
  # Set custom security headers that will be added to all responses.
146
161
  # These merge with the default security headers.
147
162
  #
data/lib/otto/utils.rb CHANGED
@@ -143,37 +143,41 @@ class Otto
143
143
  # from the right of the forwarded chain (Express `trust proxy = N`). Used
144
144
  # when the proxy tier's addresses cannot be enumerated as CIDRs.
145
145
  #
146
- # The chain is X-Forwarded-For (leftmost = client .. rightmost = nearest
147
- # proxy) plus REMOTE_ADDR (the direct peer). With depth N the client is
148
- # chain[-(N+1)] — exactly N trusted hops from the right, equivalent to
149
- # Express's addrs[N]. This is robust to X-Forwarded-For padding: a forged
150
- # leftmost entry is never reached.
146
+ # The chain is the configured forwarded header (leftmost = client ..
147
+ # rightmost = nearest proxy) plus REMOTE_ADDR (the direct peer). With depth
148
+ # N the client is chain[-(N+1)] — exactly N trusted hops from the right,
149
+ # equivalent to Express's addrs[N]. This is robust to forwarded-header
150
+ # padding: a forged leftmost entry is never reached.
151
151
  #
152
152
  # SECURITY: depth trust ASSUMES ORIGIN LOCKDOWN — the app must be
153
153
  # unreachable except through the proxy tier. Without it, a direct client
154
- # could pad X-Forwarded-For to land a forged value at the target index.
154
+ # could pad the forwarded header to land a forged value at the target index.
155
155
  # This is the inherent trade vs CIDR-walk (a fixed hop count instead of
156
156
  # enumerable proxy addresses).
157
157
  #
158
- # Only X-Forwarded-For is consulted; X-Real-IP / X-Client-IP are
159
- # single-value and cannot express a hop chain. Positions are counted raw
160
- # (never dropped), so junk padding cannot shift the index; only the
161
- # selected entry is validated. If the chain is shorter than N+1 (a request
162
- # that may have bypassed the proxy tier) or the selected entry is invalid,
163
- # REMOTE_ADDR is returned rather than a spoofable forwarded value.
158
+ # The forwarded chain is selected by security_config.trusted_proxy_header:
159
+ # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both' (RFC 7239
160
+ # when it carries a `for=`, otherwise X-Forwarded-For mirrors
161
+ # OneTimeSecret's site.network.trusted_proxy.header). X-Real-IP / X-Client-IP
162
+ # are single-value and cannot express a hop chain, so they are never
163
+ # consulted in depth mode. Positions are counted raw (never dropped), so junk
164
+ # padding cannot shift the index; only the selected entry is validated. If
165
+ # the chain is shorter than N+1 (a request that may have bypassed the proxy
166
+ # tier) or the selected entry is invalid, REMOTE_ADDR is returned rather than
167
+ # a spoofable forwarded value.
164
168
  #
165
169
  # @param env [Hash] Rack environment
166
- # @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth
170
+ # @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth and #trusted_proxy_header
167
171
  # @return [String, nil] resolved client IP (REMOTE_ADDR on short chain / invalid target)
168
172
  def resolve_client_ip_by_depth(env, security_config)
169
173
  remote_addr = env['REMOTE_ADDR']
170
174
  depth = security_config.trusted_proxy_depth.to_i
171
175
 
172
- # Split on commas keeping every position (-1 preserves trailing empty
173
- # fields) so a malformed hop still counts as a position. The client must
174
- # be located by counting from the right; dropping entries here would let
175
- # padding shift the index.
176
- forwarded = env['HTTP_X_FORWARDED_FOR'].to_s.split(',', -1)
176
+ # Build the positional hop chain from the configured header, keeping every
177
+ # position (junk/empty entries included) so the client can be located by
178
+ # counting from the right; dropping entries would let padding shift the
179
+ # index. REMOTE_ADDR (the direct peer) is the rightmost hop.
180
+ forwarded = forwarded_chain_for_depth(env, security_config.trusted_proxy_header)
177
181
  chain = forwarded + [remote_addr]
178
182
 
179
183
  index = chain.length - (depth + 1)
@@ -182,6 +186,81 @@ class Otto
182
186
  normalize_ip(chain[index].to_s.strip) || remote_addr
183
187
  end
184
188
 
189
+ # Positional forwarded-hop chain for depth resolution, selected by header
190
+ # mode. Each element is one hop (preserving count); values are raw — only the
191
+ # finally-selected entry is normalized. Mirrors OneTimeSecret's
192
+ # site.network.trusted_proxy.header semantics.
193
+ #
194
+ # @param env [Hash] Rack environment
195
+ # @param header_mode [String] 'X-Forwarded-For', 'Forwarded', or 'Both'
196
+ # @return [Array<String>] one entry per hop (may include blank/invalid entries)
197
+ def forwarded_chain_for_depth(env, header_mode)
198
+ case header_mode
199
+ when 'Forwarded'
200
+ rfc7239_for_chain(env['HTTP_FORWARDED'])
201
+ when 'Both'
202
+ # RFC 7239 wins when it carries at least one `for=`; otherwise fall back
203
+ # to X-Forwarded-For. The chains are NOT merged (matches OTS's
204
+ # `extract_rfc7239_forwarded(env) || extract_x_forwarded_for(env)`).
205
+ forwarded = rfc7239_for_chain(env['HTTP_FORWARDED'])
206
+ forwarded.any? { |entry| !entry.empty? } ? forwarded : xff_chain(env['HTTP_X_FORWARDED_FOR'])
207
+ else
208
+ xff_chain(env['HTTP_X_FORWARDED_FOR'])
209
+ end
210
+ end
211
+
212
+ # Split X-Forwarded-For into raw positional entries. `-1` keeps trailing
213
+ # empty fields so a malformed/empty hop still counts as a position.
214
+ #
215
+ # @param value [String, nil] raw X-Forwarded-For header value
216
+ # @return [Array<String>]
217
+ def xff_chain(value)
218
+ value.to_s.split(',', -1)
219
+ end
220
+
221
+ # Extract the per-hop `for=` chain from an RFC 7239 Forwarded header,
222
+ # preserving one position per forwarded-element. Elements without a `for=`
223
+ # parameter yield a blank placeholder so they still occupy a hop position
224
+ # (raw position counting). The extracted token is only unquoted here; port
225
+ # and IPv6 brackets are left for normalize_ip when the entry is selected.
226
+ # Obfuscated (`for=_hidden`) and `for=unknown` identifiers are preserved as
227
+ # positions but normalize to nil (→ REMOTE_ADDR fallback if selected).
228
+ # Commas separate forwarded-elements (and join multiple Forwarded headers).
229
+ # A nil/blank header splits to [] (not ['']), so an absent Forwarded header
230
+ # yields an empty chain and depth's explicit short-chain guard returns
231
+ # REMOTE_ADDR — symmetric with xff_chain.
232
+ #
233
+ # @param value [String, nil] raw Forwarded header value
234
+ # @return [Array<String>] one `for=` token per forwarded-element
235
+ def rfc7239_for_chain(value)
236
+ value.to_s.split(',', -1).map { |element| rfc7239_for_value(element) }
237
+ end
238
+
239
+ # Pull the `for=` token out of a single RFC 7239 forwarded-element. The value
240
+ # is a quoted-string (which may itself legally contain ';') or an unquoted
241
+ # token ending at the next ';'. The quoted form is matched first so a ';'
242
+ # inside DQUOTEs is NOT treated as a parameter separator — otherwise a value
243
+ # like for="1.2.3.4;junk" would be truncated to a valid-looking IP instead of
244
+ # being rejected. Only DQUOTE wrappers are stripped: RFC 7239 quoted-strings
245
+ # use DQUOTE exclusively, so a value like for='1.2.3.4' keeps its quotes,
246
+ # fails normalize_ip, and safely falls back to REMOTE_ADDR rather than being
247
+ # permissively accepted. This is deliberately stricter than OTS (which strips
248
+ # both ['"]), consistent with depth's other intentionally-not-reconciled-down
249
+ # safety properties. The raw value (port / IPv6 brackets intact) is left for
250
+ # normalize_ip when the entry is selected. Returns '' when the element carries
251
+ # no `for=` parameter, preserving the hop position. The `for=` pair may be the
252
+ # element's first pair or follow a ';'; leading whitespace (e.g. after a comma
253
+ # split) is tolerated.
254
+ #
255
+ # @param element [String] one forwarded-element (e.g. 'for=1.2.3.4;proto=https')
256
+ # @return [String]
257
+ def rfc7239_for_value(element)
258
+ match = element.match(/(?:\A|;)\s*for=(?:"([^"]*)"|([^;]+))/i)
259
+ return '' unless match
260
+
261
+ (match[1] || match[2]).strip
262
+ end
263
+
185
264
  # Whether an address is non-public: RFC1918 private, loopback, link-local,
186
265
  # multicast, or unspecified — for both IPv4 and IPv6.
187
266
  #
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.3.0'
6
+ VERSION = '2.3.1'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -135,6 +135,7 @@ files:
135
135
  - Gemfile.lock
136
136
  - LICENSE.txt
137
137
  - README.md
138
+ - Rakefile
138
139
  - bin/rspec
139
140
  - changelog.d/README.md
140
141
  - changelog.d/scriv.ini