otto 2.2.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.
@@ -4,6 +4,8 @@
4
4
 
5
5
  require 'securerandom'
6
6
  require 'digest'
7
+ require 'openssl'
8
+ require 'ipaddr'
7
9
  require_relative '../core/freezable'
8
10
 
9
11
  class Otto
@@ -26,13 +28,42 @@ class Otto
26
28
  class Config
27
29
  include Otto::Core::Freezable
28
30
 
29
- attr_accessor :input_validation, :max_param_depth, :csrf_token_key, :rate_limiting_config, :csrf_session_key, :max_request_size, :max_param_keys
31
+ # Error raised when the two mutually-exclusive trusted-proxy resolution
32
+ # modes are configured together: CIDR-walk (enumerated #trusted_proxies)
33
+ # and count-based depth (#trusted_proxy_depth >= 1).
34
+ PROXY_MODE_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
35
+ Cannot configure both trusted_proxies (CIDR filter mode) and
36
+ trusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a
37
+ hop count, not both.
38
+ MSG
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
+
47
+ # Error raised when CSRF protection is enabled in production without an
48
+ # explicitly configured secret. A randomly-generated per-process secret
49
+ # silently breaks token verification across workers and restarts, so we
50
+ # refuse it in production rather than serve intermittently-failing tokens.
51
+ CSRF_SECRET_REQUIRED_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
52
+ CSRF protection is enabled in production without a configured secret.
53
+ Set OTTO_CSRF_SECRET (or config.csrf_secret=) to a stable random value
54
+ (e.g. SecureRandom.hex(32)); a per-process random secret is not valid
55
+ across workers or restarts.
56
+ MSG
57
+
58
+ attr_accessor :input_validation, :max_param_depth, :csrf_token_key,
59
+ :rate_limiting_config, :csrf_session_key, :max_request_size,
60
+ :max_param_keys
30
61
 
31
62
  attr_reader :csrf_protection, :csrf_header_key,
32
63
  :trusted_proxies, :require_secure_cookies,
33
64
  :security_headers,
34
65
  :csp_nonce_enabled, :debug_csp, :mcp_auth,
35
- :ip_privacy_config
66
+ :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header
36
67
 
37
68
  # Initialize security configuration with safe defaults
38
69
  #
@@ -47,6 +78,9 @@ class Otto
47
78
  @max_param_depth = 32
48
79
  @max_param_keys = 64
49
80
  @trusted_proxies = []
81
+ @trusted_proxy_matchers = []
82
+ @trusted_proxy_depth = nil
83
+ @trusted_proxy_header = 'X-Forwarded-For'
50
84
  @require_secure_cookies = false
51
85
  @security_headers = default_security_headers
52
86
  @input_validation = true
@@ -54,6 +88,10 @@ class Otto
54
88
  @debug_csp = false
55
89
  @rate_limiting_config = { custom_rules: {} }
56
90
  @ip_privacy_config = Otto::Privacy::Config.new
91
+
92
+ configured_secret = ENV.fetch('OTTO_CSRF_SECRET', nil)
93
+ @csrf_secret_generated = configured_secret.nil? || configured_secret.empty?
94
+ @csrf_secret = @csrf_secret_generated ? SecureRandom.hex(32) : configured_secret
57
95
  end
58
96
 
59
97
  # Enable CSRF (Cross-Site Request Forgery) protection
@@ -67,7 +105,7 @@ class Otto
67
105
  # @return [void]
68
106
  # @raise [FrozenError] if configuration is frozen
69
107
  def enable_csrf_protection!
70
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
108
+ ensure_not_frozen!
71
109
 
72
110
  @csrf_protection = true
73
111
  end
@@ -77,7 +115,7 @@ class Otto
77
115
  # @return [void]
78
116
  # @raise [FrozenError] if configuration is frozen
79
117
  def disable_csrf_protection!
80
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
118
+ ensure_not_frozen!
81
119
 
82
120
  @csrf_protection = false
83
121
  end
@@ -109,12 +147,18 @@ class Otto
109
147
  # @example Add multiple proxies
110
148
  # config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
111
149
  def add_trusted_proxy(proxy)
112
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
150
+ ensure_not_frozen!
151
+ # CIDR-walk and count-based depth are mutually exclusive. Catch the
152
+ # conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
153
+ # configuration time, not only at freeze (which the test harness skips).
154
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
113
155
 
