otto 2.5.0 → 2.6.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +65 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/examples/advanced_routes/README.md +49 -0
  8. data/examples/advanced_routes/config.rb +15 -2
  9. data/examples/advanced_routes/routes +12 -0
  10. data/examples/lambda_handlers/README.md +128 -0
  11. data/examples/lambda_handlers/config.ru +26 -0
  12. data/examples/lambda_handlers/handlers.rb +75 -0
  13. data/examples/lambda_handlers/routes +28 -0
  14. data/lib/otto/core/configuration.rb +103 -1
  15. data/lib/otto/core/router.rb +67 -10
  16. data/lib/otto/core/uri_generator.rb +36 -2
  17. data/lib/otto/env_keys.rb +11 -0
  18. data/lib/otto/errors.rb +7 -0
  19. data/lib/otto/mcp/route_parser.rb +15 -4
  20. data/lib/otto/privacy/config.rb +37 -1
  21. data/lib/otto/privacy/core.rb +18 -1
  22. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  23. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  24. data/lib/otto/privacy.rb +1 -0
  25. data/lib/otto/request.rb +27 -0
  26. data/lib/otto/route.rb +103 -41
  27. data/lib/otto/route_definition.rb +56 -6
  28. data/lib/otto/route_handlers/base.rb +4 -0
  29. data/lib/otto/route_handlers/factory.rb +15 -0
  30. data/lib/otto/route_handlers/lambda.rb +47 -32
  31. data/lib/otto/security/config.rb +100 -5
  32. data/lib/otto/security/csp/policy.rb +135 -3
  33. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  34. data/lib/otto/security/csrf_validation.rb +75 -0
  35. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  36. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  37. data/lib/otto/security.rb +1 -0
  38. data/lib/otto/version.rb +1 -1
  39. data/lib/otto.rb +26 -2
  40. metadata +9 -2
data/lib/otto/route.rb CHANGED
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'concurrent'
6
+
5
7
  require_relative 'security/constant_resolver'
6
8
 
7
9
  class Otto
@@ -24,9 +26,44 @@ class Otto
24
26
  #
25
27
  #
26
28
  class Route
27
- # Class methods for Route providing Otto instance access
29
+ # Class methods for Route providing Otto instance access.
30
+ #
31
+ # `route.rb` and `route_handlers/base.rb` `extend` this onto the target
32
+ # class on every request and set `.otto = otto_instance` so app code can
33
+ # read `self.class.otto` from within a handler method. A plain class
34
+ # ivar here would be shared, mutable state: two `Otto` instances sharing
35
+ # a controller/logic class, or concurrent threads/fibers serving
36
+ # requests under different `Otto` instances, would race and clobber
37
+ # `klass.otto`, leaking one request's security_config/auth_config into
38
+ # another's handler (issue #188). Backing the accessor with a
39
+ # `Concurrent::FiberLocalVar` scopes each assignment to the fiber/thread
40
+ # actually serving that request instead.
41
+ #
42
+ # NOTE (Otto v3): this whole class-level accessor is ambient per-request
43
+ # state and exists only as a convenience so handler code can reach otto via
44
+ # `self.class.otto`. The clean design carries no ambient state at all —
45
+ # the handler instance already receives its `Otto` explicitly
46
+ # (`BaseHandler.new(route_definition, otto_instance)`), so app code should
47
+ # read it from an instance-level `#otto` reader instead. Recommended for
48
+ # Otto v3: expose `otto` on the handler instance, deprecate
49
+ # `self.class.otto`, and drop `ClassMethods` — then there is no shared slot
50
+ # to race, reset, or leak, and this fiber-local workaround goes away.
28
51
  module ClassMethods
