otto 2.5.0 → 2.7.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -6
@@ -12,9 +12,26 @@ class Otto
12
12
  # This is the main orchestrator that:
13
13
  # - Sets anonymous StrategyResult for unauthenticated routes
14
14
  # - Enforces authentication for protected routes
15
- # - Supports multi-strategy with OR logic (first success wins)
15
+ # - Supports multi-strategy with OR logic (first authenticated success wins)
16
16
  # - Performs Layer 1 (route-level) role authorization
17
17
  #
18
+ # Multi-strategy chain semantics (in precedence order):
19
+ # - AUTHENTICATED success wins immediately; later strategies never run.
20
+ # - TERMINAL failure (AuthFailure with terminal: true — explicit
21
+ # credentials examined and rejected) halts the chain and fails closed,
22
+ # regardless of strategy order. An anonymous success from an
23
+ # anonymous-capable strategy elsewhere in the chain does not rescue the
24
+ # request. See AuthFailure.
25
+ # - ANONYMOUS success (StrategyResult with no user, e.g. from noauth) is
26
+ # held as a fallback while the rest of the chain runs. It wins once the
27
+ # chain completes without an authenticated success or terminal failure,
28
+ # so credential-less requests still fall through to noauth. When more
29
+ # than one strategy produces an anonymous success, the first declared
30
+ # is the fallback; later ones are deliberately ignored.
31
+ # - Plain failures are recorded and the next strategy is consulted
32
+ # (OR logic). If everything fails, an AuthorizationFailure (valid
33
+ # credential, denied) yields 403; otherwise 401.
34
+ #
18
35
  # @example Basic usage
19
36
  # wrapper = RouteAuthWrapper.new(handler, route_def, auth_config)
20
37
  # response = wrapper.call(env)
@@ -53,7 +70,8 @@ class Otto
53
70
  validation_error = validate_strategies(auth_requirements, env)
54
71
  return validation_error if validation_error
55
72
 
56
- # Try each strategy in order (first success wins)
73
+ # Try each strategy in order (first authenticated success wins;
74
+ # anonymous success is a fallback; terminal failure halts the chain)
57
75
  authenticate_and_authorize(env, extra_params, auth_requirements)
58
76
  end
59
77
 
@@ -83,8 +101,15 @@ class Otto
83
101
  end
84
102
 
85
103
  # Main authentication and authorization flow
104
+ #
105
+ # chain tracks two views of the run: :executed is every strategy that
106
+ # ran (including one whose anonymous success is merely held as the
107
+ # fallback), :failed only those that returned a failure result. Failure
108
+ # metadata reports :executed as attempted_strategies so a terminal halt
109
+ # after a deferred anonymous success doesn't under-report what ran.
86
110
  def authenticate_and_authorize(env, extra_params, auth_requirements)
87
- failed_strategies = []
111
+ chain = { failed: [], executed: [] }
112
+ anonymous_fallback = nil
88
113
  total_start_time = Otto::Utils.now_in_μs
89
114
 
90
115
  auth_requirements.each do |requirement|
@@ -96,39 +121,90 @@ class Otto
96
121
  start_time = Otto::Utils.now_in_μs
97
122
  result = strategy.authenticate(env, requirement)
98
123
  duration = Otto::Utils.now_in_μs - start_time
124
+ chain[:executed] << strategy_name
99
125
 
100
126
  # Inject strategy_name into result
101
127
  result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
102
128
 
103
- # Handle authentication success
104
- if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
129
+ # Handle authenticated success - wins immediately
130
+ if authenticated_result?(result)
105
131
  return handle_auth_success(env, extra_params, result, strategy_name,
106
- duration, total_start_time, failed_strategies)
132
+ duration, total_start_time, chain)
133
+ end
134
+
135
+ # An anonymous success (e.g. noauth) is held as a fallback rather
136
+ # than winning outright, so a credentialed strategy elsewhere in
137
+ # the chain still gets to examine explicitly presented credentials
138
+ # and reject them terminally, regardless of declaration order.
139
+ # When the chain completes without a terminal failure, the
140
+ # fallback wins (see below), preserving OR fallthrough for
141
+ # credential-less requests.
142
+ if anonymous_result?(result)
143
+ anonymous_fallback ||= { result: result, strategy_name: strategy_name, duration: duration }
144
+ next
107
145
  end