114
156
  case proxy
115
157
  when String, Regexp
116
158
  @trusted_proxies << proxy
159
+ @trusted_proxy_matchers << register_proxy_matcher(proxy)
117
160
  when Array
161
+ proxy.each { |entry| @trusted_proxy_matchers << register_proxy_matcher(entry) }
118
162
  @trusted_proxies.concat(proxy)
119
163
  else
120
164
  raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
@@ -123,23 +167,93 @@ class Otto
123
167
 
124
168
  # Check if an IP address is from a trusted proxy
125
169
  #
170
+ # String entries that parse as an IP or CIDR range are matched with
171
+ # proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
172
+ # IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
173
+ # exact/prefix string match for backward compatibility. Regexp entries
174
+ # are matched against the raw IP string.
175
+ #
176
+ # Proxy entries are parsed once at registration (see #add_trusted_proxy)
177
+ # into @trusted_proxy_matchers, so this never re-parses per request.
178
+ #
126
179
  # @param ip [String] IP address to check
127
180
  # @return [Boolean] true if the IP is from a trusted proxy
128
181
  def trusted_proxy?(ip)
129
- return false if @trusted_proxies.empty?
130
-
131
- @trusted_proxies.any? do |proxy|
132
- case proxy
133
- when String
134
- ip == proxy || ip.start_with?(proxy)
135
- when Regexp
136
- proxy.match?(ip)
182
+ return false if @trusted_proxy_matchers.empty? || ip.nil? || ip.empty?
183
+
184
+ # Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
185
+ # peer presented in mapped form still matches an IPv4 proxy entry.
186
+ client = parse_ipaddr(ip)&.native
187
+
188
+ @trusted_proxy_matchers.any? do |entry, range|
189
+ if range
190
+ # Pre-parsed IP/CIDR entry -> proper containment
191
+ client && ip_in_range?(range, client)
192
+ elsif entry.is_a?(Regexp)
193
+ entry.match?(ip)
194
+ elsif entry.is_a?(String)
195
+ # Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
196
+ ip == entry || ip.start_with?(entry)
137
197
  else
138
198
  false
139
199
  end
140
200
  end
141
201
  end
142
202
 
203
+ # Whether count-based ("trust the last N hops") proxy resolution is active.
204
+ #
205
+ # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
206
+ # instead trusts a fixed number of hops from the right of the forwarded
207
+ # chain (Express `trust proxy = N`). This is the only sound model for
208
+ # non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
209
+ # proxies) whose addresses cannot be listed as CIDRs.
210
+ #
211
+ # @return [Boolean] true when trusted_proxy_depth is an Integer >= 1
212
+ def trusted_proxy_depth_mode?
213
+ @trusted_proxy_depth.is_a?(Integer) && @trusted_proxy_depth >= 1
214
+ end
215
+
216
+ # Set the count-based trusted-proxy depth ("trust the last N hops").
217
+ #
218
+ # Validates eagerly so a misconfiguration fails at assignment rather than
219
+ # only at freeze (which the test harness skips): the value must be a
220
+ # non-negative Integer or nil, and the mode is mutually exclusive with
221
+ # CIDR-walk (trusted_proxies). nil/0 disable depth mode.
222
+ #
223
+ # @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
224
+ # @raise [FrozenError] if configuration is frozen
225
+ # @raise [ArgumentError] if depth is non-integer/negative, or if
226
+ # trusted_proxies are already configured and depth >= 1
227
+ def trusted_proxy_depth=(depth)
228
+ ensure_not_frozen!
229
+
230
+ validate_trusted_proxy_depth!(depth)
231
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
232
+
233
+ @trusted_proxy_depth = depth
234
+ end
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
+
143
257
  # Validate that a request size is within acceptable limits
144
258
  #
145
259
  # @param content_length [String, Integer, nil] Content-Length header value
@@ -156,28 +270,45 @@ class Otto
156
270
  true
157
271
  end
158
272
 
