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.
@@ -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
- # Error raised when the two mutually-exclusive trusted-proxy resolution
34
- # modes are configured together: CIDR-walk (enumerated #trusted_proxies)
35
- # and count-based depth (#trusted_proxy_depth >= 1).
36
- PROXY_MODE_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
37
- Cannot configure both trusted_proxies (CIDR filter mode) and
38
- trusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a
39
- hop count, not both.
40
- MSG
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
- # Error raised when the explicit "trust no proxy" assertion
60
- # (#trust_no_proxies!, `trusted_proxies: :none`) is combined with an
61
- # actual trust grant (enumerated CIDRs or a depth >= 1). The two say
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
- (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.casecmp?('none')
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
- MSG
124
- # Error raised when a non-default trusted_proxy_header is combined with
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
- raise 'reset_rack_forwarding_family_for_testing! is only available in RSpec test environment' unless defined?(RSpec)
217
-
218
- RACK_FORWARDING_MUTEX.synchronize do
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
- :trusted_proxies, :require_secure_cookies,
402
+ :require_secure_cookies,
257
403
  :security_headers,
258
404
  :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
259
- :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
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
- @trusted_proxies = []
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 = default_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
- # @param proxy [String, Array] IP address, CIDR range, or array of addresses
341
- # @raise [ArgumentError] if proxy is not a String or Array
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
- case proxy
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
- # String entries that parse as an IP or CIDR range are matched with
386
- # proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
387
- # IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
388
- # exact/prefix string match for backward compatibility. Regexp entries
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
- return false if @trusted_proxy_matchers.empty? || ip.nil? || ip.empty?
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
- @trusted_proxy_matchers.any?
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
- trusted_proxies_configured? || trusted_proxy_depth_mode? || trust_no_proxies?
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
- @trust_no_proxies = true
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
- @trust_no_proxies
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
- trusted_proxies_configured? || trusted_proxy_depth_mode?
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
- @trusted_proxy_depth.is_a?(Integer) && @trusted_proxy_depth >= 1
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). nil/0 disable depth mode.
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
- validate_trusted_proxy_depth!(depth)
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
- @trusted_proxy_depth = depth
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 = canonicalize_trusted_proxy_header(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
- @trusted_proxy_header = canonical_header
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, @trusted_proxy_header, claim: forwarding_family_dependent?)
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, @trusted_proxy_header)
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
- @trusted_proxy_header == DEFAULT_TRUSTED_PROXY_HEADER
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
- # @param uri [String, nil] path browsers POST reports to (matched against
896
- # `PATH_INFO`, e.g. `/_/csp-report`), or nil to disable reporting. A
897
- # value without a leading slash is coerced to an absolute path so it
898
- # matches the slash-prefixed `PATH_INFO` the middleware compares against.
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 on PATH_INFO) intercepts modern reports too.
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
- # The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
1137
- # reject invalid types and the mutually-exclusive CIDR-walk vs depth
1138
- # combination at assignment. This re-checks at finalization as a backstop
1139
- # for a direct/ivar configuration path that bypassed the setters.
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 depth is non-integer/negative, or if both
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
- validate_trusted_proxy_header!(@trusted_proxy_header)
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
- # Backstop for the direct path (ip_privacy_config.geo_header=) that
1155
- # bypasses both eager checks; the setters cover the common orders.
1156
- raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
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' => 'strict-origin-when-cross-origin',
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 local endpoint the receiver
1378
- # matches on `PATH_INFO`. Same strip/blank-to-nil handling as
1379
- # {#normalize_report_uri}, but a bare relative value is coerced to an
1380
- # absolute path — a value like `"csp-report"` would otherwise (a) never
1381
- # equal the slash-prefixed `PATH_INFO` the middleware compares against, and
1382
- # (b) be resolved by browsers relative to the document URL. An absolute URL
1383
- # (contains a scheme) is left untouched.
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]