29
- attr_accessor :otto
52
+ # Per-fiber storage keyed by target class. Deliberately
53
+ # `FiberLocalVar`, not `ThreadLocalVar`: fiber-per-request schedulers
54
+ # (Falcon/Async) run many requests as fibers in one thread, so
55
+ # thread-scoped storage would let those fibers clobber each other's
56
+ # `klass.otto` — the same race, one level down. The default block gives
57
+ # each fiber its own class => otto hash on first access.
58
+ OTTO_INSTANCES = Concurrent::FiberLocalVar.new { {} }
59
+
60
+ def otto=(instance)
61
+ OTTO_INSTANCES.value[self] = instance
62
+ end
63
+
64
+ def otto
65
+ OTTO_INSTANCES.value[self]
66
+ end
30
67
  end
31
68
  # @return [Otto::RouteDefinition] The immutable route definition
32
69
  attr_reader :route_definition
@@ -52,8 +89,15 @@ class Otto
52
89
  # Create immutable route definition
53
90
  @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
54
91
 
55
- # Resolve the class
56
- @klass = Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
92
+ # Resolve the class.
93
+ # Lambda routes carry a registry KEY in klass_name, not a Ruby constant.
94
+ # Skip constant resolution (it would raise on a lowercase/unregistered key
95
+ # and the loader would silently drop the route).
96
+ @klass = if @route_definition.kind == :lambda
97
+ nil
98
+ else
99
+ Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
100
+ end
57
101
  end
58
102
 
59
103
  # Delegate common methods to route_definition for backward compatibility
@@ -102,14 +146,37 @@ class Otto
102
146
  # @return [Array] Rack response array [status, headers, body]
103
147
  def call(env, extra_params = {})
104
148
  extra_params ||= {}
105
- req = otto.request_class.new(env)
106
- res = otto.response_class.new
149
+
150
+ # Pluggable route handler factory (Phase 4). The handler owns
151
+ # request/response construction and decoration — param merging,
152
+ # indifferent access, security headers, CSRF/validation helpers all
153
+ # happen once in BaseHandler#setup_request_response. Building them here
154
+ # too would duplicate that work on objects that get discarded (issue #189).
155
+ if otto&.route_handler_factory
156
+ # Make security config, route definition, and options available to
157
+ # middleware and handlers before delegating, so wrappers that run
158
+ # ahead of the handler's own setup (RouteAuthWrapper, the centralized
159
+ # error handler) can see them.
160
+ env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
161
+ env['otto.route_definition'] = @route_definition
162
+ env['otto.route_options'] = @route_definition.options
163
+
164
+ handler = otto.route_handler_factory.create_handler(@route_definition, otto)
165
+ return handler.call(env, extra_params)
166
+ end
167
+
168
+ # Fallback to legacy behavior for backward compatibility. Build req/res
169
+ # before touching env, preserving the exact ordering this path always
170
+ # had — a custom request_class/response_class#initialize that reads env
171
+ # must keep seeing it unpopulated, same as before #189 (review follow-up).
172
+ req = otto.request_class.new(env)
173
+ res = otto.response_class.new
107
174
  res.request = req
108
175
 
109
176
  # Make security config available to response helpers
110
177
  env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
111
178
 
112
- # NEW: Make route definition and options available to middleware and handlers
179
+ # Make route definition and options available to middleware and handlers
113
180
  env['otto.route_definition'] = @route_definition
114
181
  env['otto.route_options'] = @route_definition.options
115
182
 
@@ -124,8 +191,11 @@ class Otto
124
191
  end
125
192
  end
126
193
 
127
- klass.extend Otto::Route::ClassMethods
128
- klass.otto = otto
194
+ # No target class for lambda routes (klass is nil); skip class extension.
195
+ if klass
196
+ klass.extend Otto::Route::ClassMethods
197
+ klass.otto = otto
198
+ end
129
199
 
130
200
  # Add security helpers if CSRF is enabled
131
201
  if otto.respond_to?(:security_config) && otto.security_config&.csrf_enabled?
@@ -135,39 +205,31 @@ class Otto
135
205
  # Add validation helpers
136
206
  res.extend Otto::Security::ValidationHelpers
137
207
 