108
146
 
109
147
  # Handle a failure (authentication OR authorization) - record it and
110
148
  # continue to the next strategy (OR logic; a later success still wins).
111
149
  # AuthorizationFailure (valid credential, denied) is tagged so the
112
150
  # final response is 403 instead of 401. See handle_all_strategies_failed.
113
- next unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
151
+ # Anything else is a strategy-author bug (wrong return type): the
152
+ # chain skips it as if it failed without a reason, but leaves a
153
+ # trace — a silent drop makes those bugs hard to debug.
154
+ unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
155
+ log_unexpected_result(env, strategy_name, result)
156
+ next
157
+ end
114
158
 
115
159
  log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
116
- failed_strategies << {
117
- strategy: strategy_name,
118
- reason: result.failure_reason,
119
- authorization: result.is_a?(AuthorizationFailure),
120
- }
160
+
161
+ # A terminal failure means explicit credentials were examined and
162
+ # rejected: fail the whole chain closed (401) instead of letting an
163
+ # anonymous-capable strategy accept the request as anonymous.
164
+ if record_failure(chain[:failed], strategy_name, result)
165
+ return handle_all_strategies_failed(env, auth_requirements, chain,
166
+ total_start_time, terminal: true)
167
+ end
168
+ end
169
+
170
+ # Chain completed without authenticated success or terminal failure:
171
+ # a held anonymous success wins (OR fallthrough to noauth et al.)
172
+ if anonymous_fallback
173
+ return handle_auth_success(env, extra_params, anonymous_fallback[:result],
174
+ anonymous_fallback[:strategy_name],
175
+ anonymous_fallback[:duration],
176
+ total_start_time, chain)
121
177
  end
122
178
 
123
179
  # All strategies failed
124
- handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
180
+ handle_all_strategies_failed(env, auth_requirements, chain, total_start_time)
181
+ end
182
+
183
+ def authenticated_result?(result)
184
+ result.is_a?(StrategyResult) && result.authenticated?
185
+ end
186
+
187
+ def anonymous_result?(result)
188
+ result.is_a?(StrategyResult) && result.anonymous?
189
+ end
190
+
191
+ # Append the failure to the running list; returns true when it was terminal
192
+ def record_failure(failed_strategies, strategy_name, result)
193
+ terminal = result.is_a?(AuthFailure) && result.terminal?
194
+ failed_strategies << {
195
+ strategy: strategy_name,
196
+ reason: result.failure_reason,
197
+ authorization: result.is_a?(AuthorizationFailure),
198
+ terminal: terminal,
199
+ }
200
+ terminal
125
201
  end
126
202
 
127
203
  # Handle successful authentication
128
- def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, failed_strategies)
204
+ def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, chain)
129
205
  total_duration = Otto::Utils.now_in_μs - total_start_time
130
206
 
131
- log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
207
+ log_auth_success(env, strategy_name, result, duration, total_duration, chain)
132
208
 
133
209
  # Set environment variables for controllers/logic
134
210
  env['otto.strategy_result'] = result
@@ -148,14 +224,19 @@ class Otto
148
224
  wrapped_handler.call(env, extra_params)
149
225
  end
150
226
 
151
- # Handle case when all authentication strategies fail
152
- def handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
227
+ # Handle case when authentication fails for the whole chain — either
228
+ # every strategy failed, or a terminal failure halted the chain early
229
+ # (terminal: true; remaining strategies were deliberately not consulted).
230
+ # chain is the { failed:, executed: } state built by
231
+ # authenticate_and_authorize.
232
+ def handle_all_strategies_failed(env, auth_requirements, chain, total_start_time, terminal: false)
233
+ failed_strategies = chain[:failed]
153
234
  total_duration = Otto::Utils.now_in_μs - total_start_time
