otto 2.2.0 → 2.3.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.
@@ -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,35 @@ 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
+ # Error raised when CSRF protection is enabled in production without an
41
+ # explicitly configured secret. A randomly-generated per-process secret
42
+ # silently breaks token verification across workers and restarts, so we
43
+ # refuse it in production rather than serve intermittently-failing tokens.
44
+ CSRF_SECRET_REQUIRED_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
45
+ CSRF protection is enabled in production without a configured secret.
46
+ Set OTTO_CSRF_SECRET (or config.csrf_secret=) to a stable random value
47
+ (e.g. SecureRandom.hex(32)); a per-process random secret is not valid
48
+ across workers or restarts.
49
+ MSG
50
+
51
+ attr_accessor :input_validation, :max_param_depth, :csrf_token_key,
52
+ :rate_limiting_config, :csrf_session_key, :max_request_size,
53
+ :max_param_keys
30
54
 
31
55
  attr_reader :csrf_protection, :csrf_header_key,
32
56
  :trusted_proxies, :require_secure_cookies,
33
57
  :security_headers,
34
58
  :csp_nonce_enabled, :debug_csp, :mcp_auth,
35
- :ip_privacy_config
59
+ :ip_privacy_config, :trusted_proxy_depth
36
60
 
37
61
  # Initialize security configuration with safe defaults
38
62
  #
@@ -47,6 +71,8 @@ class Otto
47
71
  @max_param_depth = 32
48
72
  @max_param_keys = 64
49
73
  @trusted_proxies = []
74
+ @trusted_proxy_matchers = []
75
+ @trusted_proxy_depth = nil
50
76
  @require_secure_cookies = false
51
77
  @security_headers = default_security_headers
52
78
  @input_validation = true
@@ -54,6 +80,10 @@ class Otto
54
80
  @debug_csp = false
55
81
  @rate_limiting_config = { custom_rules: {} }
56
82
  @ip_privacy_config = Otto::Privacy::Config.new
83
+
84
+ configured_secret = ENV.fetch('OTTO_CSRF_SECRET', nil)
85
+ @csrf_secret_generated = configured_secret.nil? || configured_secret.empty?
86
+ @csrf_secret = @csrf_secret_generated ? SecureRandom.hex(32) : configured_secret
57
87
  end
58
88
 
59
89
  # Enable CSRF (Cross-Site Request Forgery) protection
@@ -67,7 +97,7 @@ class Otto
67
97
  # @return [void]
68
98
  # @raise [FrozenError] if configuration is frozen
69
99
  def enable_csrf_protection!
70
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
100
+ ensure_not_frozen!
71
101
 
72
102
  @csrf_protection = true
73
103
  end
@@ -77,7 +107,7 @@ class Otto
77
107
  # @return [void]
78
108
  # @raise [FrozenError] if configuration is frozen
79
109
  def disable_csrf_protection!
80
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
110
+ ensure_not_frozen!
81
111
 
82
112
  @csrf_protection = false
83
113
  end
@@ -109,12 +139,18 @@ class Otto
109
139
  # @example Add multiple proxies
110
140
  # config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
111
141
  def add_trusted_proxy(proxy)
112
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
142
+ ensure_not_frozen!
143
+ # CIDR-walk and count-based depth are mutually exclusive. Catch the
144
+ # conflict eagerly here (and in #trusted_proxy_depth=) so it surfaces at
145
+ # configuration time, not only at freeze (which the test harness skips).
146
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if trusted_proxy_depth_mode?
113
147
 
114
148
  case proxy
115
149
  when String, Regexp
116
150
  @trusted_proxies << proxy
151
+ @trusted_proxy_matchers << register_proxy_matcher(proxy)
117
152
  when Array
153
+ proxy.each { |entry| @trusted_proxy_matchers << register_proxy_matcher(entry) }
118
154
  @trusted_proxies.concat(proxy)
119
155
  else
120
156
  raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
@@ -123,23 +159,72 @@ class Otto
123
159
 
124
160
  # Check if an IP address is from a trusted proxy