138
- # NEW: Use the pluggable route handler factory (Phase 4)
139
- # This replaces the hardcoded execution pattern with a factory approach
140
- if otto&.route_handler_factory
141
- handler = otto.route_handler_factory.create_handler(@route_definition, otto)
142
- handler.call(env, extra_params)
143
- else
144
- # Fallback to legacy behavior for backward compatibility
145
- inst = nil
146
- result = case kind
147
- when :instance
148
- inst = klass.new req, res
149
- inst.send(name)
150
- when :class
151
- klass.send(name, req, res)
152
- else
153
- raise "Unsupported kind for #{definition}: #{kind}"
154
- end
155
-
156
- # Handle response based on route options
157
- response_type = @route_definition.response_type
158
- if response_type != 'default'
159
- context = {
160
- logic_instance: (kind == :instance ? inst : nil),
161
- status_code: nil,
162
- redirect_path: nil,
163
- }
164
-
165
- Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
166
- end
167
-
168
- res.body = [res.body] unless res.body.respond_to?(:each)
169
- res.finish
208
+ inst = nil
209
+ result = case kind
210
+ when :instance
211
+ inst = klass.new req, res
212
+ inst.send(name)
213
+ when :class
214
+ klass.send(name, req, res)
215
+ else
216
+ raise "Unsupported kind for #{definition}: #{kind}"
217
+ end
218
+
219
+ # Handle response based on route options
220
+ response_type = @route_definition.response_type
221
+ if response_type != 'default'
222
+ context = {
223
+ logic_instance: (kind == :instance ? inst : nil),
224
+ status_code: nil,
225
+ redirect_path: nil,
226
+ }
227
+
228
+ Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
170
229
  end
230
+
231
+ res.body = [res.body] unless res.body.respond_to?(:each)
232
+ res.finish
171
233
  end
172
234
 
173
235
  private
@@ -2,10 +2,18 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative 'errors'
6
+
5
7
  class Otto
6
8
  # Immutable data class representing a complete route definition
7
9
  # This encapsulates all aspects of a route: path, target, and options
8
10
  class RouteDefinition
11
+ # Options that gate access to a route. A malformed token for one of these
12
+ # (e.g. `csrf` instead of `csrf=exempt`, or a bare `auth`) must not fall
13
+ # back to the default behavior silently — the route would serve without
14
+ # its intended protection (issue #191).
15
+ SECURITY_GATING_OPTIONS = %w[auth role csrf].freeze
16
+
9
17
  # @return [String] The HTTP verb (GET, POST, etc.)
10
18
  attr_reader :verb
11
19
 
@@ -112,6 +120,32 @@ class Otto
112
120
  option(:response, 'default')
113
121
  end
114
122
 
123
+ # Parse a single whitespace-delimited `key=value` option token, applying
124
+ # the security-gating fail-fast rule shared by normal routes and the MCP
125
+ # RouteParser (issue #191 and its MCP/TOOL follow-up).
126
+ # @param part [String] a single option token, e.g. "auth=session"
127
+ # @param context [String] human-readable source description for the
128
+ # raised error message, e.g. "route definition \"GET /admin ...\""
129
+ # @return [Array(Symbol, String), nil] the [key, value] pair to store,
130
+ # or nil if the token is malformed and should only be warned about
131
+ # @raise [Otto::RouteDefinitionError] if a security-gating option
132
+ # (auth/role/csrf) is malformed
133
+ def self.parse_option_token(part, context)
134
+ key, value = part.split('=', 2)
135
+ normalized_key = key&.downcase
136
+
137
+ if SECURITY_GATING_OPTIONS.include?(normalized_key)
138
+ if key != normalized_key || value.nil? || value.empty?
139
+ raise Otto::RouteDefinitionError,
140
+ "Malformed security option #{part.inspect} in #{context}: " \
141
+ "expected #{normalized_key}=value"
142
+ end
143
+ [key.to_sym, value]
144
+ elsif key && !key.empty? && value
145
+ [key.to_sym, value]
146
+ end
147
+ end
148
+
115
149
  # Check if CSRF is exempt for this route
116
150
  # @return [Boolean]