154
235
 
155
- log_all_failed(env, failed_strategies, total_duration)
236
+ log_all_failed(env, chain, total_duration, terminal: terminal)
156
237
 
157
238
  # Create anonymous result with failure info
158
- metadata = build_failure_metadata(env, failed_strategies)
239
+ metadata = build_failure_metadata(env, chain, terminal: terminal)
159
240
  failure_strategy_name = determine_failure_strategy_name(auth_requirements, failed_strategies)
160
241
 
161
242
  env['otto.strategy_result'] = StrategyResult.anonymous(
@@ -167,14 +248,18 @@ class Otto
167
248
  # authenticated the subject but denied authorization (wrong role/missing
168
249
  # permission), respond 403 Forbidden rather than 401 — the subject IS
169
250
  # authenticated, they simply lack access. A bare 401 would (incorrectly)
170
- # tell a logged-in client to re-authenticate.
251
+ # tell a logged-in client to re-authenticate. This precedence also holds
252
+ # on a terminal halt: the denial's 403 is the more specific outcome.
171
253
  authz_denial = failed_strategies.find { |f| f[:authorization] }
172
254
  return @response_builder.forbidden(env, authz_denial[:reason]) if authz_denial
173
255
 
256
+ # On a terminal halt the terminal failure is necessarily the last one
257
+ # recorded, so its reason is what gets rendered.
174
258
  last_failure = if failed_strategies.any?
175
259
  AuthFailure.new(
176
260
  failure_reason: failed_strategies.last[:reason],
177
- auth_method: failed_strategies.last[:strategy]
261
+ auth_method: failed_strategies.last[:strategy],
262
+ terminal: failed_strategies.last[:terminal] || false
178
263
  )
179
264
  else
180
265
  AuthFailure.new(
@@ -194,13 +279,21 @@ class Otto
194
279
  end
195
280
 
196
281
  # Build metadata for failed authentication
197
- def build_failure_metadata(env, failed_strategies)
282
+ #
283
+ # attempted_strategies lists every strategy that EXECUTED (including
284
+ # one whose anonymous success was held as the fallback and then
285
+ # overruled by a terminal failure); failure_reasons lists only the
286
+ # reasons of strategies that FAILED, in failure order. The two arrays
287
+ # are therefore not index-aligned.
288
+ def build_failure_metadata(env, chain, terminal: false)
289
+ summary = terminal ? 'Authentication halted by terminal failure' : 'All authentication strategies failed'
198
290
  metadata = {
199
291
  ip: env['otto.client_ip'] || env['REMOTE_ADDR'],
200
- auth_failure: 'All authentication strategies failed',
201
- attempted_strategies: failed_strategies.map { |f| f[:strategy] },
202
- failure_reasons: failed_strategies.map { |f| f[:reason] },
292
+ auth_failure: summary,
293
+ attempted_strategies: chain[:executed],
294
+ failure_reasons: chain[:failed].map { |f| f[:reason] },
203
295
  }
296
+ metadata[:terminal_failure] = true if terminal
204
297
  metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
205
298
  metadata
206
299
  end
@@ -228,7 +321,7 @@ class Otto
228
321
  ))
229
322
  end
230
323
 
231
- def log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
324
+ def log_auth_success(env, strategy_name, result, duration, total_duration, chain)
232
325
  Otto.structured_log(:info, 'Auth strategy result',
233
326
  Otto::LoggingHelpers.request_context(env).merge(
234
327
  strategy: strategy_name,
@@ -236,7 +329,11 @@ class Otto
236
329
  user_id: result.user_id,
237
330
  duration: duration,
238
331
  total_duration: total_duration,
239
- strategies_attempted: failed_strategies.size + 1
332
+ # Every strategy that ran, including the winner (already in
333
+ # chain[:executed] by the time a success is handled) and any
334
+ # held anonymous fallback — not just the failures. Same array
335
+ # shape as the failure log so consumers see one type.
336
+ strategies_attempted: chain[:executed]
240
337
  ))