273
+ # Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to
274
+ # a stable value (e.g. ENV['OTTO_CSRF_SECRET']) in multi-process or
275
+ # multi-host deployments so tokens stay valid across workers and restarts.
276
+ #
277
+ # Write-only by design: the signing key has no public reader, so it is not
278
+ # exposed to inspection/logging/serialization via the config object.
279
+ def csrf_secret=(secret)
280
+ ensure_not_frozen!
281
+
282
+ @csrf_secret = secret
283
+ @csrf_secret_generated = false
284
+ end
285
+
286
+ # Generate a CSRF token bound to the given session id and signed (HMAC-SHA256)
287
+ # with the server-side secret, so tokens cannot be self-minted and are not
288
+ # valid across sessions. A session binding is REQUIRED.
159
289
  def generate_csrf_token(session_id = nil)
160
- base = session_id || 'no-session'
161
- token = SecureRandom.hex(32)
162
- hash_input = base + ':' + token
163
- signature = Digest::SHA256.hexdigest(hash_input)
164
- csrf_token = "#{token}:#{signature}"
290
+ binding_id = session_id.to_s
291
+ raise ArgumentError, 'CSRF token generation requires a session binding' if binding_id.empty?
165
292
 
166
- csrf_token
293
+ reject_generated_secret_in_production!
294
+ warn_generated_csrf_secret
295
+ token = SecureRandom.hex(32)
296
+ "#{token}:#{sign_csrf_token(binding_id, token)}"
167
297
  end
168
298
 
299
+ # Verify a CSRF token against its session binding using a constant-time
300
+ # comparison. Returns false (never raises) for blank/malformed input.
169
301
  def verify_csrf_token(token, session_id = nil)
170
302
  return false if token.nil? || token.empty?
171
303
 
172
- token_part, signature = token.split(':')
173
- return false if token_part.nil? || signature.nil?
304
+ binding_id = session_id.to_s
305
+ return false if binding_id.empty?
174
306
 
175
- base = session_id || 'no-session'
176
- hash_input = "#{base}:#{token_part}"
177
- expected_signature = Digest::SHA256.hexdigest(hash_input)
178
- comparison_result = secure_compare(signature, expected_signature)
307
+ token_part, signature = token.split(':', 2)
308
+ return false if token_part.nil? || signature.nil?
179
309
 
180
- comparison_result
310
+ expected_signature = sign_csrf_token(binding_id, token_part)
311
+ secure_compare(signature, expected_signature)
181
312
  end
182
313
 
183
314
  # Enable HTTP Strict Transport Security (HSTS) header
@@ -191,7 +322,7 @@ class Otto
191
322
  # @return [void]
192
323
  # @raise [FrozenError] if configuration is frozen
193
324
  def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
194
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
325
+ ensure_not_frozen!
195
326
 
196
327
  hsts_value = "max-age=#{max_age}"
197
328
  hsts_value += '; includeSubDomains' if include_subdomains
@@ -210,7 +341,7 @@ class Otto
210
341
  # @example Custom policy
211
342
  # config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
212
343
  def enable_csp!(policy = "default-src 'self'")
213
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
344
+ ensure_not_frozen!
214
345
 
215
346
  @security_headers['content-security-policy'] = policy
216
347
  end
@@ -228,7 +359,7 @@ class Otto
228
359
  # @example
229
360
  # config.enable_csp_with_nonce!(debug: true)
230
361
  def enable_csp_with_nonce!(debug: false)
231
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
362
+ ensure_not_frozen!
232
363
 
233
364
  @csp_nonce_enabled = true
234
365
  @debug_csp = debug
@@ -239,7 +370,7 @@ class Otto
239
370
  # @return [void]
240
371
  # @raise [FrozenError] if configuration is frozen
241
372
  def disable_csp_nonce!
242
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
373
+ ensure_not_frozen!
243
374
 
244
375
  @csp_nonce_enabled = false
245
376
  end
@@ -274,7 +405,7 @@ class Otto
274
405
  # @return [void]
275
406
  # @raise [FrozenError] if configuration is frozen
276
407
  def enable_frame_protection!(option = 'SAMEORIGIN')
277
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
408
+ ensure_not_frozen!
278
409
 
279
410
  @security_headers['x-frame-options'] = option
280
411
  end
@@ -291,7 +422,7 @@ class Otto
291
422
  # 'cross-origin-opener-policy' => 'same-origin'
292
423
  # })
293
424
  def set_custom_headers(headers)
294
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
425
+ ensure_not_frozen!
295
426
 
296
427
  @security_headers.merge!(headers)
297
428
  end
@@ -305,6 +436,8 @@ class Otto
305
436
  def deep_freeze!