117
151
  def csrf_exempt?
@@ -168,18 +202,21 @@ class Otto
168
202
  # Parse route definition into target and options
169
203
  # @param definition [String] The route definition
170
204
  # @return [Hash] Hash with :target and :options keys
205
+ # @raise [Otto::RouteDefinitionError] if a security-gating option token
206
+ # (auth/role/csrf) has no value — failing fast instead of serving the
207
+ # route with default (less safe) behavior
171
208
  def parse_definition(definition)
172
209
  parts = definition.split(/\s+/)
173
210
  target = parts.shift
174
211
  options = {}
175
212
 
176
213
  parts.each do |part|
177
- key, value = part.split('=', 2)
178
- if key && value
179
- options[key.to_sym] = value
180
- elsif Otto.debug
181
- # Malformed parameter, log warning if debug enabled
182
- Otto.logger.warn "Ignoring malformed route parameter: #{part}"
214
+ pair = self.class.parse_option_token(part, "route definition #{definition.inspect}")
215
+ if pair
216
+ options[pair[0]] = pair[1]
217
+ else
218
+ Otto.structured_log(:warn, 'Malformed route option ignored',
219
+ { option: part, definition: definition })
183
220
  end
184
221
  end
185
222
 
@@ -191,6 +228,19 @@ class Otto
191
228
  # @return [Hash] Hash with :klass_name, :method_name, and :kind
192
229
  def parse_target(target)
193
230
  case target
231
+ when /^&/
232
+ # Lambda handler: '&name' references a proc pre-registered in the
233
+ # lambda_handlers registry. The entire remainder after '&' is the exact
234
+ # O(1) Hash lookup key (may contain '.', '#', '::' — all inert here;
235
+ # resolution is string equality, never eval/const_get). Issue #41 security.
236
+ name = target[1..].to_s
237
+ if name.strip.empty?
238
+ raise ArgumentError,
239
+ "Invalid lambda handler target #{target.inspect}: handler name " \
240
+ "after '&' cannot be empty (expected '&handler_name')"
241
+ end
242
+ { klass_name: name, method_name: nil, kind: :lambda }
243
+
194
244
  when /^(.+)\.(.+)$/
195
245
  # Class.method - call class method directly
196
246
  { klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :class }
@@ -125,6 +125,10 @@ class Otto
125
125
  # @param env [Hash] Rack environment
126
126
  # @param extra_params [Hash] Additional parameters
127
127
  def setup_request_response(req, res, env, extra_params)
128
+ # Expose the raw path-capture hash to invoke_target (LambdaHandler
129
+ # passes it as the proc's 3rd arg). Inert for other subclasses.
130
+ @extra_params = extra_params
131
+
128
132
  # Set request reference (helpers are already included in class)
129
133
  res.request = req
130
134
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  require_relative 'base'
6
6
  require_relative '../security/authentication/route_auth_wrapper'
7
+ require_relative '../security/csrf_enforcement_wrapper'
7
8
 
8
9
  class Otto
9
10
  module RouteHandlers
@@ -19,6 +20,7 @@ class Otto
19
20
  when :logic then LogicClassHandler
20
21
  when :instance then InstanceMethodHandler
21
22
  when :class then ClassMethodHandler
23
+ when :lambda then LambdaHandler
22
24
  else
23
25
  raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
24
26
  end
@@ -37,6 +39,19 @@ class Otto
37
39
  )
38
40
  end
39
41
 
42
+ # Enforce CSRF at the handler layer, where `csrf=exempt` is visible
43
+ # (the global CSRFMiddleware runs ahead of route matching and cannot
44
+ # honor per-route exemption — issue #186). Wrapped OUTSIDE
45
+ # RouteAuthWrapper so a forged unsafe request is rejected before any
46
+ # authentication work runs. Only added when protection is enabled.
47
+ if otto_instance&.security_config&.csrf_enabled?
48
+ handler = Otto::Security::CSRFEnforcementWrapper.new(
49
+ handler,
50
+ route_definition,
51
+ otto_instance.security_config
52
+ )
53
+ end
54
+
40
55
  apply_handler_wrappers(handler, route_definition, otto_instance)