241
338
  end
242
339
 
@@ -251,12 +348,22 @@ class Otto
251
348
  ))
252
349
  end
253
350
 
254
- def log_all_failed(env, failed_strategies, total_duration)
255
- Otto.structured_log(:warn, 'All auth strategies failed',
351
+ def log_unexpected_result(env, strategy_name, result)
352
+ Otto.structured_log(:warn, 'Auth strategy returned unexpected type',
353
+ Otto::LoggingHelpers.request_context(env).merge(
354
+ strategy: strategy_name,
355
+ result_class: result.class.name
356
+ ))
357
+ end
358
+
359
+ def log_all_failed(env, chain, total_duration, terminal: false)
360
+ message = terminal ? 'Auth chain halted by terminal failure' : 'All auth strategies failed'
361
+ Otto.structured_log(:warn, message,
256
362
  Otto::LoggingHelpers.request_context(env).merge(
257
- strategies_attempted: failed_strategies.map { |f| f[:strategy] },
363
+ strategies_attempted: chain[:executed],
258
364
  total_duration: total_duration,
259
- failure_count: failed_strategies.size
365
+ failure_count: chain[:failed].size,
366
+ terminal: terminal
260
367
  ))
261
368
  end
262
369
  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
 
@@ -214,6 +217,20 @@ class Otto
214
217
  end
215
218
  end
216
219
 
220
+ # Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
221
+ #
222
+ # This mirrors {#trusted_proxy?}, which consults the same matcher list, so
223
+ # the two answers stay consistent: a request can only be "via a trusted
224
+ # proxy" when matchers exist. It deliberately EXCLUDES count-based depth
225
+ # mode — depth resolves the client IP but never confers proxy identity
226
+ # trust (the same decoupling {Otto::Request#forwarded_by_trusted_proxy?}
227
+ # applies to X-Forwarded-Proto). Used to gate geo-header trust.
228
+ #
229
+ # @return [Boolean] true when at least one trusted-proxy matcher exists
230
+ def trusted_proxies_configured?
231
+ @trusted_proxy_matchers.any?
232
+ end
233
+
217
234
  # Whether count-based ("trust the last N hops") proxy resolution is active.
218
235
  #
219
236
  # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
@@ -367,19 +384,85 @@ class Otto
367
384
  # Unlike enable_csp!, this doesn't set a static policy but enables the response
368
385
  # helper to generate CSP headers with nonces on a per-request basis.
369
386
  #
387
+ # Per-directive overrides may be supplied to customize the emitted nonce
388
+ # policy without vendoring the gem. They merge into Otto's base directive
389
+ # sets ({Otto::Security::CSP::Policy.development_directives} /
390
+ # {Otto::Security::CSP::Policy.production_directives}): a matching directive
391
+ # is replaced in place, a new directive is appended, and a nil/false value
392
+ # removes a directive. See {#csp_directive_overrides=} for the accepted
393
+ # shape.
394
+ #
370
395
  # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
396
+ # @param directives [Hash] per-directive overrides merged into the base set
371
397
  # @return [void]
372
398
  # @raise [FrozenError] if configuration is frozen
373
399
  #
374
400
  # @example
375
401
  # config.enable_csp_with_nonce!(debug: true)
376
- def enable_csp_with_nonce!(debug: false)
402
+ #
403
+ # @example Restore data: workers (blob: is the default worker-src token)
404
+ # config.enable_csp_with_nonce!(directives: { 'worker-src' => "'self' data: blob:" })
405
+ def enable_csp_with_nonce!(debug: false, directives: {})
377
406
  ensure_not_frozen!
378
407
 
408
+ # Apply overrides before toggling state so a bad +directives+ argument
409
+ # raises without leaving the config half-updated (nonce enabled but
410
+ # overrides not merged).
411
+ merge_csp_directives(directives) unless directives.nil? || directives.empty?
379
412
  @csp_nonce_enabled = true
380
413
  @debug_csp = debug
381
414
  end
382
415
 
416
+ # Replace the per-directive overrides applied to the nonce CSP policy.
417
+ #
418
+ # Overrides merge into Otto's base directive sets when
419
+ # {#generate_nonce_csp} builds the policy, so a consuming app can adjust
420
+ # ANY directive rather than only `report-uri`/`report-to`. Keys are
421
+ # directive names (String or Symbol, matched case-insensitively); values
422
+ # are the source list as a String (`"'self' blob:"`) or Array
423
+ # (`%w['self' blob:]`), or nil/false to REMOVE the directive.
424
+ #
425
+ # Keys are normalized on store (lowercased, hyphenated) via
426
+ # {Otto::Security::CSP::Policy.normalize_overrides}, so the stored hash
427
+ # never accumulates logically-identical entries under different key styles
428
+ # (`'WORKER-SRC'` and `:worker_src` both read back as `'worker-src'`).
429
+ #
430
+ # @note Overriding `script-src` while nonce mode is enabled disables
431
+ # nonce-based script protection: the per-request nonce cannot be
432
+ # included in a static override, so it is stripped from the emitted
433
+ # header. A warning is logged when a `script-src` override is stored.
434
+ # See {Otto::Security::CSP::Policy.merge_directives}.
435
+ #
436
+ # @param overrides [Hash] directive name => source list / nil
437
+ # @return [void]
438
+ # @raise [FrozenError] if configuration is frozen
439
+ #
440
+ # @example
441
+ # config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }
442
+ def csp_directive_overrides=(overrides)
443
+ ensure_not_frozen!
444
+
445
+ normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
446
+ warn_if_script_src_overridden(normalized)
447
+ @csp_directive_overrides = normalized
448
+ end
449
+
450
+ # Merge additional per-directive overrides into the existing set, leaving
451
+ # untouched any directive not named in +overrides+ (last write wins for a
452
+ # repeated directive). Use this to accumulate overrides incrementally;
453
+ # use {#csp_directive_overrides=} to replace them wholesale.
454
+ #
455
+ # @param overrides [Hash] directive name => source list / nil
456
+ # @return [void]
457
+ # @raise [FrozenError] if configuration is frozen
458
+ def merge_csp_directives(overrides)
459
+ ensure_not_frozen!
460
+
461
+ normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
462
+ warn_if_script_src_overridden(normalized)
463
+ @csp_directive_overrides = @csp_directive_overrides.merge(normalized)
464
+ end
465
+
383
466
  # Disable CSP nonce support
384
467
  #
385
468
  # @return [void]
@@ -527,8 +610,10 @@ class Otto
527
610
  # Generate a CSP policy string with the provided nonce
528
611
  #
529
612
  # 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.
613
+ # sets and report-uri/report-to assembly live there now. Any configured
614
+ # {#csp_directive_overrides} are merged into the base directive set. Output
615
+ # is byte-identical to Otto's historical policy when no overrides or
616
+ # reporting are configured.
532
617
  #
533
618
  # @param nonce [String] The nonce value to include in the CSP
534
619
  # @param development_mode [Boolean] Whether to use development-friendly directives
@@ -538,7 +623,8 @@ class Otto
538
623
  nonce,
539
624
  development_mode: development_mode,
540
625
  report_uri: @csp_report_uri,
541
- report_to_url: @csp_report_to_url
626
+ report_to_url: @csp_report_to_url,
627
+ directive_overrides: @csp_directive_overrides
542
628
  )