306
437
  # Ensure custom_rules is initialized (should already be done in constructor)
307
438
  @rate_limiting_config[:custom_rules] ||= {}
439
+ validate_trusted_proxy_config!
440
+ validate_csrf_secret_config!
308
441
  super
309
442
  end
310
443
 
@@ -323,6 +456,139 @@ class Otto
323
456
 
324
457
  private
325
458
 
459
+ # Guard for mutators: refuse changes once the configuration is frozen.
460
+ # Centralizes the repeated frozen-check so every setter shares one message.
461
+ def ensure_not_frozen!
462
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
463
+ end
464
+
465
+ # Validate a candidate trusted_proxy_depth value (type and range).
466
+ #
467
+ # Shared by the eager #trusted_proxy_depth= setter and the freeze-time
468
+ # backstop so an invalid value raises a clear ArgumentError instead of a
469
+ # downstream NoMethodError from #to_i coercion. nil disables depth mode.
470
+ #
471
+ # @param depth [Object] candidate value
472
+ # @raise [ArgumentError] if depth is non-nil and not a non-negative Integer
473
+ # @return [void]
474
+ def validate_trusted_proxy_depth!(depth)
475
+ return if depth.nil?
476
+
477
+ unless depth.is_a?(Integer)
478
+ raise ArgumentError,
479
+ "trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
480
+ end
481
+
482
+ raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
483
+ end
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
+
521
+ # Validate trusted-proxy configuration coherence at freeze time.
522
+ #
523
+ # The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
524
+ # reject invalid types and the mutually-exclusive CIDR-walk vs depth
525
+ # combination at assignment. This re-checks at finalization as a backstop
526
+ # for a direct/ivar configuration path that bypassed the setters.
527
+ #
528
+ # @raise [ArgumentError] if depth is non-integer/negative, or if both
529
+ # trusted_proxies and a depth >= 1 are configured
530
+ # @return [void]
531
+ def validate_trusted_proxy_config!
532
+ validate_trusted_proxy_header!(@trusted_proxy_header)
533
+ validate_trusted_proxy_depth!(@trusted_proxy_depth)
534
+ return if @trusted_proxy_depth.nil?
535
+
536
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
537
+ end
538
+
539
+ # Parse a value into an IPAddr, returning nil for invalid / non-IP input.
540
+ #
541
+ # @param value [String] candidate IP or CIDR string
542
+ # @return [IPAddr, nil]
543
+ def parse_ipaddr(value)
544
+ IPAddr.new(value)
545
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
546
+ nil
547
+ end
548
+
549
+ # Build a cached matcher tuple for a proxy entry at registration time.
550
+ #
551
+ # String entries are parsed to an IPAddr exactly once here; the result is
552
+ # reused for both the legacy-entry warning and per-request matching, so
553
+ # trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
554
+ # store a nil range and fall back to prefix/regexp matching.
555
+ #
556
+ # @param entry [String, Regexp, Object] trusted proxy entry being added
557
+ # @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
558
+ def register_proxy_matcher(entry)
559
+ return [entry, nil] unless entry.is_a?(String)
560
+
561
+ range = parse_ipaddr(entry)
562
+ warn_legacy_proxy_entry(entry) unless range
563
+ [entry, range]
564
+ end
565
+
566
+ # Warn that a string proxy entry is not a valid IP/CIDR and will use
567
+ # legacy string-prefix matching.
568
+ #
569
+ # @param entry [String] trusted proxy entry
570
+ # @return [void]
571
+ def warn_legacy_proxy_entry(entry)
572
+ Otto.logger.warn(
573
+ "[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
574
+ 'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
575
+ "CIDR range (e.g. '172.16.0.0/12')."
576
+ )
577
+ end
578
+
579
+ # CIDR/host containment that is safe across address families.
580
+ #
581
+ # @param range [IPAddr] trusted proxy range or host
582
+ # @param client [IPAddr] client address
583
+ # @return [Boolean]
584
+ def ip_in_range?(range, client)
585
+ return false unless range.family == client.family
586
+
587
+ range.include?(client)
588
+ rescue IPAddr::InvalidAddressError
589
+ false
590
+ end
591
+
326
592
  def extract_existing_session_id(request)
327
593
  # Try session first
328
594
  begin
@@ -386,6 +652,59 @@ class Otto
386
652
  result == 0
387
653
  end