41
56
  end
42
57
 
@@ -1,46 +1,61 @@
1
1
  # lib/otto/route_handlers/lambda.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- require 'securerandom'
5
4
 
6
5
  require_relative 'base'
7
6
 
8
7
  class Otto
9
8
  module RouteHandlers
10
- # Custom handler for lambda/proc definitions (future extension)
9
+ # Handler for pre-registered lambda/proc route targets (issue #41).
10
+ #
11
+ # Route syntax `GET /ping &health_check` parses to kind :lambda with
12
+ # klass_name = "health_check" (the registry KEY, not a Ruby constant) and
13
+ # method_name = nil. The proc is looked up O(1) by name from the Otto
14
+ # instance's pre-registered lambda_handlers — no eval, no dynamic constants.
15
+ #
16
+ # Reuses BaseHandler#call: implements #invoke_target and guards the base's
17
+ # constant-resolution steps (#target_class / #handler_name).
11
18
  class LambdaHandler < BaseHandler
12
- def call(env, extra_params = {})
13
- start_time = Otto::Utils.now_in_μs
14
- req = otto_instance ? otto_instance.request_class.new(env) : Otto::Request.new(env)
15
- res = otto_instance ? otto_instance.response_class.new : Otto::Response.new
16
-
17
- begin
18
- # Security: Lambda handlers require pre-configured procs from Otto instance
19
- # This prevents code injection via eval and maintains security
20
- handler_name = route_definition.klass_name
21
- lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
22
-
23
- lambda_proc = lambda_registry[handler_name]
24
- unless lambda_proc.respond_to?(:call)
25
- raise ArgumentError, "Lambda handler '#{handler_name}' not found in registry or not callable"
26
- end
27
-
28
- result = lambda_proc.call(req, res, extra_params)
29
-
30
- handle_response(result, res, {
31
- lambda: lambda_proc,
32
- request: req,
33
- })
34
- rescue StandardError => e
35
- # Store handler context in env for centralized error handler
36
- handler_name = "Lambda[#{route_definition.klass_name}]"
37
- env['otto.handler'] = handler_name
38
- env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
39
-
40
- raise e # Re-raise to let Otto's centralized error handler manage the response
19
+ protected
20
+
21
+ # No Ruby constant backs a lambda route. Returning nil (a) prevents
22
+ # ConstantResolver.safe_const_get from raising on a registry key and
23
+ # (b) makes BaseHandler#setup_request_response skip its target_class
24
+ # extension block.
25
+ def target_class
26
+ nil
27
+ end
28
+
29
+ # Derive the log/handler name from the route, not target_class.name
30
+ # (which would be nil.name -> NoMethodError inside handle_execution_error).
31
+ def handler_name
32
+ "Lambda[#{route_definition.klass_name}]"
33
+ end
34
+
35
+ # Look up the pre-registered proc and invoke it with (req, res, extra_params).
36
+ # @return [Array] [result, context] consumed by BaseHandler#handle_response
37
+ def invoke_target(req, res)
38
+ handler_key = route_definition.klass_name
39
+ lambda_proc = lambda_registry[handler_key]
40
+
41
+ unless lambda_proc.respond_to?(:call)
42
+ raise ArgumentError,
43
+ "Lambda handler '#{handler_key}' is not registered or not callable"
41
44
  end
42
45
 
43
- res.finish
46
+ result = lambda_proc.call(req, res, @extra_params || {})
47
+ [result, { request: req }]
48
+ end
49
+
50
+ private
51
+
52
+ # O(1) read of the frozen registry from Otto config. Tolerates the
53
+ # direct-testing context (no otto_instance / no :config) by returning {},
54
+ # so invoke_target raises the clear "not registered" ArgumentError.
55
+ def lambda_registry
56
+ return {} unless otto_instance.respond_to?(:config)
57
+
58
+ (otto_instance.config && otto_instance.config[:lambda_handlers]) || {}
44
59
  end