543
629
  end
544
630
 
@@ -696,12 +782,20 @@ class Otto
696
782
  # trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
697
783
  # store a nil range and fall back to prefix/regexp matching.
698
784
  #
785
+ # The parsed range is folded through IPAddr#native at registration, to
786
+ # match the fold trusted_proxy? applies to the client address. Without
787
+ # it a mapped-IPv6 proxy entry (::ffff:10.0.0.0/104) could never match,
788
+ # because ip_in_range?'s family check would reject the folded IPv4
789
+ # client — a proxy silently untrusted, which is what gates
790
+ # otto.via_trusted_proxy, secure?, and geo-header trust. #native returns
791
+ # self for entries that are not IPv4-mapped/compatible.
792
+ #
699
793
  # @param entry [String, Regexp, Object] trusted proxy entry being added
700
794
  # @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
701
795
  def register_proxy_matcher(entry)
702
796
  return [entry, nil] unless entry.is_a?(String)
703
797
 
704
- range = parse_ipaddr(entry)
798
+ range = parse_ipaddr(entry)&.native
705
799
  warn_legacy_proxy_entry(entry) unless range
706
800
  [entry, range]
707
801
  end
@@ -819,6 +913,29 @@ class Otto
819
913
  MSG
820
914
  end
821
915
 
