otto 2.11.0 → 2.12.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 +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.rubocop_todo.yml +2 -2
- data/CHANGELOG.rst +100 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +68 -5
- data/docs/guides/testing-guide.md +115 -2
- data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
- data/lib/otto/core/configuration.rb +5 -3
- data/lib/otto/core/router.rb +16 -20
- data/lib/otto/env_keys.rb +2 -1
- data/lib/otto/privacy/config.rb +19 -13
- data/lib/otto/response.rb +5 -2
- data/lib/otto/route.rb +1 -1
- data/lib/otto/route_handlers/base.rb +1 -1
- data/lib/otto/route_handlers/logic_class.rb +86 -34
- data/lib/otto/security/config.rb +349 -303
- data/lib/otto/security/configurator.rb +82 -45
- data/lib/otto/security/core.rb +4 -2
- data/lib/otto/security/csp/report_middleware.rb +17 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
- data/lib/otto/security/rate_limiter.rb +8 -5
- data/lib/otto/security/trusted_proxy_config.rb +396 -0
- data/lib/otto/static.rb +7 -6
- data/lib/otto/testing.rb +148 -0
- data/lib/otto/utils.rb +63 -14
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +7 -2
- metadata +4 -2
data/lib/otto/security/config.rb
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'digest'
|
|
7
7
|
require 'openssl'
|
|
8
|
-
require 'ipaddr'
|
|
9
8
|
require 'rack/request'
|
|
10
9
|
require_relative '../core/freezable'
|
|
11
10
|
require_relative 'csp/policy'
|
|
11
|
+
require_relative 'trusted_proxy_config'
|
|
12
12
|
|
|
13
13
|
class Otto
|
|
14
14
|
module Security
|
|
@@ -30,14 +30,200 @@ class Otto
|
|
|
30
30
|
class Config
|
|
31
31
|
include Otto::Core::Freezable
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
# Otto accepts exactly one W3C Referrer Policy token for its
|
|
34
|
+
# referrer_policy setting. The supported tokens are enumerated below:
|
|
35
|
+
# https://www.w3.org/TR/referrer-policy/#referrer-policy-header-dfn
|
|
36
|
+
REFERRER_POLICIES = %w[
|
|
37
|
+
no-referrer
|
|
38
|
+
no-referrer-when-downgrade
|
|
39
|
+
strict-origin
|
|
40
|
+
strict-origin-when-cross-origin
|
|
41
|
+
same-origin
|
|
42
|
+
origin
|
|
43
|
+
origin-when-cross-origin
|
|
44
|
+
unsafe-url
|
|
45
|
+
].freeze
|
|
46
|
+
DEFAULT_REFERRER_POLICY = 'strict-origin-when-cross-origin'
|
|
47
|
+
|
|
48
|
+
# Hash-compatible storage that keeps the generic security-header API and
|
|
49
|
+
# the dedicated referrer_policy setting on one validated code path.
|
|
50
|
+
# Existing callers may continue to mutate Config#security_headers like a
|
|
51
|
+
# Hash. Every destructive Hash operation keeps the required
|
|
52
|
+
# Referrer-Policy entry canonical, validates any replacement before
|
|
53
|
+
# committing it, and prevents removal of the dedicated setting.
|
|
54
|
+
class SecurityHeaders < Hash
|
|
55
|
+
REFERRER_POLICY_HEADER = 'referrer-policy'
|
|
56
|
+
REFERRER_POLICY_REMOVAL_MESSAGE =
|
|
57
|
+
'referrer-policy cannot be removed; assign a valid referrer_policy token instead'
|
|
58
|
+
|
|
59
|
+
def initialize(referrer_policy_validator)
|
|
60
|
+
@referrer_policy_validator = referrer_policy_validator
|
|
61
|
+
super()
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def []=(header, value)
|
|
65
|
+
if referrer_policy_header?(header)
|
|
66
|
+
validated = @referrer_policy_validator.call(value).dup.freeze
|
|
67
|
+
super(REFERRER_POLICY_HEADER, validated)
|
|
68
|
+
else
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
alias store []=
|
|
73
|
+
|
|
74
|
+
def merge!(*other_hashes)
|
|
75
|
+
raise FrozenError, "can't modify frozen #{self.class}" if frozen?
|
|
76
|
+
|
|
77
|
+
replacement = dup
|
|
78
|
+
other_hashes.each do |other_hash|
|
|
79
|
+
converted = Hash.try_convert(other_hash)
|
|
80
|
+
raise TypeError, "no implicit conversion of #{other_hash.class} into Hash" unless converted
|
|
81
|
+
|
|
82
|
+
converted.each_pair do |header, value|
|
|
83
|
+
key = referrer_policy_header?(header) ? REFERRER_POLICY_HEADER : header
|
|
84
|
+
value = yield(key, replacement[key], value) if block_given? && replacement.key?(key)
|
|
85
|
+
replacement[key] = value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
super(replacement, &nil)
|
|
90
|
+
end
|
|
91
|
+
alias update merge!
|
|
92
|
+
|
|
93
|
+
def replace(other_hash)
|
|
94
|
+
replacement = self.class.new(@referrer_policy_validator)
|
|
95
|
+
replacement.merge!(other_hash)
|
|
96
|
+
replacement[REFERRER_POLICY_HEADER] = self[REFERRER_POLICY_HEADER] unless replacement.key?(
|
|
97
|
+
REFERRER_POLICY_HEADER
|
|
98
|
+
)
|
|
99
|
+
super(replacement)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Clearing custom security headers must not remove Otto's required,
|
|
103
|
+
# dedicated referrer_policy setting.
|
|
104
|
+
def clear
|
|
105
|
+
policy = self[REFERRER_POLICY_HEADER]
|
|
106
|
+
super
|
|
107
|
+
self[REFERRER_POLICY_HEADER] = policy
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def delete(header, &)
|
|
112
|
+
raise ArgumentError, REFERRER_POLICY_REMOVAL_MESSAGE if referrer_policy_header?(header)
|
|
113
|
+
|
|
114
|
+
super
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def delete_if(&block)
|
|
118
|
+
return enum_for(__method__) unless block
|
|
119
|
+
|
|
120
|
+
filter_entries!(remove_when: true, return_nil_when_unchanged: false, &block)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reject!(&block)
|
|
124
|
+
return enum_for(__method__) unless block
|
|
125
|
+
|
|
126
|
+
filter_entries!(remove_when: true, return_nil_when_unchanged: true, &block)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def keep_if(&block)
|
|
130
|
+
return enum_for(__method__) unless block
|
|
131
|
+
|
|
132
|
+
filter_entries!(remove_when: false, return_nil_when_unchanged: false, &block)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def select!(&block)
|
|
136
|
+
return enum_for(__method__) unless block
|
|
137
|
+
|
|
138
|
+
filter_entries!(remove_when: false, return_nil_when_unchanged: true, &block)
|
|
139
|
+
end
|
|
140
|
+
alias filter! select!
|
|
141
|
+
|
|
142
|
+
# Remove the first non-Referrer-Policy entry, keeping the dedicated
|
|
143
|
+
# setting even when it is the only entry left.
|
|
144
|
+
def shift
|
|
145
|
+
raise FrozenError, "can't modify frozen #{self.class}" if frozen?
|
|
146
|
+
|
|
147
|
+
header = each_key.find { |key| !referrer_policy_header?(key) }
|
|
148
|
+
return nil unless header
|
|
149
|
+
|
|
150
|
+
[header, delete(header)]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def transform_values!
|
|
154
|
+
return enum_for(__method__) unless block_given?
|
|
155
|
+
|
|
156
|
+
replacement = self.class.new(@referrer_policy_validator)
|
|
157
|
+
each_pair { |header, value| replacement[header] = yield(value) }
|
|
158
|
+
replace(replacement)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Hash#transform_keys! accepts an optional key-mapping Hash, a block,
|
|
162
|
+
# or both (the mapping wins for keys it contains). Referrer-Policy may
|
|
163
|
+
# change case but cannot be renamed to a different field.
|
|
164
|
+
def transform_keys!(*args, &block)
|
|
165
|
+
if args.empty? && !block
|
|
166
|
+
return enum_for(__method__, *args)
|
|
167
|
+
elsif args.length > 1
|
|
168
|
+
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 0..1)"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
mapping = args.first
|
|
172
|
+
replacement = self.class.new(@referrer_policy_validator)
|
|
173
|
+
each_pair do |header, value|
|
|
174
|
+
transformed = transformed_header(header, mapping, block)
|
|
175
|
+
raise ArgumentError, REFERRER_POLICY_REMOVAL_MESSAGE if referrer_policy_header?(header) &&
|
|
176
|
+
!referrer_policy_header?(transformed)
|
|
177
|
+
|
|
178
|
+
replacement[transformed] = value
|
|
179
|
+
end
|
|
180
|
+
replace(replacement)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Identity comparison would make normal String lookups miss the
|
|
184
|
+
# canonical Referrer-Policy key and create apparent duplicates.
|
|
185
|
+
def compare_by_identity
|
|
186
|
+
raise ArgumentError, 'security_headers cannot use identity comparison'
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def referrer_policy_header?(header)
|
|
192
|
+
header.to_s.casecmp?(REFERRER_POLICY_HEADER)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def filter_entries!(remove_when:, return_nil_when_unchanged:)
|
|
196
|
+
replacement = self.class.new(@referrer_policy_validator)
|
|
197
|
+
each_pair do |header, value|
|
|
198
|
+
selected = yield(header, value)
|
|
199
|
+
keep = remove_when ? !selected : selected
|
|
200
|
+
raise ArgumentError, REFERRER_POLICY_REMOVAL_MESSAGE if referrer_policy_header?(header) && !keep
|
|
201
|
+
|
|
202
|
+
replacement[header] = value if keep
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return nil if return_nil_when_unchanged && replacement == self
|
|
206
|
+
|
|
207
|
+
replace(replacement)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def transformed_header(header, mapping, block)
|
|
211
|
+
return mapping[header] if mapping&.key?(header)
|
|
212
|
+
return block.call(header) if block
|
|
213
|
+
|
|
214
|
+
header
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Trusted-proxy error messages and values, owned by TrustedProxyConfig
|
|
219
|
+
# and aliased here because callers have always referenced them on Config.
|
|
220
|
+
PROXY_MODE_CONFLICT_MESSAGE = TrustedProxyConfig::PROXY_MODE_CONFLICT_MESSAGE
|
|
221
|
+
TRUST_NO_PROXIES_CONFLICT_MESSAGE = TrustedProxyConfig::TRUST_NO_PROXIES_CONFLICT_MESSAGE
|
|
222
|
+
TRUST_NO_PROXIES_ENTRY_MESSAGE = TrustedProxyConfig::TRUST_NO_PROXIES_ENTRY_MESSAGE
|
|
223
|
+
FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE = TrustedProxyConfig::FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE
|
|
224
|
+
TRUST_NO_PROXIES = TrustedProxyConfig::TRUST_NO_PROXIES
|
|
225
|
+
TRUSTED_PROXY_HEADERS = TrustedProxyConfig::HEADERS
|
|
226
|
+
DEFAULT_TRUSTED_PROXY_HEADER = TrustedProxyConfig::DEFAULT_HEADER
|
|
41
227
|
|
|
42
228
|
# Error raised when an app-configured trusted geo header (ip_privacy
|
|
43
229
|
# geo_header) is combined with count-based depth mode. Geo headers are
|
|
@@ -56,53 +242,16 @@ class Otto
|
|
|
56
242
|
(geo_db_path or geo_db_reader).
|
|
57
243
|
MSG
|
|
58
244
|
|
|
59
|
-
#
|
|
60
|
-
# (
|
|
61
|
-
#
|
|
62
|
-
# opposite things about the same peer, so the combination is refused at
|
|
63
|
-
# configuration time rather than silently resolved in one direction.
|
|
64
|
-
TRUST_NO_PROXIES_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
65
|
-
Cannot combine trusted_proxies: :none (trust no proxy) with
|
|
66
|
-
trusted_proxies CIDRs or trusted_proxy_depth >= 1. Assert :none OR
|
|
67
|
-
grant trust, not both.
|
|
68
|
-
MSG
|
|
69
|
-
|
|
70
|
-
# Error raised when the trust-nobody sentinel arrives as a proxy ENTRY
|
|
71
|
-
# (`trusted_proxies: ['none']`, as a YAML/JSON list naturally yields, or
|
|
72
|
-
# `add_trusted_proxy('none')`) instead of as the whole option. Inside a
|
|
73
|
-
# list it would otherwise register a legacy string-prefix matcher that
|
|
74
|
-
# matches nothing: peers would be untrusted, but trust_no_proxies? would
|
|
75
|
-
# stay false and the config would stake a forwarding-family claim, so the
|
|
76
|
-
# explicit assertion would be silently replaced by a lookalike.
|
|
77
|
-
TRUST_NO_PROXIES_ENTRY_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
78
|
-
trusted_proxies entry :none is the trust-nobody assertion, not a proxy
|
|
79
|
-
address. Pass trusted_proxies: :none as the whole option (not inside a
|
|
80
|
-
list) or call trust_no_proxies! instead.
|
|
81
|
-
MSG
|
|
82
|
-
|
|
83
|
-
# Sentinel accepted wherever a trusted_proxies list is accepted, meaning
|
|
84
|
-
# "the operator asserts that NO proxy is trusted". See #trust_no_proxies!.
|
|
85
|
-
TRUST_NO_PROXIES = :none
|
|
86
|
-
|
|
87
|
-
# Whether a trusted_proxies option value is the trust-nobody sentinel.
|
|
88
|
-
# Accepts the symbol and the String spelling 'none' (case-insensitive),
|
|
89
|
-
# which is what YAML/ENV-driven configuration naturally produces; without
|
|
90
|
-
# this, 'none' would fall through to add_trusted_proxy and install a
|
|
91
|
-
# legacy string-prefix matcher, silently inverting the assertion.
|
|
245
|
+
# Whether a trusted_proxies option value is the trust-nobody sentinel
|
|
246
|
+
# (:none, or 'none' in any case). See
|
|
247
|
+
# TrustedProxyConfig.trust_no_proxies_option?.
|
|
92
248
|
#
|
|
93
249
|
# @param value [Object] raw trusted_proxies option
|
|
94
250
|
# @return [Boolean]
|
|
95
251
|
def self.trust_no_proxies_option?(value)
|
|
96
|
-
|
|
252
|
+
TrustedProxyConfig.trust_no_proxies_option?(value)
|
|
97
253
|
end
|
|
98
254
|
|
|
99
|
-
# Forwarded-header sources depth mode (#trusted_proxy_depth) can count
|
|
100
|
-
# hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
|
|
101
|
-
# Both (Forwarded when present, else X-Forwarded-For). Mirrors
|
|
102
|
-
# OneTimeSecret's site.network.trusted_proxy.header. Only consulted in
|
|
103
|
-
# depth mode; CIDR-walk is unaffected.
|
|
104
|
-
TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
105
|
-
|
|
106
255
|
# Rack uses one process-global priority for forwarded host, port, scheme,
|
|
107
256
|
# and IP resolution. Keep it aligned with Otto's configured forwarding
|
|
108
257
|
# family so the two request views cannot silently disagree.
|
|
@@ -113,28 +262,14 @@ class Otto
|
|
|
113
262
|
'Forwarded' => [:forwarded].freeze,
|
|
114
263
|
'Both' => %i[forwarded x_forwarded].freeze,
|
|
115
264
|
}.freeze
|
|
116
|
-
DEFAULT_TRUSTED_PROXY_HEADER = 'X-Forwarded-For'
|
|
117
265
|
FORWARDING_FAMILY_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
118
266
|
Cannot use forwarding family %s (trusted_proxy_header) because another
|
|
119
267
|
Otto application in this process already uses %s. Rack's forwarded
|
|
120
268
|
host, port, scheme, and IP policy is process-global, so every Otto
|
|
121
269
|
application in one process that resolves proxied requests must use the
|
|
122
|
-
same forwarding family.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# CIDR filter mode. Otto's CIDR-walk resolves the client IP from the
|
|
126
|
-
# X-Forwarded-For family only (X-Forwarded-For, then X-Real-IP, then
|
|
127
|
-
# X-Client-IP — Otto::Utils::FORWARDED_FOR_HEADERS), never RFC 7239
|
|
128
|
-
# Forwarded, while trusted_proxy_header also pins Rack's
|
|
129
|
-
# forwarding family; honoring 'Forwarded' or 'Both' there would make Rack
|
|
130
|
-
# read a header Otto ignores, recreating the disagreement the pin exists
|
|
131
|
-
# to close.
|
|
132
|
-
FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
133
|
-
Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together
|
|
134
|
-
with trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs
|
|
135
|
-
from the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP,
|
|
136
|
-
X-Client-IP), never RFC 7239 Forwarded. Use trusted_proxy_depth (count
|
|
137
|
-
mode) to read the RFC 7239 Forwarded header.
|
|
270
|
+
same forwarding family. A test suite that builds applications with
|
|
271
|
+
different families must clear this between tests: require
|
|
272
|
+
'otto/testing' and call Otto::Testing.reset!.
|
|
138
273
|
MSG
|
|
139
274
|
|
|
140
275
|
# Eager so the first two concurrent Otto.new calls cannot race on
|
|
@@ -210,16 +345,16 @@ class Otto
|
|
|
210
345
|
attr_reader :rack_forwarding_family
|
|
211
346
|
|
|
212
347
|
# Clear process-global forwarding state between isolated RSpec examples.
|
|
348
|
+
# Otto::Testing.reset! does the same under any test framework.
|
|
213
349
|
#
|
|
214
350
|
# @api private
|
|
215
351
|
def reset_rack_forwarding_family_for_testing!
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
@rack_forwarding_owners = nil
|
|
220
|
-
@rack_forwarding_family = nil
|
|
221
|
-
RACK_REQUEST.forwarded_priority = DEFAULT_RACK_FORWARDED_PRIORITY.dup
|
|
352
|
+
unless defined?(RSpec)
|
|
353
|
+
raise 'reset_rack_forwarding_family_for_testing! is only available in RSpec test environment; ' \
|
|
354
|
+
"outside RSpec, require 'otto/testing' and call Otto::Testing.reset!"
|
|
222
355
|
end
|
|
356
|
+
|
|
357
|
+
reset_rack_forwarding_family!
|
|
223
358
|
end
|
|
224
359
|
|
|
225
360
|
private
|
|
@@ -228,6 +363,17 @@ class Otto
|
|
|
228
363
|
def rack_forwarding_owners
|
|
229
364
|
@rack_forwarding_owners ||= {}.compare_by_identity
|
|
230
365
|
end
|
|
366
|
+
|
|
367
|
+
# Drop every commitment and restore Rack's load-time priority. Private
|
|
368
|
+
# because production must never do this: it is what lets two configs
|
|
369
|
+
# with different families coexist. Otto::Testing.reset! reaches it.
|
|
370
|
+
def reset_rack_forwarding_family!
|
|
371
|
+
RACK_FORWARDING_MUTEX.synchronize do
|
|
372
|
+
@rack_forwarding_owners = nil
|
|
373
|
+
@rack_forwarding_family = nil
|
|
374
|
+
RACK_REQUEST.forwarded_priority = DEFAULT_RACK_FORWARDED_PRIORITY.dup
|
|
375
|
+
end
|
|
376
|
+
end
|
|
231
377
|
end
|
|
232
378
|
|
|
233
379
|
# Endpoint group name shared by the CSP `report-to` directive and the
|
|
@@ -253,10 +399,10 @@ class Otto
|
|
|
253
399
|
:max_param_keys
|
|
254
400
|
|
|
255
401
|
attr_reader :csrf_protection, :csrf_header_key,
|
|
256
|
-
:
|
|
402
|
+
:require_secure_cookies,
|
|
257
403
|
:security_headers,
|
|
258
404
|
:csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
|
|
259
|
-
:ip_privacy_config,
|
|
405
|
+
:ip_privacy_config,
|
|
260
406
|
:csp_report_uri, :csp_report_to_url, :csp_violation_callback,
|
|
261
407
|
:csp_directive_overrides, :csp_request_extras_enabled
|
|
262
408
|
|
|
@@ -272,13 +418,10 @@ class Otto
|
|
|
272
418
|
@max_request_size = 10 * 1024 * 1024 # 10MB
|
|
273
419
|
@max_param_depth = 32
|
|
274
420
|
@max_param_keys = 64
|
|
275
|
-
@
|
|
276
|
-
@trusted_proxy_matchers = []
|
|
277
|
-
@trust_no_proxies = false
|
|
278
|
-
@trusted_proxy_depth = nil
|
|
279
|
-
@trusted_proxy_header = DEFAULT_TRUSTED_PROXY_HEADER
|
|
421
|
+
@trusted_proxy_config = TrustedProxyConfig.new
|
|
280
422
|
@require_secure_cookies = false
|
|
281
|
-
@security_headers =
|
|
423
|
+
@security_headers = SecurityHeaders.new(method(:validate_referrer_policy!))
|
|
424
|
+
@security_headers.merge!(default_security_headers)
|
|
282
425
|
@input_validation = true
|
|
283
426
|
@csp_nonce_enabled = false
|
|
284
427
|
@debug_csp = false
|
|
@@ -331,14 +474,52 @@ class Otto
|
|
|
331
474
|
@csrf_protection
|
|
332
475
|
end
|
|
333
476
|
|
|
477
|
+
# The active trusted-proxy mode: :filter, :depth, :none, or nil when
|
|
478
|
+
# proxy trust is unconfigured. The TrustedProxyConfig behind it is not
|
|
479
|
+
# exposed, because its setters would skip the Rack forwarding-family pin
|
|
480
|
+
# and the geo_header check this class adds.
|
|
481
|
+
#
|
|
482
|
+
# @return [Symbol, nil]
|
|
483
|
+
def trusted_proxy_mode
|
|
484
|
+
@trusted_proxy_config.mode
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Proxy entries registered with #add_trusted_proxy, in order.
|
|
488
|
+
#
|
|
489
|
+
# @return [Array<String, Regexp>]
|
|
490
|
+
def trusted_proxies
|
|
491
|
+
@trusted_proxy_config.proxies
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Count-based trusted-proxy depth, or nil. See #trusted_proxy_depth=.
|
|
495
|
+
#
|
|
496
|
+
# @return [Integer, nil]
|
|
497
|
+
def trusted_proxy_depth
|
|
498
|
+
@trusted_proxy_config.depth
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Forwarded header family depth mode counts hops from. See
|
|
502
|
+
# #trusted_proxy_header=.
|
|
503
|
+
#
|
|
504
|
+
# @return [String] one of TRUSTED_PROXY_HEADERS
|
|
505
|
+
def trusted_proxy_header
|
|
506
|
+
@trusted_proxy_config.header
|
|
507
|
+
end
|
|
508
|
+
|
|
334
509
|
# Add a trusted proxy server for accurate client IP detection
|
|
335
510
|
#
|
|
336
511
|
# Only requests from trusted proxies will have their X-Forwarded-For
|
|
337
512
|
# and similar headers honored for IP detection. This prevents IP spoofing
|
|
338
513
|
# from untrusted sources.
|
|
339
514
|
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
515
|
+
# Mutually exclusive with count-based depth, with the trust-nobody
|
|
516
|
+
# assertion, and with a trusted_proxy_header other than X-Forwarded-For;
|
|
517
|
+
# each conflict raises here rather than only at freeze (which the test
|
|
518
|
+
# harness skips). A list is validated whole before any entry is
|
|
519
|
+
# registered. See TrustedProxyConfig#add.
|
|
520
|
+
#
|
|
521
|
+
# @param proxy [String, Regexp, Array] IP address, CIDR range, Regexp, or array of these
|
|
522
|
+
# @raise [ArgumentError] if proxy is not a String, Regexp, or Array, or on a conflict
|
|
342
523
|
# @raise [FrozenError] if configuration is frozen
|
|
343
524
|
# @return [void]
|
|
344
525
|
#
|
|
@@ -352,67 +533,21 @@ class Otto
|
|
|
352
533
|
# config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
|
|
353
534
|
def add_trusted_proxy(proxy)
|
|
354
535
|
ensure_not_frozen!
|
|
355
|
-
# CIDR-walk and count-based depth are mutually exclusive. Catch the
|
|
356
|
-
# conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
|
|
357
|
-
# configuration time, not only at freeze (which the test harness skips).
|
|
358
|
-
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
|
|
359
|
-
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if @trust_no_proxies
|
|
360
|
-
# Same pattern for header-then-proxies; proxies-then-header is caught
|
|
361
|
-
# in #trusted_proxy_header=.
|
|
362
|
-
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE unless default_trusted_proxy_header?
|
|
363
|
-
|
|
364
|
-
# The trust-nobody sentinel is an option value, never an entry; validate
|
|
365
|
-
# the whole list before registering anything so a bad list leaves the
|
|
366
|
-
# config untouched.
|
|
367
|
-
Array(proxy).each do |entry|
|
|
368
|
-
raise ArgumentError, TRUST_NO_PROXIES_ENTRY_MESSAGE if self.class.trust_no_proxies_option?(entry)
|
|
369
|
-
end
|
|
370
536
|
|
|
371
|
-
|
|
372
|
-
when String, Regexp
|
|
373
|
-
@trusted_proxies << proxy
|
|
374
|
-
@trusted_proxy_matchers << register_proxy_matcher(proxy)
|
|
375
|
-
when Array
|
|
376
|
-
proxy.each { |entry| @trusted_proxy_matchers << register_proxy_matcher(entry) }
|
|
377
|
-
@trusted_proxies.concat(proxy)
|
|
378
|
-
else
|
|
379
|
-
raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
|
|
380
|
-
end
|
|
537
|
+
@trusted_proxy_config.add(proxy)
|
|
381
538
|
end
|
|
382
539
|
|
|
383
540
|
# Check if an IP address is from a trusted proxy
|
|
384
541
|
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
# are matched against the raw IP string.
|
|
390
|
-
#
|
|
391
|
-
# Proxy entries are parsed once at registration (see #add_trusted_proxy)
|
|
392
|
-
# into @trusted_proxy_matchers, so this never re-parses per request.
|
|
542
|
+
# IP and CIDR entries match by IPAddr containment (IPv4 and IPv6, with
|
|
543
|
+
# IPv4-mapped addresses folded), Regexp entries match the raw string, and
|
|
544
|
+
# non-IP strings fall back to legacy prefix matching. Entries are parsed
|
|
545
|
+
# once at registration. See TrustedProxyConfig#trusted?.
|
|
393
546
|
#
|
|
394
547
|
# @param ip [String] IP address to check
|
|
395
548
|
# @return [Boolean] true if the IP is from a trusted proxy
|
|
396
549
|
def trusted_proxy?(ip)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
# Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
|
|
400
|
-
# peer presented in mapped form still matches an IPv4 proxy entry.
|
|
401
|
-
client = parse_ipaddr(ip)&.native
|
|
402
|
-
|
|
403
|
-
@trusted_proxy_matchers.any? do |entry, range|
|
|
404
|
-
if range
|
|
405
|
-
# Pre-parsed IP/CIDR entry -> proper containment
|
|
406
|
-
client && ip_in_range?(range, client)
|
|
407
|
-
elsif entry.is_a?(Regexp)
|
|
408
|
-
entry.match?(ip)
|
|
409
|
-
elsif entry.is_a?(String)
|
|
410
|
-
# Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
|
|
411
|
-
ip == entry || ip.start_with?(entry)
|
|
412
|
-
else
|
|
413
|
-
false
|
|
414
|
-
end
|
|
415
|
-
end
|
|
550
|
+
@trusted_proxy_config.trusted?(ip)
|
|
416
551
|
end
|
|
417
552
|
|
|
418
553
|
# Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
|
|
@@ -425,7 +560,7 @@ class Otto
|
|
|
425
560
|
#
|
|
426
561
|
# @return [Boolean] true when at least one trusted-proxy matcher exists
|
|
427
562
|
def trusted_proxies_configured?
|
|
428
|
-
@
|
|
563
|
+
@trusted_proxy_config.filter?
|
|
429
564
|
end
|
|
430
565
|
|
|
431
566
|
# Whether ANY proxy-trust mode is configured — CIDR matchers (filter
|
|
@@ -439,7 +574,7 @@ class Otto
|
|
|
439
574
|
# @return [Boolean] true when filter or depth mode is configured, or when
|
|
440
575
|
# the operator explicitly asserted that no proxy is trusted
|
|
441
576
|
def proxy_trust_configured?
|
|
442
|
-
|
|
577
|
+
@trusted_proxy_config.configured?
|
|
443
578
|
end
|
|
444
579
|
|
|
445
580
|
# Assert that NO proxy is trusted for this application.
|
|
@@ -467,16 +602,15 @@ class Otto
|
|
|
467
602
|
# config.trust_no_proxies!
|
|
468
603
|
def trust_no_proxies!
|
|
469
604
|
ensure_not_frozen!
|
|
470
|
-
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if trusted_proxies_configured? || trusted_proxy_depth_mode?
|
|
471
605
|
|
|
472
|
-
@
|
|
606
|
+
@trusted_proxy_config.trust_none!
|
|
473
607
|
end
|
|
474
608
|
|
|
475
609
|
# Whether the operator explicitly asserted that no proxy is trusted.
|
|
476
610
|
#
|
|
477
611
|
# @return [Boolean]
|
|
478
612
|
def trust_no_proxies?
|
|
479
|
-
@
|
|
613
|
+
@trusted_proxy_config.trust_none?
|
|
480
614
|
end
|
|
481
615
|
|
|
482
616
|
# Whether this config's request handling DEPENDS on Rack's process-global
|
|
@@ -486,7 +620,7 @@ class Otto
|
|
|
486
620
|
#
|
|
487
621
|
# @return [Boolean]
|
|
488
622
|
def forwarding_family_dependent?
|
|
489
|
-
|
|
623
|
+
@trusted_proxy_config.forwarding_family_dependent?
|
|
490
624
|
end
|
|
491
625
|
|
|
492
626
|
# Whether count-based ("trust the last N hops") proxy resolution is active.
|
|
@@ -499,7 +633,7 @@ class Otto
|
|
|
499
633
|
#
|
|
500
634
|
# @return [Boolean] true when trusted_proxy_depth is an Integer >= 1
|
|
501
635
|
def trusted_proxy_depth_mode?
|
|
502
|
-
@
|
|
636
|
+
@trusted_proxy_config.depth?
|
|
503
637
|
end
|
|
504
638
|
|
|
505
639
|
# Set the count-based trusted-proxy depth ("trust the last N hops").
|
|
@@ -507,7 +641,8 @@ class Otto
|
|
|
507
641
|
# Validates eagerly so a misconfiguration fails at assignment rather than
|
|
508
642
|
# only at freeze (which the test harness skips): the value must be a
|
|
509
643
|
# non-negative Integer or nil, and the mode is mutually exclusive with
|
|
510
|
-
# CIDR-walk (trusted_proxies)
|
|
644
|
+
# CIDR-walk (trusted_proxies), with the trust-nobody assertion, and with
|
|
645
|
+
# a trusted ip_privacy geo_header. nil/0 disable depth mode.
|
|
511
646
|
#
|
|
512
647
|
# @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
|
|
513
648
|
# @raise [FrozenError] if configuration is frozen
|
|
@@ -516,14 +651,12 @@ class Otto
|
|
|
516
651
|
def trusted_proxy_depth=(depth)
|
|
517
652
|
ensure_not_frozen!
|
|
518
653
|
|
|
519
|
-
|
|
520
|
-
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
|
|
521
|
-
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if depth.to_i >= 1 && @trust_no_proxies
|
|
654
|
+
@trusted_proxy_config.check_depth!(depth)
|
|
522
655
|
# Depth-then-geo assignment order is caught by configure_ip_privacy;
|
|
523
656
|
# this catches geo-then-depth so both orders fail eagerly.
|
|
524
657
|
raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
|
|
525
658
|
|
|
526
|
-
@
|
|
659
|
+
@trusted_proxy_config.depth = depth
|
|
527
660
|
end
|
|
528
661
|
|
|
529
662
|
# Select which forwarded header family Otto and Rack read from:
|
|
@@ -556,12 +689,9 @@ class Otto
|
|
|
556
689
|
def trusted_proxy_header=(header)
|
|
557
690
|
ensure_not_frozen!
|
|
558
691
|
|
|
559
|
-
canonical_header =
|
|
560
|
-
cidr_conflict = canonical_header != DEFAULT_TRUSTED_PROXY_HEADER && @trusted_proxies.any?
|
|
561
|
-
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if cidr_conflict
|
|
562
|
-
|
|
692
|
+
canonical_header = @trusted_proxy_config.check_header!(header)
|
|
563
693
|
self.class.apply_rack_forwarding_family!(self, canonical_header)
|
|
564
|
-
@
|
|
694
|
+
@trusted_proxy_config.header = canonical_header
|
|
565
695
|
end
|
|
566
696
|
|
|
567
697
|
# Align Rack's forwarding family with this config's current value. Used by
|
|
@@ -576,7 +706,7 @@ class Otto
|
|
|
576
706
|
def apply_default_rack_forwarding_family!
|
|
577
707
|
ensure_not_frozen!
|
|
578
708
|
|
|
579
|
-
self.class.apply_rack_forwarding_family!(self,
|
|
709
|
+
self.class.apply_rack_forwarding_family!(self, trusted_proxy_header, claim: forwarding_family_dependent?)
|
|
580
710
|
end
|
|
581
711
|
|
|
582
712
|
# Commit this config's current family process-wide once it depends on
|
|
@@ -593,14 +723,14 @@ class Otto
|
|
|
593
723
|
ensure_not_frozen!
|
|
594
724
|
return unless forwarding_family_dependent?
|
|
595
725
|
|
|
596
|
-
self.class.apply_rack_forwarding_family!(self,
|
|
726
|
+
self.class.apply_rack_forwarding_family!(self, trusted_proxy_header)
|
|
597
727
|
end
|
|
598
728
|
|
|
599
729
|
# Whether trusted_proxy_header is the X-Forwarded-For default.
|
|
600
730
|
#
|
|
601
731
|
# @return [Boolean]
|
|
602
732
|
def default_trusted_proxy_header?
|
|
603
|
-
@
|
|
733
|
+
@trusted_proxy_config.default_header?
|
|
604
734
|
end
|
|
605
735
|
|
|
606
736
|
# Validate that a request size is within acceptable limits
|
|
@@ -892,10 +1022,24 @@ class Otto
|
|
|
892
1022
|
# For the turnkey setup that also injects the receiving middleware, prefer
|
|
893
1023
|
# {Otto::Security::Core#enable_csp_reporting!} on the Otto instance.
|
|
894
1024
|
#
|
|
895
|
-
#
|
|
896
|
-
#
|
|
897
|
-
#
|
|
898
|
-
#
|
|
1025
|
+
# The value is a site-absolute path: the path from the site root that the
|
|
1026
|
+
# browser POSTs to, including any prefix the app is mounted under. It is
|
|
1027
|
+
# emitted verbatim in the `report-uri` directive, which browsers resolve
|
|
1028
|
+
# against the site root, and the middleware compares it against the
|
|
1029
|
+
# request's full path (`SCRIPT_NAME` + `PATH_INFO`), so one value is right
|
|
1030
|
+
# for both. With Otto mounted by `map '/api' { run otto }`, configure
|
|
1031
|
+
# `/api/csp-report`, not `/csp-report`. Unmounted, `SCRIPT_NAME` is empty
|
|
1032
|
+
# and the full path is `PATH_INFO`.
|
|
1033
|
+
#
|
|
1034
|
+
# The comparison normalizes both sides with {Otto::Utils.normalize_path}
|
|
1035
|
+
# (percent-decoding, one trailing slash stripped), so `/_/csp-report/`
|
|
1036
|
+
# and a percent-encoded spelling of the path are also intercepted.
|
|
1037
|
+
#
|
|
1038
|
+
# @param uri [String, nil] site-absolute path browsers POST reports to
|
|
1039
|
+
# (e.g. `/_/csp-report`, or `/api/csp-report` when mounted at `/api`),
|
|
1040
|
+
# or nil to disable reporting. A value without a leading slash is
|
|
1041
|
+
# coerced to an absolute path, since browsers would otherwise resolve
|
|
1042
|
+
# it relative to the document URL.
|
|
899
1043
|
# @return [void]
|
|
900
1044
|
# @raise [FrozenError] if configuration is frozen
|
|
901
1045
|
def csp_report_uri=(uri)
|
|
@@ -920,7 +1064,8 @@ class Otto
|
|
|
920
1064
|
# The value MUST be an ABSOLUTE URL (Reporting-Endpoints does not accept a
|
|
921
1065
|
# bare path). Point it at the same receiver as {#csp_report_uri=}: its path
|
|
922
1066
|
# component should equal the report URI so {Otto::Security::CSP::ReportMiddleware}
|
|
923
|
-
# (which matches
|
|
1067
|
+
# (which matches the full request path, mount prefix included) intercepts
|
|
1068
|
+
# modern reports too.
|
|
924
1069
|
#
|
|
925
1070
|
# When nil/empty (the default), NO `report-to` directive or
|
|
926
1071
|
# `Reporting-Endpoints` header is emitted and policy output is
|
|
@@ -1023,6 +1168,24 @@ class Otto
|
|
|
1023
1168
|
@security_headers['x-frame-options'] = option
|
|
1024
1169
|
end
|
|
1025
1170
|
|
|
1171
|
+
# The Referrer-Policy value applied to Otto-generated responses.
|
|
1172
|
+
#
|
|
1173
|
+
# @return [String] one of {REFERRER_POLICIES}
|
|
1174
|
+
def referrer_policy
|
|
1175
|
+
@security_headers['referrer-policy']
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Configure the Referrer-Policy value applied to Otto responses.
|
|
1179
|
+
#
|
|
1180
|
+
# @param policy [String] one W3C Referrer Policy HTTP policy token
|
|
1181
|
+
# @return [String] the configured policy
|
|
1182
|
+
# @raise [ArgumentError] when +policy+ is not a recognized token
|
|
1183
|
+
# @raise [FrozenError] if configuration is frozen
|
|
1184
|
+
def referrer_policy=(policy)
|
|
1185
|
+
ensure_not_frozen!
|
|
1186
|
+
@security_headers['referrer-policy'] = policy
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1026
1189
|
# Set custom security headers
|
|
1027
1190
|
#
|
|
1028
1191
|
# @param headers [Hash] Hash of header name => value pairs
|
|
@@ -1049,6 +1212,7 @@ class Otto
|
|
|
1049
1212
|
def deep_freeze!
|
|
1050
1213
|
# Ensure custom_rules is initialized (should already be done in constructor)
|
|
1051
1214
|
@rate_limiting_config[:custom_rules] ||= {}
|
|
1215
|
+
validate_referrer_policy!(@security_headers['referrer-policy'])
|
|
1052
1216
|
validate_trusted_proxy_config!
|
|
1053
1217
|
validate_csrf_secret_config!
|
|
1054
1218
|
super
|
|
@@ -1075,86 +1239,21 @@ class Otto
|
|
|
1075
1239
|
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
1076
1240
|
end
|
|
1077
1241
|
|
|
1078
|
-
# Validate a candidate trusted_proxy_depth value (type and range).
|
|
1079
|
-
#
|
|
1080
|
-
# Shared by the eager #trusted_proxy_depth= setter and the freeze-time
|
|
1081
|
-
# backstop so an invalid value raises a clear ArgumentError instead of a
|
|
1082
|
-
# downstream NoMethodError from #to_i coercion. nil disables depth mode.
|
|
1083
|
-
#
|
|
1084
|
-
# @param depth [Object] candidate value
|
|
1085
|
-
# @raise [ArgumentError] if depth is non-nil and not a non-negative Integer
|
|
1086
|
-
# @return [void]
|
|
1087
|
-
def validate_trusted_proxy_depth!(depth)
|
|
1088
|
-
return if depth.nil?
|
|
1089
|
-
|
|
1090
|
-
unless depth.is_a?(Integer)
|
|
1091
|
-
raise ArgumentError,
|
|
1092
|
-
"trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
|
|
1093
|
-
end
|
|
1094
|
-
|
|
1095
|
-
raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
|
|
1096
|
-
end
|
|
1097
|
-
|
|
1098
|
-
# Canonicalize a candidate trusted_proxy_header value: match it
|
|
1099
|
-
# case-insensitively (ignoring surrounding whitespace) against the
|
|
1100
|
-
# recognized set and return the canonical spelling. Liberal in the spelling
|
|
1101
|
-
# it accepts (e.g. 'forwarded' => 'Forwarded') but fail-loud on a genuinely
|
|
1102
|
-
# unrecognized value, so a typo is caught at config time rather than
|
|
1103
|
-
# silently resolving the client IP from the wrong header.
|
|
1104
|
-
#
|
|
1105
|
-
# @param header [Object] candidate value
|
|
1106
|
-
# @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
|
|
1107
|
-
# @return [String] the canonical header value
|
|
1108
|
-
def canonicalize_trusted_proxy_header(header)
|
|
1109
|
-
candidate = header.to_s.strip
|
|
1110
|
-
canonical = TRUSTED_PROXY_HEADERS.find { |allowed| allowed.casecmp?(candidate) }
|
|
1111
|
-
return canonical if canonical
|
|
1112
|
-
|
|
1113
|
-
raise ArgumentError,
|
|
1114
|
-
"trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
|
|
1115
|
-
end
|
|
1116
|
-
|
|
1117
|
-
# Strictly validate a stored trusted_proxy_header value against the allowed
|
|
1118
|
-
# set. The eager #trusted_proxy_header= setter already canonicalizes, so by
|
|
1119
|
-
# freeze time the value is canonical; this freeze-time backstop catches a
|
|
1120
|
-
# value smuggled in through a direct-ivar path that bypassed the setter,
|
|
1121
|
-
# failing loud rather than silently mis-resolving the client IP at request
|
|
1122
|
-
# time.
|
|
1123
|
-
#
|
|
1124
|
-
# @param header [Object] candidate value
|
|
1125
|
-
# @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
|
|
1126
|
-
# @return [void]
|
|
1127
|
-
def validate_trusted_proxy_header!(header)
|
|
1128
|
-
return if TRUSTED_PROXY_HEADERS.include?(header)
|
|
1129
|
-
|
|
1130
|
-
raise ArgumentError,
|
|
1131
|
-
"trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
1242
|
# Validate trusted-proxy configuration coherence at freeze time.
|
|
1135
1243
|
#
|
|
1136
|
-
#
|
|
1137
|
-
#
|
|
1138
|
-
#
|
|
1139
|
-
#
|
|
1244
|
+
# TrustedProxyConfig#validate! re-checks its own rules (header value,
|
|
1245
|
+
# depth type, mode exclusivity) as a backstop for state that bypassed the
|
|
1246
|
+
# eager setters; the geo_header rule spans two sub-configs, so it is
|
|
1247
|
+
# checked here.
|
|
1140
1248
|
#
|
|
1141
|
-
# @raise [ArgumentError] if
|
|
1142
|
-
# trusted_proxies and a depth >= 1 are configured
|
|
1249
|
+
# @raise [ArgumentError] if any trusted-proxy rule is violated
|
|
1143
1250
|
# @return [void]
|
|
1144
1251
|
def validate_trusted_proxy_config!
|
|
1145
|
-
|
|
1146
|
-
validate_trusted_proxy_depth!(@trusted_proxy_depth)
|
|
1147
|
-
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if !default_trusted_proxy_header? && @trusted_proxies.any?
|
|
1148
|
-
|
|
1149
|
-
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if @trust_no_proxies && (@trusted_proxies.any? || @trusted_proxy_depth.to_i >= 1)
|
|
1150
|
-
|
|
1151
|
-
if @trusted_proxy_depth
|
|
1152
|
-
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
|
|
1252
|
+
@trusted_proxy_config.validate!
|
|
1153
1253
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
end
|
|
1254
|
+
# Backstop for the direct path (ip_privacy_config.geo_header=) that
|
|
1255
|
+
# bypasses both eager checks; the setters cover the common orders.
|
|
1256
|
+
raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if trusted_proxy_depth_mode? && @ip_privacy_config&.geo_header
|
|
1158
1257
|
|
|
1159
1258
|
# Last, so a config that fails the checks above never registers as an
|
|
1160
1259
|
# owner: late-configured proxy trust (after Otto.new) commits here at
|
|
@@ -1162,67 +1261,6 @@ class Otto
|
|
|
1162
1261
|
commit_rack_forwarding_family!
|
|
1163
1262
|
end
|
|
1164
1263
|
|
|
1165
|
-
# Parse a value into an IPAddr, returning nil for invalid / non-IP input.
|
|
1166
|
-
#
|
|
1167
|
-
# @param value [String] candidate IP or CIDR string
|
|
1168
|
-
# @return [IPAddr, nil]
|
|
1169
|
-
def parse_ipaddr(value)
|
|
1170
|
-
IPAddr.new(value)
|
|
1171
|
-
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
1172
|
-
nil
|
|
1173
|
-
end
|
|
1174
|
-
|
|
1175
|
-
# Build a cached matcher tuple for a proxy entry at registration time.
|
|
1176
|
-
#
|
|
1177
|
-
# String entries are parsed to an IPAddr exactly once here; the result is
|
|
1178
|
-
# reused for both the legacy-entry warning and per-request matching, so
|
|
1179
|
-
# trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
|
|
1180
|
-
# store a nil range and fall back to prefix/regexp matching.
|
|
1181
|
-
#
|
|
1182
|
-
# The parsed range is folded through IPAddr#native at registration, to
|
|
1183
|
-
# match the fold trusted_proxy? applies to the client address. Without
|
|
1184
|
-
# it a mapped-IPv6 proxy entry (::ffff:10.0.0.0/104) could never match,
|
|
1185
|
-
# because ip_in_range?'s family check would reject the folded IPv4
|
|
1186
|
-
# client — a proxy silently untrusted, which is what gates
|
|
1187
|
-
# otto.via_trusted_proxy, secure?, and geo-header trust. #native returns
|
|
1188
|
-
# self for entries that are not IPv4-mapped/compatible.
|
|
1189
|
-
#
|
|
1190
|
-
# @param entry [String, Regexp, Object] trusted proxy entry being added
|
|
1191
|
-
# @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
|
|
1192
|
-
def register_proxy_matcher(entry)
|
|
1193
|
-
return [entry, nil] unless entry.is_a?(String)
|
|
1194
|
-
|
|
1195
|
-
range = parse_ipaddr(entry)&.native
|
|
1196
|
-
warn_legacy_proxy_entry(entry) unless range
|
|
1197
|
-
[entry, range]
|
|
1198
|
-
end
|
|
1199
|
-
|
|
1200
|
-
# Warn that a string proxy entry is not a valid IP/CIDR and will use
|
|
1201
|
-
# legacy string-prefix matching.
|
|
1202
|
-
#
|
|
1203
|
-
# @param entry [String] trusted proxy entry
|
|
1204
|
-
# @return [void]
|
|
1205
|
-
def warn_legacy_proxy_entry(entry)
|
|
1206
|
-
Otto.logger.warn(
|
|
1207
|
-
"[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
|
|
1208
|
-
'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
|
|
1209
|
-
"CIDR range (e.g. '172.16.0.0/12')."
|
|
1210
|
-
)
|
|
1211
|
-
end
|
|
1212
|
-
|
|
1213
|
-
# CIDR/host containment that is safe across address families.
|
|
1214
|
-
#
|
|
1215
|
-
# @param range [IPAddr] trusted proxy range or host
|
|
1216
|
-
# @param client [IPAddr] client address
|
|
1217
|
-
# @return [Boolean]
|
|
1218
|
-
def ip_in_range?(range, client)
|
|
1219
|
-
return false unless range.family == client.family
|
|
1220
|
-
|
|
1221
|
-
range.include?(client)
|
|
1222
|
-
rescue IPAddr::InvalidAddressError
|
|
1223
|
-
false
|
|
1224
|
-
end
|
|
1225
|
-
|
|
1226
1264
|
def extract_existing_session_id(request)
|
|
1227
1265
|
# Try session first
|
|
1228
1266
|
begin
|
|
@@ -1265,10 +1303,17 @@ class Otto
|
|
|
1265
1303
|
{
|
|
1266
1304
|
'x-content-type-options' => 'nosniff',
|
|
1267
1305
|
'x-xss-protection' => '1; mode=block',
|
|
1268
|
-
'referrer-policy' =>
|
|
1306
|
+
'referrer-policy' => DEFAULT_REFERRER_POLICY,
|
|
1269
1307
|
}
|
|
1270
1308
|
end
|
|
1271
1309
|
|
|
1310
|
+
def validate_referrer_policy!(policy)
|
|
1311
|
+
return policy if policy.is_a?(String) && REFERRER_POLICIES.include?(policy)
|
|
1312
|
+
|
|
1313
|
+
raise ArgumentError,
|
|
1314
|
+
"Invalid referrer_policy #{policy.inspect}; expected one of: #{REFERRER_POLICIES.join(', ')}"
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1272
1317
|
# Perform constant-time string comparison to prevent timing attacks
|
|
1273
1318
|
#
|
|
1274
1319
|
# This method compares two strings in constant time regardless of where
|
|
@@ -1374,13 +1419,14 @@ class Otto
|
|
|
1374
1419
|
stripped.empty? ? nil : stripped
|
|
1375
1420
|
end
|
|
1376
1421
|
|
|
1377
|
-
# Normalize a configured report PATH: the
|
|
1378
|
-
# matches
|
|
1379
|
-
# {#normalize_report_uri}, but a bare
|
|
1380
|
-
# absolute path — a value like
|
|
1381
|
-
# equal the slash-prefixed
|
|
1382
|
-
# (b) be resolved by browsers
|
|
1383
|
-
# (contains a scheme) is left
|
|
1422
|
+
# Normalize a configured report PATH: the site-absolute endpoint the
|
|
1423
|
+
# receiver matches against `SCRIPT_NAME` + `PATH_INFO`. Same
|
|
1424
|
+
# strip/blank-to-nil handling as {#normalize_report_uri}, but a bare
|
|
1425
|
+
# relative value is coerced to an absolute path — a value like
|
|
1426
|
+
# `"csp-report"` would otherwise (a) never equal the slash-prefixed request
|
|
1427
|
+
# path the middleware compares against, and (b) be resolved by browsers
|
|
1428
|
+
# relative to the document URL. An absolute URL (contains a scheme) is left
|
|
1429
|
+
# untouched.
|
|
1384
1430
|
#
|
|
1385
1431
|
# @param uri [String, nil]
|
|
1386
1432
|
# @return [String, nil]
|