45
60
  end
46
61
  end
@@ -72,7 +72,8 @@ class Otto
72
72
  :security_headers,
73
73
  :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
74
74
  :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
75
- :csp_report_uri, :csp_report_to_url, :csp_violation_callback
75
+ :csp_report_uri, :csp_report_to_url, :csp_violation_callback,
76
+ :csp_directive_overrides
76
77
 
77
78
  # Initialize security configuration with safe defaults
78
79
  #
@@ -100,6 +101,8 @@ class Otto
100
101
  @csp_report_uri = nil
101
102
  @csp_report_to_url = nil
102
103
  @csp_violation_callback = nil
104
+ @csp_directive_overrides = {}
105
+ @csp_script_src_override_warned = false
103
106
  @rate_limiting_config = { custom_rules: {} }
104
107
  @ip_privacy_config = Otto::Privacy::Config.new
105
108
 
@@ -367,19 +370,85 @@ class Otto
367
370
  # Unlike enable_csp!, this doesn't set a static policy but enables the response
368
371
  # helper to generate CSP headers with nonces on a per-request basis.
369
372
  #
373
+ # Per-directive overrides may be supplied to customize the emitted nonce
374
+ # policy without vendoring the gem. They merge into Otto's base directive
375
+ # sets ({Otto::Security::CSP::Policy.development_directives} /
376
+ # {Otto::Security::CSP::Policy.production_directives}): a matching directive
377
+ # is replaced in place, a new directive is appended, and a nil/false value
378
+ # removes a directive. See {#csp_directive_overrides=} for the accepted
379
+ # shape.
380
+ #
370
381
  # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
382
+ # @param directives [Hash] per-directive overrides merged into the base set
371
383
  # @return [void]
372
384
  # @raise [FrozenError] if configuration is frozen
373
385
  #
374
386
  # @example
375
387
  # config.enable_csp_with_nonce!(debug: true)
376
- def enable_csp_with_nonce!(debug: false)
388
+ #
389
+ # @example Restore data: workers (blob: is the default worker-src token)
390
+ # config.enable_csp_with_nonce!(directives: { 'worker-src' => "'self' data: blob:" })
391
+ def enable_csp_with_nonce!(debug: false, directives: {})
377
392
  ensure_not_frozen!
378
393
 
394
+ # Apply overrides before toggling state so a bad +directives+ argument
395
+ # raises without leaving the config half-updated (nonce enabled but
396
+ # overrides not merged).
397
+ merge_csp_directives(directives) unless directives.nil? || directives.empty?
379
398
  @csp_nonce_enabled = true
380
399
  @debug_csp = debug
381
400
  end
382
401
 
402
+ # Replace the per-directive overrides applied to the nonce CSP policy.
403
+ #
404
+ # Overrides merge into Otto's base directive sets when
405
+ # {#generate_nonce_csp} builds the policy, so a consuming app can adjust
406
+ # ANY directive rather than only `report-uri`/`report-to`. Keys are
407
+ # directive names (String or Symbol, matched case-insensitively); values
408
+ # are the source list as a String (`"'self' blob:"`) or Array
409
+ # (`%w['self' blob:]`), or nil/false to REMOVE the directive.
410
+ #
411
+ # Keys are normalized on store (lowercased, hyphenated) via
412
+ # {Otto::Security::CSP::Policy.normalize_overrides}, so the stored hash
413
+ # never accumulates logically-identical entries under different key styles
414
+ # (`'WORKER-SRC'` and `:worker_src` both read back as `'worker-src'`).
415
+ #
416
+ # @note Overriding `script-src` while nonce mode is enabled disables
417
+ # nonce-based script protection: the per-request nonce cannot be
418
+ # included in a static override, so it is stripped from the emitted
419
+ # header. A warning is logged when a `script-src` override is stored.
420
+ # See {Otto::Security::CSP::Policy.merge_directives}.
421
+ #
422
+ # @param overrides [Hash] directive name => source list / nil
423
+ # @return [void]
424
+ # @raise [FrozenError] if configuration is frozen
425
+ #
426
+ # @example
427
+ # config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }
428
+ def csp_directive_overrides=(overrides)
429
+ ensure_not_frozen!
430
+
431
+ normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
432
+ warn_if_script_src_overridden(normalized)
433
+ @csp_directive_overrides = normalized
434
+ end
435
+
436
+ # Merge additional per-directive overrides into the existing set, leaving
437
+ # untouched any directive not named in +overrides+ (last write wins for a
438
+ # repeated directive). Use this to accumulate overrides incrementally;
439
+ # use {#csp_directive_overrides=} to replace them wholesale.
440
+ #
441
+ # @param overrides [Hash] directive name => source list / nil
442
+ # @return [void]
443
+ # @raise [FrozenError] if configuration is frozen
444
+ def merge_csp_directives(overrides)
445
+ ensure_not_frozen!
446
+
447
+ normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
448
+ warn_if_script_src_overridden(normalized)
449
+ @csp_directive_overrides = @csp_directive_overrides.merge(normalized)
450
+ end
451
+
383
452
  # Disable CSP nonce support
