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 +4 -4
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +6 -1
- data/CHANGELOG.rst +25 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/Rakefile +21 -0
- data/docs/migrating/v2.3.0.md +50 -3
- data/lib/otto/core/configuration.rb +9 -1
- data/lib/otto/security/config.rb +67 -1
- data/lib/otto/security/configurator.rb +15 -0
- data/lib/otto/utils.rb +97 -18
- data/lib/otto/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72c7a8fdbc299b202666d5f94532e4eed6d3fc2b2ee6a58234519deba65bfb0c
|
|
4
|
+
data.tar.gz: d82daec0441d3e9a8c196e6306aa8945292b8a322e3acbdf146294bc55345782
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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@
|
|
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.
|
|
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
|
data/docs/migrating/v2.3.0.md
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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]
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -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
|
|
147
|
-
# proxy) plus REMOTE_ADDR (the direct peer). With depth
|
|
148
|
-
# chain[-(N+1)] — exactly N trusted hops from the right,
|
|
149
|
-
# Express's addrs[N]. This is robust to
|
|
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
|
|
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
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
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
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
forwarded = env
|
|
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
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.
|
|
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
|