916
+ # Warn once per config instance when a `script-src` override is stored for
917
+ # the nonce CSP policy. The per-request nonce is generated at response time
918
+ # and therefore cannot be present in a static override, so replacing (or
919
+ # removing) `script-src` necessarily strips the nonce from the emitted
920
+ # header — the browser then accepts any inline script the page carries the
921
+ # nonce attribute on, voiding nonce-based protection. Overriding any other
922
+ # directive (e.g. `worker-src`) is unaffected and stays silent.
923
+ #
924
+ # @param normalized [Hash] normalized (lowercased/hyphenated) overrides
925
+ # @return [void]
926
+ def warn_if_script_src_overridden(normalized)
927
+ return unless normalized.key?('script-src')
928
+ return if @csp_script_src_override_warned
929
+
930
+ @csp_script_src_override_warned = true
931
+ Otto.structured_log(:warn, <<~MSG.gsub(/\s+/, ' ').strip, directive: 'script-src')
932
+ [Otto::CSP] A script-src override was configured while nonce mode is in
933
+ use. The per-request nonce cannot be included in a static override, so
934
+ nonce-based script protection is disabled for this policy. Remove the
935
+ script-src override to keep nonce enforcement.
936
+ MSG
937
+ end
938
+
822
939
  # Freeze-time backstop: refuse to finalize a production configuration that
823
940
  # enables CSRF with a generated (non-configured) secret. Mirrors
824
941
  # #validate_trusted_proxy_config! so the failure surfaces at boot, before
@@ -181,7 +181,10 @@ class Otto
181
181
  # The middleware is pinned to run OUTERMOST (ahead of CSRF and every other
182
182
  # middleware), so it short-circuits report POSTs before CSRF validation —
183
183
  # browsers can post reports without a CSRF token. This holds regardless of
184
- # the order in which you enable security features.
184
+ # the order in which you enable security features. The one thing that runs
185
+ # ahead of it is IPPrivacyMiddleware, pinned to the outer :entrypoint tier
186
+ # so nothing observes a raw client IP; being a pass-through, it cannot
187
+ # affect the short-circuit.
185
188
  #
186
189
  # SECURITY / DoS: running outermost also means the receiver sits ahead of
187
190
  # rate limiting (rate limiting is inner middleware). This is intentional —
@@ -43,9 +43,14 @@ class Otto
43
43
  # @param report_to_url [String, nil] absolute URL configured for the
44
44
  # modern Reporting API; its presence (not its value) toggles the
45
45
  # `report-to <group>` directive (omitted when nil/empty)
46
+ # @param directive_overrides [Hash, nil] per-directive overrides merged
47
+ # into the base set before reporting directives are appended. See
48
+ # {.merge_directives} for the accepted shape (replace a directive's
49
+ # sources, add a new directive, or remove one with a nil/false value).
46
50
  # @return [String] complete CSP policy string
47
- def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil)
51
+ def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
48
52
  directives = development_mode ? development_directives(nonce) : production_directives(nonce)
53
+ directives = merge_directives(directives, directive_overrides)
49
54
  uri_directive = report_uri_directive(report_uri)
50
55
  to_directive = report_to_directive(report_to_url)
51
56
  directives += ["#{uri_directive};"] if uri_directive
@@ -88,6 +93,133 @@ class Otto
88
93
  "report-to #{REPORTING_GROUP}"
89
94
  end
90
95
 
96
+ # Merge per-directive overrides into a base directive set.
97
+ #
98
+ # This is the customization seam the hardcoded directive sets previously
99
+ # lacked: a consuming app can adjust ANY directive (e.g. re-allow
100
+ # `data:` workers via `worker-src 'self' data: blob:`) without
101
+ # vendoring the gem. Order is
102
+ # preserved — an override that matches an existing directive replaces it
103
+ # in place; an override for a directive not in the base set is appended
104
+ # after the base directives (before any reporting directives).
105
+ #
106
+ # Override values:
107
+ # - a String → the directive's source list verbatim, e.g.
108
+ # `'worker-src' => "'self' blob:"` yields `worker-src 'self' blob:;`
109
+ # - an Array → sources joined with a single space, e.g.
110
+ # `%w['self' blob:]`
111
+ # - `nil`/`false` → REMOVE the directive from the emitted policy
112
+ #
113
+ # Directive names are matched case-insensitively (CSP directive names are
114
+ # case-insensitive) and may be given as Strings or Symbols.
115
+ #
116
+ # @note The per-request nonce is embedded in `script-src` (production)
117
+ # and cannot be reproduced in a static override, so replacing (or
118
+ # removing) `script-src` strips the nonce from the emitted header and
119
+ # DEFEATS nonce protection: the browser then accepts any inline script
120
+ # the page carries the nonce attribute on. Overriding `script-src`
121
+ # while nonce mode is enabled therefore disables nonce enforcement;
122
+ # {Otto::Security::Config} logs a warning when such an override is
123
+ # configured. Override other directives freely.
124
+ #
125
+ # @param directives [Array<String>] base directive strings, each `;`-terminated
126
+ # @param overrides [Hash, nil] directive name => source list / nil
127
+ # @return [Array<String>] merged directive strings, each `;`-terminated
128
+ # @raise [ArgumentError] if an override name or source token contains a
129
+ # `;`, newline, or carriage return (see {.build_directive})
130
+ def merge_directives(directives, overrides)
131
+ return directives if overrides.nil? || overrides.empty?
132
+
133
+ normalized = normalize_overrides(overrides)
134
+ consumed = {}
135
+
136
+ merged = directives.filter_map do |directive|
137
+ name = directive_name(directive)
138
+ next directive unless normalized.key?(name)
139
+
140
+ consumed[name] = true
141
+ build_directive(name, normalized[name])
142
+ end
143
+
144
+ normalized.each do |name, value|
145
+ next if consumed[name]
146
+
147
+ appended = build_directive(name, value)
148
+ merged << appended if appended
149
+ end
150
+
151
+ merged
152
+ end
153
+
154
+ # Normalize an overrides hash to lowercased, hyphenated String keys so
155
+ # lookups are case-insensitive and Symbol/String keys are
156
+ # interchangeable. Underscores map to hyphens (no CSP directive contains
157
+ # an underscore) so a Symbol key like `:worker_src` addresses the
158
+ # `worker-src` directive. Blank keys are dropped.
159
+ #
160
+ # @param overrides [Hash]
161
+ # @return [Hash{String=>Object}]
162
+ def normalize_overrides(overrides)
163
+ overrides.each_with_object({}) do |(key, value), acc|
164
+ name = key.to_s.strip.downcase.tr('_', '-')
165
+ acc[name] = value unless name.empty?
166
+ end
167
+ end
168
+
169
+ # The directive name (first token) of a `;`-terminated directive string,
170
+ # lowercased for case-insensitive matching.
171
+ #
172
+ # @param directive [String]
173
+ # @return [String]
174
+ def directive_name(directive)
175
+ directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
176
+ end
177
+
178
+ # Build a single `;`-terminated directive string from a name and an
179
+ # override value, or nil when the value signals removal (nil/false).
180
+ #
181
+ # The directive name and each source token are validated against CSP's
182
+ # separator characters: a name or token containing `;` (which separates
183
+ # directives), a newline, or a carriage return raises {ArgumentError}
184
+ # rather than silently injecting extra directives — a real footgun when
185
+ # overrides come from env/config files. (The `false` removal sentinel is
186
+ # checked before {Array} so a bare `false` never becomes a `[false]`
187
+ # source list.)
188
+ #
189
+ # @param name [String] directive name
190
+ # @param value [String, Array, nil, false] source list, or nil/false to remove
191
+ # @return [String, nil]
192
+ # @raise [ArgumentError] if the name or a source token contains a `;`,
193
+ # newline, or carriage return
194
+ def build_directive(name, value)
195
+ return nil if value.nil? || value == false
196
+
197
+ reject_injection!('directive name', name)
198
+ sources = Array(value).filter_map do |token|
199
+ str = token.to_s.strip
200
+ next if str.empty?
201
+
202
+ reject_injection!("source for #{name}", str)
203
+ str
204
+ end.join(' ')
205
+ sources.empty? ? "#{name};" : "#{name} #{sources};"
206
+ end
207
+
208
+ # Raise {ArgumentError} when +text+ carries a CSP directive/token
209
+ # separator (`;`, newline, or carriage return) that would let an
210
+ # override break out of its directive and inject another.
211
+ #
212
+ # @param label [String] what is being validated (for the error message)
213
+ # @param text [String]
214
+ # @return [void]
215
+ # @raise [ArgumentError] if +text+ contains `;`, `\n`, or `\r`
216
+ def reject_injection!(label, text)
217
+ return unless text.match?(/[;\r\n]/)
218
+
219
+ raise ArgumentError,
220
+ "invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return"
221
+ end
222
+
91
223
  # CSP directives for the development environment.
92
224
  #
93
225
  # Development mode allows inline scripts/styles and hot reloading
@@ -108,7 +240,7 @@ class Otto
108
240
  "form-action 'self';",
109
241
  "frame-ancestors 'none';",
110
242
  "manifest-src 'self';",
111
- "worker-src 'self' data:;",
243
+ "worker-src 'self' blob:;",
112
244
  ]
113
245
  end
114
246
 
@@ -132,7 +264,7 @@ class Otto
132
264
  "form-action 'self';", # Restrict form submissions to same origin
133
265
  "frame-ancestors 'none';", # Prevent site from being embedded in frames
134
266
  "manifest-src 'self';", # Allow web app manifests from same origin
135
- "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
267
+ "worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs
136
268
  ]
137
269
  end
138
270
  end
@@ -27,7 +27,9 @@ class Otto
27
27
  # with no CSRF token: the report never reaches the CSRF middleware.
28
28
  # {Otto::Security::Core#enable_csp_reporting!} pins this middleware
29
29
  # OUTERMOST (via the :outermost stack position), so the guarantee holds
30
- # regardless of the order security features are enabled in. The flip side
30
+ # regardless of the order security features are enabled in. Only
31
+ # IPPrivacyMiddleware (pinned to the outer :entrypoint tier) runs ahead
32
+ # of it, and it is a pass-through. The flip side
31
33
  # is that reports also bypass rate limiting — see the DoS note on
32
34
  # {Otto::Security::Core#enable_csp_reporting!}; keep callbacks cheap.
33
35
  # - Enforces a hard {MAX_BODY_BYTES} body cap. Oversized bodies are