125
161
  #
162
+ # String entries that parse as an IP or CIDR range are matched with
163
+ # proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
164
+ # IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
165
+ # exact/prefix string match for backward compatibility. Regexp entries
166
+ # are matched against the raw IP string.
167
+ #
168
+ # Proxy entries are parsed once at registration (see #add_trusted_proxy)
169
+ # into @trusted_proxy_matchers, so this never re-parses per request.
170
+ #
126
171
  # @param ip [String] IP address to check
127
172
  # @return [Boolean] true if the IP is from a trusted proxy
128
173
  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)
174
+ return false if @trusted_proxy_matchers.empty? || ip.nil? || ip.empty?
175
+
176
+ # Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
177
+ # peer presented in mapped form still matches an IPv4 proxy entry.
178
+ client = parse_ipaddr(ip)&.native
179
+
180
+ @trusted_proxy_matchers.any? do |entry, range|
181
+ if range
182
+ # Pre-parsed IP/CIDR entry -> proper containment
183
+ client && ip_in_range?(range, client)
184
+ elsif entry.is_a?(Regexp)
185
+ entry.match?(ip)
186
+ elsif entry.is_a?(String)
187
+ # Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
188
+ ip == entry || ip.start_with?(entry)
137
189
  else
138
190
  false
139
191
  end
140
192
  end
141
193
  end
142
194
 
195
+ # Whether count-based ("trust the last N hops") proxy resolution is active.
196
+ #
197
+ # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
198
+ # instead trusts a fixed number of hops from the right of the forwarded
199
+ # chain (Express `trust proxy = N`). This is the only sound model for
200
+ # non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
201
+ # proxies) whose addresses cannot be listed as CIDRs.
202
+ #
203
+ # @return [Boolean] true when trusted_proxy_depth is an Integer >= 1
204
+ def trusted_proxy_depth_mode?
205
+ @trusted_proxy_depth.is_a?(Integer) && @trusted_proxy_depth >= 1
206
+ end
207
+
208
+ # Set the count-based trusted-proxy depth ("trust the last N hops").
209
+ #
210
+ # Validates eagerly so a misconfiguration fails at assignment rather than
211
+ # only at freeze (which the test harness skips): the value must be a
212
+ # non-negative Integer or nil, and the mode is mutually exclusive with
213
+ # CIDR-walk (trusted_proxies). nil/0 disable depth mode.
214
+ #
215
+ # @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
216
+ # @raise [FrozenError] if configuration is frozen
217
+ # @raise [ArgumentError] if depth is non-integer/negative, or if
218
+ # trusted_proxies are already configured and depth >= 1
219
+ def trusted_proxy_depth=(depth)
220
+ ensure_not_frozen!
221
+
222
+ validate_trusted_proxy_depth!(depth)
223
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
224
+
225
+ @trusted_proxy_depth = depth
226
+ end
227
+
143
228
  # Validate that a request size is within acceptable limits
144
229
  #
145
230
  # @param content_length [String, Integer, nil] Content-Length header value
@@ -156,28 +241,45 @@ class Otto
156
241
  true
157
242
  end
158
243
 
244
+ # Set the server-side secret used to sign (HMAC) CSRF tokens. Set this to
245
+ # a stable value (e.g. ENV['OTTO_CSRF_SECRET']) in multi-process or
246
+ # multi-host deployments so tokens stay valid across workers and restarts.
247
+ #
248
+ # Write-only by design: the signing key has no public reader, so it is not
249
+ # exposed to inspection/logging/serialization via the config object.
250
+ def csrf_secret=(secret)
251
+ ensure_not_frozen!
252
+
253
+ @csrf_secret = secret
254
+ @csrf_secret_generated = false
255
+ end
256
+
257
+ # Generate a CSRF token bound to the given session id and signed (HMAC-SHA256)
258
+ # with the server-side secret, so tokens cannot be self-minted and are not
259
+ # valid across sessions. A session binding is REQUIRED.
159
260
  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}"
261
+ binding_id = session_id.to_s
262
+ raise ArgumentError, 'CSRF token generation requires a session binding' if binding_id.empty?
165
263
 