388
654
 
655
+ # HMAC-SHA256 signature binding a token's random component to a session id
656
+ # and the server-side secret. Keyed HMAC (not a bare digest) is what prevents
657
+ # token self-minting.
658
+ def sign_csrf_token(session_id, token)
659
+ OpenSSL::HMAC.hexdigest('SHA256', @csrf_secret, "#{session_id}:#{token}")
660
+ end
661
+
662
+ # Warn once per config instance when CSRF tokens are being signed with a
663
+ # randomly-generated per-process secret. Such tokens do not survive process
664
+ # restarts and are not shared across workers; set OTTO_CSRF_SECRET (or
665
+ # config.csrf_secret=) for stable multi-process behavior.
666
+ def warn_generated_csrf_secret
667
+ return unless @csrf_secret_generated
668
+ return if @csrf_secret_warning_emitted
669
+
670
+ @csrf_secret_warning_emitted = true
671
+ Otto.logger.warn(<<~MSG.gsub(/\s+/, ' ').strip)
672
+ [Otto::Security::Config] CSRF tokens are signed with a randomly
673
+ generated per-process secret; they will not survive restarts or be
674
+ valid across workers. Set OTTO_CSRF_SECRET (or config.csrf_secret=)
675
+ for stable CSRF tokens in multi-process deployments.
676
+ MSG
677
+ end
678
+
679
+ # Freeze-time backstop: refuse to finalize a production configuration that
680
+ # enables CSRF with a generated (non-configured) secret. Mirrors
681
+ # #validate_trusted_proxy_config! so the failure surfaces at boot, before
682
+ # serving traffic, for apps that deep-freeze their config.
683
+ def validate_csrf_secret_config!
684
+ raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
685
+ end
686
+
687
+ # Generation-time guard for apps that never freeze their config: never
688
+ # mint a CSRF token signed with a generated per-process secret in
689
+ # production (fail loud instead of serving tokens that won't verify).
690
+ def reject_generated_secret_in_production!
691
+ raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
692
+ end
693
+
694
+ # True when CSRF is enabled, the secret was randomly generated (not
695
+ # configured via OTTO_CSRF_SECRET / #csrf_secret=), and we are running in
696
+ # a production environment.
697
+ def csrf_secret_unsafe_for_production?
698
+ @csrf_protection && @csrf_secret_generated && production_environment?
699
+ end
700
+
701
+ # Whether RACK_ENV indicates a production deployment. Gated to an explicit
702
+ # allowlist (not "anything but dev") so test/unknown environments keep the
703
+ # zero-config generated-secret fallback.
704
+ def production_environment?
705
+ defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
706
+ end
707
+
389
708
  # Generate CSP directives for development environment
390
709
  #
391
710
  # Development mode allows inline scripts/styles and hot reloading connections
@@ -36,6 +36,12 @@ class Otto
36
36
  # - `true`: Enable with default settings
37
37
  # - `Hash`: Provide custom rate limiting rules
38
38
  # @param trusted_proxies [String, Array<String>] IP addresses or CIDR ranges to trust
39
+ # @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
40
+ # the last N hops") for non-enumerable proxy tiers; mutually exclusive
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.
39
45
  # @param security_headers [Hash] Custom security headers to merge with defaults
40
46
  # @param hsts [Boolean] Enable HTTP Strict Transport Security
41
47
  # @param csp [Boolean, String] Enable Content Security Policy
@@ -58,6 +64,8 @@ class Otto
58
64
  request_validation: false,
59
65
  rate_limiting: false,
60
66
  trusted_proxies: [],
67
+ trusted_proxy_depth: nil,
68
+ trusted_proxy_header: nil,
61
69
  security_headers: {},
62
70
  hsts: false,
63
71
  csp: false,
@@ -69,6 +77,8 @@ class Otto
69
77
  enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
70
78
 
71
79
  Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
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?
72
82
  self.security_headers = security_headers unless security_headers.empty?
73
83
 
74
84
  enable_hsts! if hsts
@@ -127,6 +137,26 @@ class Otto
127
137
  @security_config.add_trusted_proxy(proxy)
128
138
  end
129
139
 