384
453
  #
385
454
  # @return [void]
@@ -527,8 +596,10 @@ class Otto
527
596
  # Generate a CSP policy string with the provided nonce
528
597
  #
529
598
  # Thin facade over {Otto::Security::CSP::Policy.nonce_policy}; the directive
530
- # sets and report-uri/report-to assembly live there now. Output is
531
- # byte-identical to Otto's historical policy.
599
+ # sets and report-uri/report-to assembly live there now. Any configured
600
+ # {#csp_directive_overrides} are merged into the base directive set. Output
601
+ # is byte-identical to Otto's historical policy when no overrides or
602
+ # reporting are configured.
532
603
  #
533
604
  # @param nonce [String] The nonce value to include in the CSP
534
605
  # @param development_mode [Boolean] Whether to use development-friendly directives
@@ -538,7 +609,8 @@ class Otto
538
609
  nonce,
539
610
  development_mode: development_mode,
540
611
  report_uri: @csp_report_uri,
541
- report_to_url: @csp_report_to_url
612
+ report_to_url: @csp_report_to_url,
613
+ directive_overrides: @csp_directive_overrides
542
614
  )
543
615
  end
544
616
 
@@ -819,6 +891,29 @@ class Otto
819
891
  MSG
820
892
  end
821
893
 
894
+ # Warn once per config instance when a `script-src` override is stored for
895
+ # the nonce CSP policy. The per-request nonce is generated at response time
896
+ # and therefore cannot be present in a static override, so replacing (or
897
+ # removing) `script-src` necessarily strips the nonce from the emitted
898
+ # header — the browser then accepts any inline script the page carries the
899
+ # nonce attribute on, voiding nonce-based protection. Overriding any other
900
+ # directive (e.g. `worker-src`) is unaffected and stays silent.
901
+ #
902
+ # @param normalized [Hash] normalized (lowercased/hyphenated) overrides
903
+ # @return [void]
904
+ def warn_if_script_src_overridden(normalized)
905
+ return unless normalized.key?('script-src')
906
+ return if @csp_script_src_override_warned
907
+
908
+ @csp_script_src_override_warned = true
909
+ Otto.structured_log(:warn, <<~MSG.gsub(/\s+/, ' ').strip, directive: 'script-src')
910
+ [Otto::CSP] A script-src override was configured while nonce mode is in
911
+ use. The per-request nonce cannot be included in a static override, so
912
+ nonce-based script protection is disabled for this policy. Remove the
913
+ script-src override to keep nonce enforcement.
914
+ MSG
915
+ end
916
+
822
917
  # Freeze-time backstop: refuse to finalize a production configuration that
823
918
  # enables CSRF with a generated (non-configured) secret. Mirrors
824
919
  # #validate_trusted_proxy_config! so the failure surfaces at boot, before