166
- csrf_token
264
+ reject_generated_secret_in_production!
265
+ warn_generated_csrf_secret
266
+ token = SecureRandom.hex(32)
267
+ "#{token}:#{sign_csrf_token(binding_id, token)}"
167
268
  end
168
269
 
270
+ # Verify a CSRF token against its session binding using a constant-time
271
+ # comparison. Returns false (never raises) for blank/malformed input.
169
272
  def verify_csrf_token(token, session_id = nil)
170
273
  return false if token.nil? || token.empty?
171
274
 
172
- token_part, signature = token.split(':')
173
- return false if token_part.nil? || signature.nil?
275
+ binding_id = session_id.to_s
276
+ return false if binding_id.empty?
174
277
 
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)
278
+ token_part, signature = token.split(':', 2)
279
+ return false if token_part.nil? || signature.nil?
179
280
 
180
- comparison_result
281
+ expected_signature = sign_csrf_token(binding_id, token_part)
282
+ secure_compare(signature, expected_signature)
181
283
  end
182
284
 
183
285
  # Enable HTTP Strict Transport Security (HSTS) header
@@ -191,7 +293,7 @@ class Otto
191
293
  # @return [void]
192
294
  # @raise [FrozenError] if configuration is frozen
193
295
  def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
194
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
296
+ ensure_not_frozen!
195
297
 
196
298
  hsts_value = "max-age=#{max_age}"
197
299
  hsts_value += '; includeSubDomains' if include_subdomains
@@ -210,7 +312,7 @@ class Otto
210
312
  # @example Custom policy
211
313
  # config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
212
314
  def enable_csp!(policy = "default-src 'self'")
213
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
315
+ ensure_not_frozen!
214
316
 
215
317
  @security_headers['content-security-policy'] = policy
216
318
  end
@@ -228,7 +330,7 @@ class Otto
228
330
  # @example
229
331
  # config.enable_csp_with_nonce!(debug: true)
230
332
  def enable_csp_with_nonce!(debug: false)
231
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
333
+ ensure_not_frozen!
232
334
 
233
335
  @csp_nonce_enabled = true
234
336
  @debug_csp = debug
@@ -239,7 +341,7 @@ class Otto
239
341
  # @return [void]
240
342
  # @raise [FrozenError] if configuration is frozen
241
343
  def disable_csp_nonce!
242
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
344
+ ensure_not_frozen!
243
345
 
244
346
  @csp_nonce_enabled = false
245
347
  end
@@ -274,7 +376,7 @@ class Otto
274
376
  # @return [void]
275
377
  # @raise [FrozenError] if configuration is frozen
276
378
  def enable_frame_protection!(option = 'SAMEORIGIN')
277
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
379
+ ensure_not_frozen!
278
380
 
279
381
  @security_headers['x-frame-options'] = option
280
382
  end
@@ -291,7 +393,7 @@ class Otto
291
393
  # 'cross-origin-opener-policy' => 'same-origin'
292
394
  # })
293
395
  def set_custom_headers(headers)
294
- raise FrozenError, 'Cannot modify frozen configuration' if frozen?
396
+ ensure_not_frozen!
295
397
 
296
398
  @security_headers.merge!(headers)
297
399
  end
@@ -305,6 +407,8 @@ class Otto
305
407
  def deep_freeze!
306
408
  # Ensure custom_rules is initialized (should already be done in constructor)
307
409
  @rate_limiting_config[:custom_rules] ||= {}
410
+ validate_trusted_proxy_config!
411
+ validate_csrf_secret_config!
308
412
  super
309
413
  end
310
414
 
@@ -323,6 +427,102 @@ class Otto
323
427
 
324
428
  private
325
429
 