140
+ # Set count-based trusted-proxy depth ("trust the last N hops") for
141
+ # non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
142
+ # proxies). Mutually exclusive with trusted_proxies; the conflict is
143
+ # validated when the configuration is frozen.
144
+ #
145
+ # @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
146
+ def trusted_proxy_depth=(depth)
147
+ @security_config.trusted_proxy_depth = depth
148
+ end
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
+
130
160
  # Set custom security headers that will be added to all responses.
131
161
  # These merge with the default security headers.
132
162
  #
@@ -0,0 +1,73 @@
1
+ # lib/otto/security/constant_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ # Shared, validated resolution of a class from its String name.
8
+ #
9
+ # Centralizes the class-name format check and the forbidden-class blocklist
10
+ # so every dispatch path that turns a route/handler string into a constant
11
+ # (Otto::Route, RouteHandlers::BaseHandler, and the MCP registry/server)
12
+ # enforces the SAME guards against code-injection via crafted class names.
13
+ module ConstantResolver
14
+ # A class name is a sequence of ::-separated, capitalized Ruby constant
15
+ # tokens. This also rejects leading "::" (a name must start with [A-Z]).
16
+ CLASS_NAME_PATTERN = /\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/
17
+
18
+ # Constants that must never be resolvable from untrusted route/handler
19
+ # strings, since dispatching to them enables arbitrary/dangerous behavior.
20
+ FORBIDDEN_CLASSES = %w[
21
+ Kernel Module Class Object BasicObject
22
+ File Dir IO Process System
23
+ Binding Proc Method UnboundMethod
24
+ Thread ThreadGroup Fiber
25
+ ObjectSpace GC
26
+ ].freeze
27
+
28
+ # The actual constant objects behind FORBIDDEN_CLASSES that exist in this
29
+ # runtime. The resolved constant is checked against these by identity so a
30
+ # forbidden class reached through a namespace prefix (e.g. "Object::Kernel")
31
+ # or via Ruby's trailing-segment constant inheritance (e.g. "App::File"
32
+ # falling back to top-level ::File) is rejected even though its literal
33
+ # string is not listed in FORBIDDEN_CLASSES. An app's OWN class that merely
34
+ # shares a name (a distinct object) is unaffected.
35
+ FORBIDDEN_CONSTANTS = FORBIDDEN_CLASSES.filter_map do |const_name|
36
+ Object.const_get(const_name) if Object.const_defined?(const_name, false)
37
+ end.freeze
38
+
39
+ module_function
40
+
41
+ # Resolve a validated class name to its Class object.
42
+ #
43
+ # @param class_name [String] fully-qualified class name (e.g. "App::Users")
44
+ # @return [Class, Module] the resolved constant
45
+ # @raise [ArgumentError] if the name is malformed, forbidden, or not found
46
+ def safe_const_get(class_name)
47
+ name = class_name.to_s
48
+
49
+ raise ArgumentError, "Invalid class name format: #{class_name}" unless name.match?(CLASS_NAME_PATTERN)
50
+
51
+ raise ArgumentError, "Forbidden class name: #{class_name}" if FORBIDDEN_CLASSES.include?(name)
52
+
53
+ fq_class_name = "::#{name.sub(/^::+/, '')}"
54
+
55
+ resolved =
56
+ begin
57
+ Object.const_get(fq_class_name)
58
+ rescue NameError => e
59
+ raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
60
+ end
61
+
62
+ # Reject forbidden constants reached via a namespace prefix or Ruby's
63
+ # trailing-segment constant inheritance, which the literal-name check
64
+ # above cannot see (e.g. "Object::Kernel", or "App::File" -> ::File).
65
+ if FORBIDDEN_CONSTANTS.any? { |forbidden| resolved.equal?(forbidden) }
66
+ raise ArgumentError, "Forbidden class name: #{class_name}"
67
+ end
68
+
69
+ resolved
70
+ end
71
+ end
72
+ end
73
+ end
@@ -93,9 +93,10 @@ class Otto
93
93
  # Inject meta tag into HTML head
94
94
  body_content = body.respond_to?(:join) ? body.join : body.to_s
95
95
 
96
- if body_content.match?(/<head>/i)
96
+ head_open_tag = /<head(?:\s[^>]*)?>/i
97
+ if body_content.match?(head_open_tag)
97
98
  meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
98
- body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
99
+ body_content = body_content.sub(head_open_tag) { |tag| "#{tag}\n#{meta_tag}" }
99
100
 
100
101
  # Update content length if present
101
102
  content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }