otto 2.3.1 → 2.5.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +1 -1
  3. data/.github/workflows/ci.yml +7 -1
  4. data/.github/workflows/claude-code-review.yml +32 -9
  5. data/.github/workflows/claude.yml +7 -5
  6. data/.github/workflows/code-smells.yml +2 -2
  7. data/.github/workflows/release-gem.yml +12 -2
  8. data/.github/workflows/ruby-lint.yml +66 -0
  9. data/.github/workflows/yardoc.yml +117 -0
  10. data/.yardopts +15 -0
  11. data/CHANGELOG.rst +129 -0
  12. data/Gemfile +4 -2
  13. data/Gemfile.lock +23 -17
  14. data/README.md +150 -0
  15. data/docs/.gitignore +1 -0
  16. data/docs/reverse-proxy-network-services.md +358 -0
  17. data/examples/caddy_tls_demo/README.md +100 -0
  18. data/examples/caddy_tls_demo/app.rb +41 -0
  19. data/examples/caddy_tls_demo/config.ru +31 -0
  20. data/examples/caddy_tls_demo/routes +9 -0
  21. data/examples/caddy_tls_demo/standalone.ru +38 -0
  22. data/lib/otto/caddy_tls/core.rb +74 -0
  23. data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
  24. data/lib/otto/caddy_tls/server.rb +149 -0
  25. data/lib/otto/caddy_tls.rb +7 -0
  26. data/lib/otto/core/middleware_management.rb +7 -7
  27. data/lib/otto/core/middleware_stack.rb +40 -5
  28. data/lib/otto/core/router.rb +4 -8
  29. data/lib/otto/env_keys.rb +10 -0
  30. data/lib/otto/request.rb +14 -0
  31. data/lib/otto/response.rb +80 -43
  32. data/lib/otto/security/config.rb +234 -51
  33. data/lib/otto/security/configurator.rb +54 -0
  34. data/lib/otto/security/core.rb +94 -0
  35. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  36. data/lib/otto/security/csp/nonce.rb +78 -0
  37. data/lib/otto/security/csp/parser.rb +120 -0
  38. data/lib/otto/security/csp/policy.rb +141 -0
  39. data/lib/otto/security/csp/report.rb +147 -0
  40. data/lib/otto/security/csp/report_middleware.rb +120 -0
  41. data/lib/otto/security/csp/writer.rb +197 -0
  42. data/lib/otto/security/csp.rb +31 -0
  43. data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
  44. data/lib/otto/security.rb +1 -0
  45. data/lib/otto/utils.rb +36 -0
  46. data/lib/otto/version.rb +1 -1
  47. data/lib/otto.rb +26 -3
  48. metadata +27 -3
data/lib/otto/response.rb CHANGED
@@ -14,12 +14,18 @@ class Otto
14
14
  # @example Using Otto's response in route handlers
15
15
  # def show(req, res)
16
16
  # res.send_secure_cookie('session_id', token, 3600)
17
- # res.send_csp_headers('text/html', nonce)
17
+ # res.apply_csp(req.csp_nonce)
18
18
  # res.no_cache!
19
19
  # end
20
20
  #
21
21
  # @see Otto#register_response_helpers
22
22
  class Response < Rack::Response
23
+ # One-time-per-process guard for the #send_csp_headers deprecation warning.
24
+ @send_csp_headers_deprecation_warned = false
25
+ class << self
26
+ attr_accessor :send_csp_headers_deprecation_warned # rubocop:disable ThreadSafety/ClassAndModuleAttributes
27
+ end
28
+
23
29
  # Reference to the request object (needed by some response helpers)
24
30
  # @return [Otto::Request]
25
31
  attr_accessor :request
@@ -97,56 +103,65 @@ class Otto
97
103
  headers
98
104
  end
99
105
 
100
- # Set Content Security Policy (CSP) headers with nonce support
106
+ # Apply a nonce-based Content-Security-Policy to this response.
107
+ #
108
+ # This is THE emission helper: it routes through the single apply core
109
+ # ({Otto::Security::CSP::Writer}), so all the invariants — enabled-only,
110
+ # nonce-present, HTML-only, lowercase key, no duplicate — hold here exactly as
111
+ # they do in the middleware, with no guard logic duplicated. The response's
112
+ # Content-Type must already be set (it decides HTML-only); this helper does
113
+ # NOT set it.
114
+ #
115
+ # `mode: :override` (the default) is the deliberate per-request call: it
116
+ # REPLACES any existing CSP. Pass `mode: :backstop` to defer to an existing
117
+ # policy instead.
101
118
  #
102
- # This method generates and sets CSP headers with the provided nonce value,
103
- # following the same usage pattern as send_cookie methods. The CSP policy
104
- # is generated dynamically based on the security configuration and environment.
119
+ # @param nonce [String] the per-request nonce (typically {Otto::Request#csp_nonce})
120
+ # @param mode [Symbol] `:override` or `:backstop` (see {Otto::Security::CSP::Writer::MODES})
121
+ # @param development_mode [Boolean] use development-friendly CSP directives
122
+ # @param security_config [Otto::Security::Config, nil] config to use; resolved
123
+ # from the request env when omitted
124
+ # @return [Otto::Security::CSP::Writer::Result] the outcome (applied?, policy,
125
+ # skip_reason) for uniform observability
105
126
  #
106
- # @param content_type [String] Content-Type header value to set
127
+ # @example
128
+ # res['content-type'] = 'text/html; charset=utf-8'
129
+ # res.apply_csp(req.csp_nonce)
130
+ def apply_csp(nonce, mode: :override, development_mode: false, security_config: nil)
131
+ config = security_config || (request&.env && request.env['otto.security_config'])
132
+ Otto::Security::CSP::Writer.apply(
133
+ headers, nonce,
134
+ config: config, mode: mode, development_mode: development_mode
135
+ )
136
+ end
137
+
138
+ # @deprecated Use {#apply_csp} instead. Retained as a thin shim over the apply
139
+ # core so existing callers keep working while its historical quirks are
140
+ # fixed: a nil/empty nonce no longer emits a broken `script-src 'nonce-'`
141
+ # (it skips), a CSP is no longer emitted for non-HTML responses, and the
142
+ # override notice goes through {Otto.logger} instead of a bare `warn` to
143
+ # stderr. Unlike {#apply_csp}, it still sets the Content-Type for you and
144
+ # emits in `:override` mode.
145
+ #
146
+ # @param content_type [String] Content-Type to set if not already set
107
147
  # @param nonce [String] Nonce value to include in CSP directives
108
- # @param opts [Hash] Options for CSP generation
148
+ # @param opts [Hash] Options
109
149
  # @option opts [Otto::Security::Config] :security_config Security config to use
110
150
  # @option opts [Boolean] :development_mode Use development-friendly CSP directives
111
- # @option opts [Boolean] :debug Enable debug logging for this request
112
- # @return [void]
113
- #
114
- # @example Basic usage
115
- # nonce = SecureRandom.base64(16)
116
- # res.send_csp_headers('text/html; charset=utf-8', nonce)
117
- #
118
- # @example With options
119
- # res.send_csp_headers('text/html; charset=utf-8', nonce, {
120
- # development_mode: Rails.env.development?,
121
- # debug: true
122
- # })
151
+ # @return [Otto::Security::CSP::Writer::Result]
123
152
  def send_csp_headers(content_type, nonce, opts = {})
124
- # Set content type if not already set
125
- headers['content-type'] ||= content_type
153
+ warn_send_csp_headers_deprecated
126
154
 
127
- # Warn if CSP header already exists but don't skip
128
- warn 'CSP header already set, overriding with nonce-based policy' if headers['content-security-policy']
129
-
130
- # Get security configuration
131
- security_config = opts[:security_config] ||
132
- (request&.env && request.env['otto.security_config']) ||
133
- nil
134
-
135
- # Skip if CSP nonce support is not enabled
136
- return unless security_config&.csp_nonce_enabled?
137
-
138
- # Generate CSP policy with nonce
139
- development_mode = opts[:development_mode] || false
140
- csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
141
-
142
- # Debug logging if enabled
143
- debug_enabled = opts[:debug] || security_config.debug_csp?
144
- if debug_enabled && defined?(Otto.logger)
145
- Otto.logger.debug "[CSP] #{csp_policy}"
146
- end
155
+ # Historical behavior the shim keeps (apply_csp does not): default the
156
+ # Content-Type so an HTML response is recognized as HTML.
157
+ headers['content-type'] ||= content_type
147
158
 
148
- # Set the CSP header
149
- headers['content-security-policy'] = csp_policy
159
+ apply_csp(
160
+ nonce,
161
+ mode: :override,
162
+ development_mode: opts[:development_mode] || false,
163
+ security_config: opts[:security_config]
164
+ )
150
165
  end
151
166
 
152
167
  # Set cache control headers to prevent caching
@@ -187,5 +202,27 @@ class Otto
187
202
  paths.unshift(request.env['SCRIPT_NAME']) if request&.env&.[]('SCRIPT_NAME')
188
203
  paths.join('/').gsub('//', '/')
189
204
  end
205
+
206
+ private
207
+
208
+ # Emit the #send_csp_headers deprecation notice at most once per process
209
+ # (Response is per-request, so the guard lives on the class).
210
+ #
211
+ # The check-then-set on the class flag is deliberately unsynchronized: the
212
+ # race is benign — worst case, two threads racing on the very first call each
213
+ # log the notice once. The flag gates only a log line, never any behavior, so
214
+ # a mutex would add contention on a hot path to save at most a couple of
215
+ # duplicate deprecation lines at startup.
216
+ def warn_send_csp_headers_deprecated
217
+ return if self.class.send_csp_headers_deprecation_warned
218
+ return unless defined?(Otto.logger) && Otto.logger
219
+
220
+ self.class.send_csp_headers_deprecation_warned = true
221
+ Otto.logger.warn(
222
+ '[Otto::Response] #send_csp_headers is deprecated and will be removed in a ' \
223
+ 'future release; use #apply_csp(nonce, mode: :override) (set Content-Type first), ' \
224
+ 'or mount Otto::Security::CSP::EmitMiddleware via #enable_csp_emission!.'
225
+ )
226
+ end
190
227
  end
191
228
  end
@@ -7,6 +7,7 @@ require 'digest'
7
7
  require 'openssl'
8
8
  require 'ipaddr'
9
9
  require_relative '../core/freezable'
10
+ require_relative 'csp/policy'
10
11
 
11
12
  class Otto
12
13
  module Security
@@ -44,6 +45,13 @@ class Otto
44
45
  # depth mode; CIDR-walk is unaffected.
45
46
  TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
46
47
 
48
+ # Endpoint group name shared by the CSP `report-to` directive and the
49
+ # `Reporting-Endpoints` response header (modern Reporting API). Browsers
50
+ # match the directive's group to the header's key, so both must agree.
51
+ # Aliases {Otto::Security::CSP::Policy::REPORTING_GROUP} — the one source
52
+ # the policy builder uses — so the header and the directive cannot drift.
53
+ CSP_REPORTING_GROUP = Otto::Security::CSP::Policy::REPORTING_GROUP
54
+
47
55
  # Error raised when CSRF protection is enabled in production without an
48
56
  # explicitly configured secret. A randomly-generated per-process secret
49
57
  # silently breaks token verification across workers and restarts, so we
@@ -62,8 +70,9 @@ class Otto
62
70
  attr_reader :csrf_protection, :csrf_header_key,
63
71
  :trusted_proxies, :require_secure_cookies,
64
72
  :security_headers,
65
- :csp_nonce_enabled, :debug_csp, :mcp_auth,
66
- :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header
73
+ :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
74
+ :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
75
+ :csp_report_uri, :csp_report_to_url, :csp_violation_callback
67
76
 
68
77
  # Initialize security configuration with safe defaults
69
78
  #
@@ -86,6 +95,11 @@ class Otto
86
95
  @input_validation = true
87
96
  @csp_nonce_enabled = false
88
97
  @debug_csp = false
98
+ @csp_nonce_key = 'otto.nonce'
99
+ @csp_policy = nil
100
+ @csp_report_uri = nil
101
+ @csp_report_to_url = nil
102
+ @csp_violation_callback = nil
89
103
  @rate_limiting_config = { custom_rules: {} }
90
104
  @ip_privacy_config = Otto::Privacy::Config.new
91
105
 
@@ -343,7 +357,8 @@ class Otto
343
357
  def enable_csp!(policy = "default-src 'self'")
344
358
  ensure_not_frozen!
345
359
 
346
- @security_headers['content-security-policy'] = policy
360
+ @csp_policy = policy
361
+ @security_headers['content-security-policy'] = build_static_csp(policy)
347
362
  end
348
363
 
349
364
  # Enable Content Security Policy (CSP) with nonce support
@@ -382,6 +397,22 @@ class Otto
382
397
  @csp_nonce_enabled
383
398
  end
384
399
 
400
+ # Set the Rack env key the framework-owned lazy nonce is memoized under
401
+ # ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
402
+ # `'otto.nonce'`; override it for an app with an existing convention (e.g.
403
+ # `'onetime.nonce'`) so the accessor adopts that app's env key without a
404
+ # rename. A blank value resets to the default.
405
+ #
406
+ # @param key [String] the env key
407
+ # @return [void]
408
+ # @raise [FrozenError] if configuration is frozen
409
+ def csp_nonce_key=(key)
410
+ ensure_not_frozen!
411
+
412
+ normalized = key.to_s.strip
413
+ @csp_nonce_key = normalized.empty? ? 'otto.nonce' : normalized
414
+ end
415
+
385
416
  # Check if CSP debug logging is enabled
386
417
  #
387
418
  # @return [Boolean] true if CSP debug logging is enabled
@@ -389,14 +420,126 @@ class Otto
389
420
  @debug_csp
390
421
  end
391
422
 
423
+ # Configure the path browsers should POST CSP violation reports to.
424
+ #
425
+ # Setting this does two things:
426
+ # 1. A `report-uri <path>` directive is appended to every emitted CSP
427
+ # policy — both the static policy from {#enable_csp!} and the per-request
428
+ # nonce policy from {#generate_nonce_csp} — so browsers know where to
429
+ # send violations.
430
+ # 2. {Otto::Security::CSP::ReportMiddleware} activates for that path (it is
431
+ # inert until a report URI is set).
432
+ #
433
+ # When nil/empty (the default), NO reporting directive is emitted and the
434
+ # policy output is byte-identical to Otto's historical output.
435
+ #
436
+ # For the turnkey setup that also injects the receiving middleware, prefer
437
+ # {Otto::Security::Core#enable_csp_reporting!} on the Otto instance.
438
+ #
439
+ # @param uri [String, nil] path browsers POST reports to (matched against
440
+ # `PATH_INFO`, e.g. `/_/csp-report`), or nil to disable reporting. A
441
+ # value without a leading slash is coerced to an absolute path so it
442
+ # matches the slash-prefixed `PATH_INFO` the middleware compares against.
443
+ # @return [void]
444
+ # @raise [FrozenError] if configuration is frozen
445
+ def csp_report_uri=(uri)
446
+ ensure_not_frozen!
447
+
448
+ @csp_report_uri = normalize_report_path(uri)
449
+ rebuild_static_csp_with_reporting!
450
+ end
451
+
452
+ # Configure the absolute URL browsers should POST CSP violation reports to
453
+ # via the modern Reporting API (Reporting-Endpoints header + `report-to`
454
+ # directive), complementing the legacy path-based {#csp_report_uri=}.
455
+ #
456
+ # Setting this does two things:
457
+ # 1. A `report-to #{CSP_REPORTING_GROUP}` directive is appended to every
458
+ # emitted CSP policy (static and per-request nonce alike), and a
459
+ # `Reporting-Endpoints` response header maps that group to this URL.
460
+ # 2. Modern browsers (which have deprecated `report-uri`) deliver reports
461
+ # as `application/reports+json` to this endpoint — already parsed by
462
+ # {Otto::Security::CSP::Parser}.
463
+ #
464
+ # The value MUST be an ABSOLUTE URL (Reporting-Endpoints does not accept a
465
+ # bare path). Point it at the same receiver as {#csp_report_uri=}: its path
466
+ # component should equal the report URI so {Otto::Security::CSP::ReportMiddleware}
467
+ # (which matches on PATH_INFO) intercepts modern reports too.
468
+ #
469
+ # When nil/empty (the default), NO `report-to` directive or
470
+ # `Reporting-Endpoints` header is emitted and policy output is
471
+ # byte-identical to Otto's historical output.
472
+ #
473
+ # @param url [String, nil] absolute URL for the Reporting API endpoint, or
474
+ # nil to disable modern reporting.
475
+ # @return [void]
476
+ # @raise [FrozenError] if configuration is frozen
477
+ def csp_report_to_url=(url)
478
+ ensure_not_frozen!
479
+
480
+ @csp_report_to_url = normalize_report_uri(url)
481
+ if @csp_report_to_url
482
+ @security_headers['reporting-endpoints'] = reporting_endpoints_header
483
+ else
484
+ @security_headers.delete('reporting-endpoints')
485
+ end
486
+ rebuild_static_csp_with_reporting!
487
+ end
488
+
489
+ # Register the callback invoked once per parsed CSP violation report.
490
+ #
491
+ # The block receives an {Otto::Security::CSP::Report}. Your application
492
+ # decides what to do — log, emit a metric, store, forward, or ignore. Otto
493
+ # adds no storage or database coupling.
494
+ #
495
+ # Registering a second callback REPLACES the first (last registration
496
+ # wins), matching the singular `on_csp_violation` semantics. Calling this
497
+ # with NO block clears (unregisters) any previously-set callback.
498
+ #
499
+ # SECURITY NOTE: report URL fields may carry sensitive path/query data in
500
+ # some applications. Redact them in your callback before logging if needed;
501
+ # Otto passes them through un-redacted (see {Otto::Security::CSP::Report}).
502
+ #
503
+ # @yieldparam report [Otto::Security::CSP::Report] a normalized report
504
+ # @return [void]
505
+ # @raise [FrozenError] if configuration is frozen
506
+ def on_csp_violation(&block)
507
+ ensure_not_frozen!
508
+
509
+ @csp_violation_callback = block
510
+ end
511
+
512
+ # Invoke the registered violation callback for a report, isolating any
513
+ # error it raises. A misbehaving application callback must never break the
514
+ # report receiver (which always answers 204).
515
+ #
516
+ # @param report [Otto::Security::CSP::Report]
517
+ # @return [void]
518
+ def dispatch_csp_violation(report)
519
+ callback = @csp_violation_callback
520
+ return if callback.nil?
521
+
522
+ callback.call(report)
523
+ rescue StandardError => e
524
+ Otto.logger.error("[Otto::CSP] violation callback raised #{e.class}: #{e.message}")
525
+ end
526
+
392
527
  # Generate a CSP policy string with the provided nonce
393
528
  #
529
+ # 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.
532
+ #
394
533
  # @param nonce [String] The nonce value to include in the CSP
395
534
  # @param development_mode [Boolean] Whether to use development-friendly directives
396
535
  # @return [String] Complete CSP policy string
397
536
  def generate_nonce_csp(nonce, development_mode: false)
398
- directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
399
- directives.join(' ')
537
+ Otto::Security::CSP::Policy.nonce_policy(
538
+ nonce,
539
+ development_mode: development_mode,
540
+ report_uri: @csp_report_uri,
541
+ report_to_url: @csp_report_to_url
542
+ )
400
543
  end
401
544
 
402
545
  # Enable X-Frame-Options header to prevent clickjacking
@@ -705,52 +848,92 @@ class Otto
705
848
  defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
706
849
  end
707
850
 
708
- # Generate CSP directives for development environment
709
- #
710
- # Development mode allows inline scripts/styles and hot reloading connections
711
- # for better developer experience with build tools like Vite.
712
- #
713
- # @param nonce [String] The nonce value to include in script-src
714
- # @return [Array<String>] Array of CSP directive strings
715
- def development_csp_directives(nonce)
716
- [
717
- "default-src 'none';",
718
- "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
719
- "style-src 'self' 'unsafe-inline';",
720
- "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
721
- "img-src 'self' data:;",
722
- "font-src 'self';",
723
- "object-src 'none';",
724
- "base-uri 'self';",
725
- "form-action 'self';",
726
- "frame-ancestors 'none';",
727
- "manifest-src 'self';",
728
- "worker-src 'self' data:;",
729
- ]
730
- end
731
-
732
- # Generate CSP directives for production environment
733
- #
734
- # Production mode is more restrictive, only allowing HTTPS connections
735
- # and nonce-only scripts for enhanced XSS protection.
736
- #
737
- # @param nonce [String] The nonce value to include in script-src
738
- # @return [Array<String>] Array of CSP directive strings
739
- def production_csp_directives(nonce)
740
- [
741
- "default-src 'none';", # Restrict to same origin by default
742
- "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
743
- "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
744
- "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
745
- "img-src 'self' data:;", # Allow images from same origin and data URIs
746
- "font-src 'self';", # Allow fonts from same origin only
747
- "object-src 'none';", # Block <object>, <embed>, and <applet> elements
748
- "base-uri 'self';", # Restrict <base> tag targets to same origin
749
- "form-action 'self';", # Restrict form submissions to same origin
750
- "frame-ancestors 'none';", # Prevent site from being embedded in frames
751
- "manifest-src 'self';", # Allow web app manifests from same origin
752
- "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
753
- ]
851
+ # Normalize a configured report URI: strip surrounding whitespace and
852
+ # treat a blank string as "not configured" (nil).
853
+ #
854
+ # @param uri [String, nil]
855
+ # @return [String, nil]
856
+ def normalize_report_uri(uri)
857
+ return nil if uri.nil?
858
+
859
+ stripped = uri.to_s.strip
860
+ stripped.empty? ? nil : stripped
861
+ end
862
+
863
+ # Normalize a configured report PATH: the local endpoint the receiver
864
+ # matches on `PATH_INFO`. Same strip/blank-to-nil handling as
865
+ # {#normalize_report_uri}, but a bare relative value is coerced to an
866
+ # absolute path — a value like `"csp-report"` would otherwise (a) never
867
+ # equal the slash-prefixed `PATH_INFO` the middleware compares against, and
868
+ # (b) be resolved by browsers relative to the document URL. An absolute URL
869
+ # (contains a scheme) is left untouched.
870
+ #
871
+ # @param uri [String, nil]
872
+ # @return [String, nil]
873
+ def normalize_report_path(uri)
874
+ normalized = normalize_report_uri(uri)
875
+ return nil if normalized.nil?
876
+ return normalized if normalized.start_with?('/') || normalized.include?('://')
877
+
878
+ "/#{normalized}"
879
+ end
880
+
881
+ # Recompute the stored static CSP header so the report-uri / report-to
882
+ # directives track the current settings, independent of the order in which
883
+ # {#enable_csp!} and the report setters were called.
884
+ #
885
+ # The base is normally @csp_policy (set by {#enable_csp!}). When a static
886
+ # CSP was instead injected directly through {#set_security_headers} (so
887
+ # @csp_policy is nil), adopt that header as the base the first time a report
888
+ # directive is configured so reporting augments it too. Capturing it as
889
+ # the pristine base keeps later rebuilds idempotent. A static header set
890
+ # directly AFTER reporting is configured bypasses this and remains the
891
+ # application's to manage.
892
+ #
893
+ # @return [void]
894
+ def rebuild_static_csp_with_reporting!
895
+ @csp_policy ||= adoptable_static_csp_base
896
+ return if @csp_policy.nil?
897
+
898
+ @security_headers['content-security-policy'] = build_static_csp(@csp_policy)
899
+ end
900
+
901
+ # The current static CSP header when it can serve as a pristine base policy
902
+ # for reporting augmentation: present and not already carrying a report
903
+ # directive (adopting one that does would double-append). Otherwise nil —
904
+ # notably, the nonce path sets no static header, so nothing is adopted.
905
+ #
906
+ # @return [String, nil]
907
+ def adoptable_static_csp_base
908
+ existing = @security_headers['content-security-policy']
909
+ return nil if existing.nil? || existing.empty?
910
+ return nil if existing.include?('report-uri') || existing.include?('report-to')
911
+
912
+ existing
913
+ end
914
+
915
+ # The `Reporting-Endpoints` response header value mapping the CSP reporting
916
+ # group to the configured absolute endpoint URL, e.g.
917
+ # `otto-csp="https://example.com/_/csp-report"`.
918
+ #
919
+ # @return [String]
920
+ def reporting_endpoints_header
921
+ %(#{CSP_REPORTING_GROUP}="#{@csp_report_to_url}")
922
+ end
923
+
924
+ # Build the stored static-CSP header value: the base policy plus the
925
+ # optional report-uri and report-to directives. Thin facade over
926
+ # {Otto::Security::CSP::Policy.static_policy}; byte-identical to the bare
927
+ # policy when no reporting is configured.
928
+ #
929
+ # @param policy [String] the base policy passed to {#enable_csp!}
930
+ # @return [String]
931
+ def build_static_csp(policy)
932
+ Otto::Security::CSP::Policy.static_policy(
933
+ policy,
934
+ report_uri: @csp_report_uri,
935
+ report_to_url: @csp_report_to_url
936
+ )
754
937
  end
755
938
  end
756
939
 
@@ -198,6 +198,60 @@ class Otto
198
198
  @security_config.enable_csp_with_nonce!(debug: debug)
199
199
  end
200
200
 
201
+ # Mount {Otto::Security::CSP::EmitMiddleware} (passive backstop that emits a
202
+ # nonce CSP for responses lacking one, never clobbering). Enable nonce-CSP
203
+ # ({#enable_csp_with_nonce!}) for it to emit anything; until then it is
204
+ # INERT (a transparent pass-through), not an error, and the two may be
205
+ # enabled in either order. Emit-if-consumed by default — see
206
+ # {Otto::Security::Core#enable_csp_emission!}.
207
+ #
208
+ # @param eager [Boolean] mint-and-emit for every eligible HTML response
209
+ # @param development_mode [Boolean, #call, nil] development-directive toggle;
210
+ # a callable is evaluated per request with the env
211
+ def enable_csp_emission!(eager: false, development_mode: nil)
212
+ return if middleware_enabled?(Otto::Security::CSP::EmitMiddleware)
213
+
214
+ @middleware_stack.add(Otto::Security::CSP::EmitMiddleware, eager: eager, development_mode: development_mode)
215
+ end
216
+
217
+ # Enable turnkey CSP violation reporting: set the report URI (appends a
218
+ # `report-uri` directive to emitted policies), register the callback, and
219
+ # inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
220
+ # intercepts report POSTs ahead of CSRF regardless of enable order.
221
+ #
222
+ # @param report_uri [String] path browsers POST reports to (matched against PATH_INFO)
223
+ # @param endpoint_url [String, nil] absolute URL for the modern Reporting
224
+ # API endpoint (emits `report-to` + `Reporting-Endpoints`); nil emits
225
+ # only the legacy `report-uri`
226
+ # @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
227
+ def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
228
+ @security_config.csp_report_uri = report_uri
229
+ @security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
230
+ @security_config.on_csp_violation(&block) if block
231
+
232
+ return if middleware_enabled?(Otto::Security::CSP::ReportMiddleware)
233
+
234
+ @middleware_stack.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
235
+ end
236
+
237
+ # Configure the CSP violation report path without injecting middleware.
238
+ # Prefer {#enable_csp_reporting!} for the full turnkey setup.
239
+ #
240
+ # @param uri [String, nil] report path (matched against PATH_INFO), or nil to disable
241
+ def csp_report_uri=(uri)
242
+ @security_config.csp_report_uri = uri
243
+ end
244
+
245
+ # Configure the absolute URL for the modern Reporting API endpoint
246
+ # (`report-to` directive + `Reporting-Endpoints` header) without injecting
247
+ # middleware. Prefer {#enable_csp_reporting!} with `endpoint_url:` for the
248
+ # full turnkey setup.
249
+ #
250
+ # @param url [String, nil] absolute endpoint URL, or nil to disable modern reporting
251
+ def csp_report_to_url=(url)
252
+ @security_config.csp_report_to_url = url
253
+ end
254
+
201
255
  # Add a single authentication strategy
202
256
  #
203
257
  # Part of the Security::Configurator facade for consolidated configuration.
@@ -135,6 +135,100 @@ class Otto
135
135
  @security_config.enable_csp_with_nonce!(debug: debug)
136
136
  end
137
137
 
138
+ # Mount {Otto::Security::CSP::EmitMiddleware} so nonce-based CSP headers are
139
+ # applied to responses by the framework instead of hand-rolled in each app.
140
+ #
141
+ # It is a passive backstop: it emits a nonce CSP only for responses that
142
+ # would otherwise ship without one, and never clobbers a policy a route
143
+ # already set. Enable nonce-CSP via {#enable_csp_with_nonce!} for it to
144
+ # emit anything — until then the middleware is INERT (a transparent
145
+ # pass-through), NOT an error. The two may be enabled in either order:
146
+ # both read the same security config, so mounting the backstop first and
147
+ # enabling nonce-CSP later works. Enable-order independence is why this
148
+ # does not raise when nonce-CSP is off.
149
+ #
150
+ # By DEFAULT it is emit-if-consumed — it emits only when the request
151
+ # actually consumed a nonce (a view called {Otto::Request#csp_nonce}). This
152
+ # is the only safe blanket default: a nonce-only policy on a page that never
153
+ # stamped the nonce blocks every script.
154
+ #
155
+ # @param eager [Boolean] mint-and-emit for every eligible HTML response
156
+ # rather than only emit-if-consumed (see the middleware's caveats)
157
+ # @param development_mode [Boolean, #call, nil] whether to emit development
158
+ # directives; a callable is evaluated per request with the env
159
+ # @return [void]
160
+ # @example
161
+ # otto.enable_csp_with_nonce!
162
+ # otto.enable_csp_emission!(development_mode: -> (env) { ENV['RACK_ENV'] == 'development' })
163
+ def enable_csp_emission!(eager: false, development_mode: nil)
164
+ ensure_not_frozen!
165
+ return if @middleware.includes?(Otto::Security::CSP::EmitMiddleware)
166
+
167
+ @middleware.add(Otto::Security::CSP::EmitMiddleware, eager: eager, development_mode: development_mode)
168
+ end
169
+
170
+ # Enable turnkey Content Security Policy violation reporting.
171
+ #
172
+ # This is the receiving half of Otto's CSP support. It:
173
+ # 1. Configures the report path (`config.csp_report_uri = report_uri`), so a
174
+ # `report-uri` directive is appended to every emitted CSP policy (static
175
+ # {#enable_csp!} and nonce {#enable_csp_with_nonce!} alike).
176
+ # 2. Registers your violation callback (if a block is given).
177
+ # 3. Injects {Otto::Security::CSP::ReportMiddleware} so browser POSTs to the
178
+ # report path are received, parsed, and dispatched to the callback —
179
+ # always answered with 204 and never touching your routes.
180
+ #
181
+ # The middleware is pinned to run OUTERMOST (ahead of CSRF and every other
182
+ # middleware), so it short-circuits report POSTs before CSRF validation —
183
+ # browsers can post reports without a CSRF token. This holds regardless of
184
+ # the order in which you enable security features.
185
+ #
186
+ # SECURITY / DoS: running outermost also means the receiver sits ahead of
187
+ # rate limiting (rate limiting is inner middleware). This is intentional —
188
+ # a public, unauthenticated report endpoint cannot depend on CSRF, session,
189
+ # or per-client throttling state — but it means a client can POST reports
190
+ # up to the 64 KiB body cap and invoke your callback on each one. Keep the
191
+ # callback cheap and bounded (sample or aggregate; avoid unbounded
192
+ # synchronous I/O), and put request-rate control for this path at the edge
193
+ # (reverse proxy / CDN / WAF) rather than expecting Otto to throttle it.
194
+ #
195
+ # To (re)assign the callback later without touching the wiring, use the
196
+ # config primitive directly: `otto.security_config.on_csp_violation { ... }`.
197
+ #
198
+ # For modern browsers (which have deprecated `report-uri`), also pass
199
+ # `endpoint_url:` — an ABSOLUTE URL whose path is `report_uri`. Otto then
200
+ # emits a `report-to` directive plus a `Reporting-Endpoints` header so those
201
+ # browsers deliver `application/reports+json` to the same receiver.
202
+ #
203
+ # @param report_uri [String] path browsers POST reports to (matched against
204
+ # `PATH_INFO`, e.g. `/_/csp-report`).
205
+ # @param endpoint_url [String, nil] absolute URL for the modern Reporting
206
+ # API endpoint (e.g. `https://example.com/_/csp-report`); nil emits only
207
+ # the legacy `report-uri`.
208
+ # @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
209
+ # @return [void]
210
+ # @example
211
+ # otto.enable_csp_with_nonce!
212
+ # otto.enable_csp_reporting!('/_/csp-report',
213
+ # endpoint_url: 'https://example.com/_/csp-report') do |report|
214
+ # Otto.logger.warn("CSP violation: #{report.to_h}")
215
+ # end
216
+ def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
217
+ ensure_not_frozen!
218
+
219
+ @security_config.csp_report_uri = report_uri
220
+ @security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
221
+ @security_config.on_csp_violation(&block) if block
222
+
223
+ return if @middleware.includes?(Otto::Security::CSP::ReportMiddleware)
224
+
225
+ # Pin OUTERMOST so it intercepts report POSTs ahead of CSRF regardless of
226
+ # the order security features are enabled in. add_with_position fires the
227
+ # stack's on_change callback, which rebuilds @app (wired in
228
+ # Otto#initialize_core_state) — no explicit build_app! needed.
229
+ @middleware.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
230
+ end
231
+
138
232
  # Add an authentication strategy with a registered name
139
233
  #
140
234
  # This is the primary public API for registering authentication strategies.