430
+ # Guard for mutators: refuse changes once the configuration is frozen.
431
+ # Centralizes the repeated frozen-check so every setter shares one message.
432
+ def ensure_not_frozen!
433
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
434
+ end
435
+
436
+ # Validate a candidate trusted_proxy_depth value (type and range).
437
+ #
438
+ # Shared by the eager #trusted_proxy_depth= setter and the freeze-time
439
+ # backstop so an invalid value raises a clear ArgumentError instead of a
440
+ # downstream NoMethodError from #to_i coercion. nil disables depth mode.
441
+ #
442
+ # @param depth [Object] candidate value
443
+ # @raise [ArgumentError] if depth is non-nil and not a non-negative Integer
444
+ # @return [void]
445
+ def validate_trusted_proxy_depth!(depth)
446
+ return if depth.nil?
447
+
448
+ unless depth.is_a?(Integer)
449
+ raise ArgumentError,
450
+ "trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
451
+ end
452
+
453
+ raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
454
+ end
455
+
456
+ # Validate trusted-proxy configuration coherence at freeze time.
457
+ #
458
+ # The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
459
+ # reject invalid types and the mutually-exclusive CIDR-walk vs depth
460
+ # combination at assignment. This re-checks at finalization as a backstop
461
+ # for a direct/ivar configuration path that bypassed the setters.
462
+ #
463
+ # @raise [ArgumentError] if depth is non-integer/negative, or if both
464
+ # trusted_proxies and a depth >= 1 are configured
465
+ # @return [void]
466
+ def validate_trusted_proxy_config!
467
+ validate_trusted_proxy_depth!(@trusted_proxy_depth)
468
+ return if @trusted_proxy_depth.nil?
469
+
470
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
471
+ end
472
+
473
+ # Parse a value into an IPAddr, returning nil for invalid / non-IP input.
474
+ #
475
+ # @param value [String] candidate IP or CIDR string
476
+ # @return [IPAddr, nil]
477
+ def parse_ipaddr(value)
478
+ IPAddr.new(value)
479
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
480
+ nil
481
+ end
482
+
483
+ # Build a cached matcher tuple for a proxy entry at registration time.
484
+ #
485
+ # String entries are parsed to an IPAddr exactly once here; the result is
486
+ # reused for both the legacy-entry warning and per-request matching, so
487
+ # trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
488
+ # store a nil range and fall back to prefix/regexp matching.
489
+ #
490
+ # @param entry [String, Regexp, Object] trusted proxy entry being added
491
+ # @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
492
+ def register_proxy_matcher(entry)
493
+ return [entry, nil] unless entry.is_a?(String)
494
+
495
+ range = parse_ipaddr(entry)
496
+ warn_legacy_proxy_entry(entry) unless range
497
+ [entry, range]
498
+ end
499
+
500
+ # Warn that a string proxy entry is not a valid IP/CIDR and will use
501
+ # legacy string-prefix matching.
502
+ #
503
+ # @param entry [String] trusted proxy entry
504
+ # @return [void]
505
+ def warn_legacy_proxy_entry(entry)
506
+ Otto.logger.warn(
507
+ "[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
508
+ 'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
509
+ "CIDR range (e.g. '172.16.0.0/12')."
510
+ )
511
+ end
512
+
513
+ # CIDR/host containment that is safe across address families.
514
+ #
515
+ # @param range [IPAddr] trusted proxy range or host
516
+ # @param client [IPAddr] client address
517
+ # @return [Boolean]
518
+ def ip_in_range?(range, client)
519
+ return false unless range.family == client.family
520
+
521
+ range.include?(client)
522
+ rescue IPAddr::InvalidAddressError
523
+ false
524
+ end
525
+
326
526
  def extract_existing_session_id(request)
327
527
  # Try session first
328
528
  begin
@@ -386,6 +586,59 @@ class Otto
386
586
  result == 0
387
587
  end
388
588
 
589
+ # HMAC-SHA256 signature binding a token's random component to a session id
590
+ # and the server-side secret. Keyed HMAC (not a bare digest) is what prevents
591
+ # token self-minting.
592
+ def sign_csrf_token(session_id, token)
593
+ OpenSSL::HMAC.hexdigest('SHA256', @csrf_secret, "#{session_id}:#{token}")
594
+ end
595
+
596
+ # Warn once per config instance when CSRF tokens are being signed with a
597
+ # randomly-generated per-process secret. Such tokens do not survive process
598
+ # restarts and are not shared across workers; set OTTO_CSRF_SECRET (or
599
+ # config.csrf_secret=) for stable multi-process behavior.
600
+ def warn_generated_csrf_secret
601
+ return unless @csrf_secret_generated
602
+ return if @csrf_secret_warning_emitted
603
+
604
+ @csrf_secret_warning_emitted = true
605
+ Otto.logger.warn(<<~MSG.gsub(/\s+/, ' ').strip)
606
+ [Otto::Security::Config] CSRF tokens are signed with a randomly
607
+ generated per-process secret; they will not survive restarts or be
608
+ valid across workers. Set OTTO_CSRF_SECRET (or config.csrf_secret=)
609
+ for stable CSRF tokens in multi-process deployments.
610
+ MSG
611
+ end
612
+
613
+ # Freeze-time backstop: refuse to finalize a production configuration that
614
+ # enables CSRF with a generated (non-configured) secret. Mirrors
615
+ # #validate_trusted_proxy_config! so the failure surfaces at boot, before
616
+ # serving traffic, for apps that deep-freeze their config.
617
+ def validate_csrf_secret_config!
618
+ raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
619
+ end
620
+
621
+ # Generation-time guard for apps that never freeze their config: never
622
+ # mint a CSRF token signed with a generated per-process secret in
623
+ # production (fail loud instead of serving tokens that won't verify).
624
+ def reject_generated_secret_in_production!
625
+ raise ArgumentError, CSRF_SECRET_REQUIRED_MESSAGE if csrf_secret_unsafe_for_production?
626
+ end
627
+
628
+ # True when CSRF is enabled, the secret was randomly generated (not
629
+ # configured via OTTO_CSRF_SECRET / #csrf_secret=), and we are running in
630
+ # a production environment.
631
+ def csrf_secret_unsafe_for_production?
632
+ @csrf_protection && @csrf_secret_generated && production_environment?
633
+ end
634
+
635
+ # Whether RACK_ENV indicates a production deployment. Gated to an explicit
636
+ # allowlist (not "anything but dev") so test/unknown environments keep the
637
+ # zero-config generated-secret fallback.
638
+ def production_environment?
639
+ defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
640
+ end
641
+
389
642
  # Generate CSP directives for development environment
390
643
  #
391
644
  # Development mode allows inline scripts/styles and hot reloading connections
@@ -36,6 +36,9 @@ 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)
39
42
  # @param security_headers [Hash] Custom security headers to merge with defaults
40
43
  # @param hsts [Boolean] Enable HTTP Strict Transport Security
41
44
  # @param csp [Boolean, String] Enable Content Security Policy
@@ -58,6 +61,7 @@ class Otto
58
61
  request_validation: false,
59
62
  rate_limiting: false,
60
63
  trusted_proxies: [],
64
+ trusted_proxy_depth: nil,
61
65
  security_headers: {},
62
66
  hsts: false,
63
67
  csp: false,
@@ -69,6 +73,7 @@ class Otto
69
73
  enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
70
74
 
71
75
  Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
76
+ self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
72
77
  self.security_headers = security_headers unless security_headers.empty?
73
78
 
74
79
  enable_hsts! if hsts
@@ -127,6 +132,16 @@ class Otto
127
132
  @security_config.add_trusted_proxy(proxy)
128
133
  end
129
134
 
135
+ # Set count-based trusted-proxy depth ("trust the last N hops") for
136
+ # non-enumerable proxy tiers (Fly, cloud load balancers, dynamic reverse
137
+ # proxies). Mutually exclusive with trusted_proxies; the conflict is
138
+ # validated when the configuration is frozen.
139
+ #
140
+ # @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
141
+ def trusted_proxy_depth=(depth)
142
+ @security_config.trusted_proxy_depth = depth
143
+ end
144
+
130
145
  # Set custom security headers that will be added to all responses.
131
146
  # These merge with the default security headers.
132
147
  #
@@ -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' }