otto 2.4.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e50cca678689db3e2447e76a8a1b12f3b709305d4f73908cb06dfe6883adc596
4
- data.tar.gz: a6093388b74ffbd098c222a026c1f1cf439c8039c75a0d55b663a0874669c6ee
3
+ metadata.gz: 343eb2f324f142437e65c4d5659201363a13eb68d599891146a2aad426da817e
4
+ data.tar.gz: 542f896395a10158c89025cc3a6685d837ad63fa4aeb93deca81744f3a7d7e92
5
5
  SHA512:
6
- metadata.gz: ea5a8acda9a29c09d3a16a050050c2e9b9a1517c7f63e24bb7bc910801a7a07874fff1215550a740cef2c9c3698bc77378083b33f47794a7668b77feff9e6e67
7
- data.tar.gz: 62cfbe824d5d0d9e159424a6d930217f05b8b01de7142108a6a80953207cca9037a3e63d6ae5c16585dda3ff7b8a9b2d070b593f748563c0a5a20ed852add37f
6
+ metadata.gz: 59073e6907b3f183b7bf81b376ec07ff6d30ddbadb1436bbc8b1711265c325026486d44645b8f1a39e2cc181d9fcba4e8661259321ce40eab006e1e837fb82de
7
+ data.tar.gz: 4b2a0c746eb41f05971740593d04a45670869f35ce4b58d1aa74cc1a25052cbb32d0dec961ba60dfd31bc7fe0c6e990dbaad5e12396869240065bd461b7e8229
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,76 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.5.0:
11
+
12
+ 2.5.0 — 2026-07-02
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - ``Otto::Security::CSP::Writer.apply(headers, nonce, config:, mode:,
19
+ development_mode:)`` — the single structural apply core for nonce-based CSP
20
+ emission. Writes are in-place and key-scoped (case-variant keys are corrected
21
+ to Rack 3's lowercase in the caller's hash; a frozen headers hash fails loud).
22
+ Returns a ``Result`` (``applied?``, ``policy``, ``skip_reason`` of
23
+ ``:disabled`` / ``:blank_nonce`` / ``:non_html`` / ``:existing_csp``). Named
24
+ modes ``:override`` (deliberate, replaces) and ``:backstop`` (passive,
25
+ defers). (delano/otto#180)
26
+
27
+ - Framework-owned lazy nonce: ``Otto::Request#csp_nonce`` /
28
+ ``Otto::Security::CSP.nonce(env)`` generate on first access and memoize into
29
+ ``env['otto.nonce']`` (registered as ``Otto::EnvKeys::NONCE``), so views and
30
+ the header read one value. Configurable env key via
31
+ ``Otto::Security::Config#csp_nonce_key`` for apps with an existing convention.
32
+
33
+ - ``Otto::Security::CSP::EmitMiddleware`` and ``Otto#enable_csp_emission!`` — a
34
+ passive backstop that emits a nonce CSP for HTML responses whose request
35
+ consumed a nonce (emit-if-consumed default), never clobbering an existing
36
+ policy. Optional ``eager:`` mode and a per-request ``development_mode:``
37
+ callable.
38
+
39
+ - ``Otto::Response#apply_csp(nonce, mode: :override)`` — the one emission helper,
40
+ routed through the apply core.
41
+
42
+ - ``Otto::Security::CSP::Policy`` — CSP policy building (directive sets,
43
+ report-uri/report-to assembly) extracted from ``Otto::Security::Config`` into
44
+ its own home beside the parser and middlewares; ``Config`` delegates with
45
+ byte-identical output.
46
+
47
+ Deprecated
48
+ ----------
49
+
50
+ - ``Otto::Response#send_csp_headers`` — use ``#apply_csp`` or
51
+ ``#enable_csp_emission!``. Retained as a thin shim over the apply core (logs a
52
+ one-time ``Otto.logger`` deprecation notice).
53
+
54
+ Fixed
55
+ -----
56
+
57
+ - ``#send_csp_headers`` no longer emits a broken ``script-src 'nonce-'`` for a
58
+ blank/nil nonce (it skips) and no longer emits a CSP for non-HTML responses —
59
+ both via the shared apply core. Its bare ``warn`` to stderr when overwriting an
60
+ existing CSP is also gone: replacement is deliberate in ``:override`` mode, and
61
+ the shim instead logs a one-time deprecation notice through ``Otto.logger``.
62
+
63
+ Security
64
+ --------
65
+
66
+ - Nonce-CSP emission now detects and normalizes CSP / Content-Type headers
67
+ case-insensitively, so a canonical-/mixed-cased header from a downstream layer
68
+ is recognized (and the CSP key rewritten to lowercase) rather than silently
69
+ duplicated — de-duplicating the hand-rolled, case-sensitive guards adopters
70
+ previously re-implemented at each raw-tuple boundary. (delano/otto#180)
71
+
72
+ AI Assistance
73
+ -------------
74
+
75
+ - The nonce-CSP emission redesign — the ``Writer`` apply core, the
76
+ framework-owned lazy nonce, the ``EmitMiddleware`` backstop, and the
77
+ ``Policy`` extraction — was designed and implemented with AI assistance.
78
+ (delano/otto#180)
79
+
10
80
  .. _changelog-2.4.0:
11
81
 
12
82
  2.4.0 — 2026-07-01
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.4.0)
4
+ otto (2.5.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
data/README.md CHANGED
@@ -84,6 +84,60 @@ app = Otto.new("./routes", {
84
84
 
85
85
  Security features include CSRF protection, input validation, security headers, and trusted proxy configuration.
86
86
 
87
+ ### Content Security Policy (nonce-based emission)
88
+
89
+ Otto owns the nonce lifecycle so the header and your views can never drift. A
90
+ request-scoped nonce is minted lazily on first access and memoized in the env;
91
+ your views read it to stamp `<script>`/`<link>` tags, and the framework reads
92
+ the *same* value to emit the `script-src 'nonce-…'` header.
93
+
94
+ ```ruby
95
+ app = Otto.new("./routes")
96
+ app.enable_csp_with_nonce! # turn on nonce-based CSP
97
+ app.enable_csp_emission! # mount the backstop that writes the header
98
+
99
+ # In a view/handler:
100
+ def show(req, res)
101
+ res['content-type'] = 'text/html; charset=utf-8'
102
+ res.write(%(<script nonce="#{req.csp_nonce}">/* inline */</script>))
103
+ end
104
+ ```
105
+
106
+ `enable_csp_emission!` mounts `Otto::Security::CSP::EmitMiddleware`, a passive
107
+ **backstop**:
108
+
109
+ - **Emit-if-consumed** (default): it emits a policy only for a response whose
110
+ request actually consumed a nonce (a view called `req.csp_nonce`). A nonce-only
111
+ `script-src` on an HTML page that never stamped the nonce would block every
112
+ script, so "CSP responses whose request consumed a nonce" is the only safe
113
+ blanket default. Pass `eager: true` to mint-and-emit for every eligible HTML
114
+ response (see the caveat in the middleware docs).
115
+ - **Never clobbers**: it defers to any CSP a route already set.
116
+ - **HTML only**, and inert unless `enable_csp_with_nonce!` is on.
117
+ - `development_mode:` accepts a per-request callable, e.g.
118
+ `->(env) { ENV['RACK_ENV'] == 'development' }`, to switch directive sets.
119
+
120
+ To set a policy explicitly from a handler instead, use the one emission helper —
121
+ it routes through the same apply core:
122
+
123
+ ```ruby
124
+ res['content-type'] = 'text/html; charset=utf-8'
125
+ result = res.apply_csp(req.csp_nonce) # mode: :override by default
126
+ result.applied? # => true
127
+ result.skip_reason # => nil (or :disabled / :blank_nonce / :non_html / :existing_csp)
128
+ ```
129
+
130
+ Apps with an existing nonce env-key convention can point the accessor at it with
131
+ `app.security_config.csp_nonce_key = 'onetime.nonce'` — the views and the header
132
+ still share one value.
133
+
134
+ > [!NOTE]
135
+ > `res.send_csp_headers(content_type, nonce)` is **deprecated** in favour of
136
+ > `res.apply_csp` / `enable_csp_emission!`. It remains as a thin shim over the
137
+ > same apply core (so its old quirks — a broken `'nonce-'` on a blank nonce, a
138
+ > CSP on non-HTML responses, a `warn` to stderr — are now fixed) and logs a
139
+ > one-time deprecation notice.
140
+
87
141
  ### CSP Violation Reporting
88
142
 
89
143
  Otto can both emit Content-Security-Policy headers and receive the violation
@@ -275,6 +275,7 @@ class Otto
275
275
  Otto::Security::Middleware::RateLimitMiddleware,
276
276
  Otto::Security::Middleware::IPPrivacyMiddleware,
277
277
  Otto::Security::CSP::ReportMiddleware,
278
+ Otto::Security::CSP::EmitMiddleware,
278
279
  ].include?(middleware_class)
279
280
  end
280
281
  end
data/lib/otto/env_keys.rb CHANGED
@@ -61,6 +61,16 @@ class Otto
61
61
  # Used by: All security middleware (CSRF, Headers, Validation)
62
62
  SECURITY_CONFIG = 'otto.security_config'
63
63
 
64
+ # Per-request CSP nonce, minted lazily on first access and memoized here.
65
+ # Type: String (base64)
66
+ # Set by: Otto::Security::CSP.nonce / Otto::Request#csp_nonce (first touch)
67
+ # Used by: views (stamping script/style nonces) and
68
+ # Otto::Security::CSP::EmitMiddleware (emit-if-consumed)
69
+ # Note: this is the DEFAULT key. Apps with an existing convention can point
70
+ # the accessor at their own key via Otto::Security::Config#csp_nonce_key
71
+ # (e.g. 'onetime.nonce'), so the header and views still share one value.
72
+ NONCE = 'otto.nonce'
73
+
64
74
  # Whether the request arrived via a trusted proxy.
65
75
  # Type: Boolean
66
76
  # Set by: IPPrivacyMiddleware (every request, evaluated on the original
data/lib/otto/request.rb CHANGED
@@ -24,6 +24,20 @@ class Otto
24
24
  env['HTTP_USER_AGENT']
25
25
  end
26
26
 
27
+ # Framework-owned, request-scoped CSP nonce, generated lazily on first
28
+ # access and memoized into the request env. Views call this to stamp
29
+ # `nonce="…"` onto their inline `<script>`/`<link>` tags; the same value is
30
+ # what {Otto::Security::CSP::EmitMiddleware} writes into the `script-src
31
+ # 'nonce-…'` header — so the header and the views agree structurally, not by
32
+ # convention. An untouched request generates nothing.
33
+ #
34
+ # The env key is configurable via {Otto::Security::Config#csp_nonce_key}.
35
+ #
36
+ # @return [String] this request's nonce (base64)
37
+ def csp_nonce
38
+ Otto::Security::CSP.nonce(env)
39
+ end
40
+
27
41
  # Canonical client IP for the request.
28
42
  #
29
43
  # Prefers env['otto.client_ip'] — the value resolved once, early, by
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
@@ -47,7 +48,9 @@ class Otto
47
48
  # Endpoint group name shared by the CSP `report-to` directive and the
48
49
  # `Reporting-Endpoints` response header (modern Reporting API). Browsers
49
50
  # match the directive's group to the header's key, so both must agree.
50
- CSP_REPORTING_GROUP = 'otto-csp'
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
51
54
 
52
55
  # Error raised when CSRF protection is enabled in production without an
53
56
  # explicitly configured secret. A randomly-generated per-process secret
@@ -67,7 +70,7 @@ class Otto
67
70
  attr_reader :csrf_protection, :csrf_header_key,
68
71
  :trusted_proxies, :require_secure_cookies,
69
72
  :security_headers,
70
- :csp_nonce_enabled, :debug_csp, :mcp_auth,
73
+ :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
71
74
  :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
72
75
  :csp_report_uri, :csp_report_to_url, :csp_violation_callback
73
76
 
@@ -92,6 +95,7 @@ class Otto
92
95
  @input_validation = true
93
96
  @csp_nonce_enabled = false
94
97
  @debug_csp = false
98
+ @csp_nonce_key = 'otto.nonce'
95
99
  @csp_policy = nil
96
100
  @csp_report_uri = nil
97
101
  @csp_report_to_url = nil
@@ -393,6 +397,22 @@ class Otto
393
397
  @csp_nonce_enabled
394
398
  end
395
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
+
396
416
  # Check if CSP debug logging is enabled
397
417
  #
398
418
  # @return [Boolean] true if CSP debug logging is enabled
@@ -506,16 +526,20 @@ class Otto
506
526
 
507
527
  # Generate a CSP policy string with the provided nonce
508
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
+ #
509
533
  # @param nonce [String] The nonce value to include in the CSP
510
534
  # @param development_mode [Boolean] Whether to use development-friendly directives
511
535
  # @return [String] Complete CSP policy string
512
536
  def generate_nonce_csp(nonce, development_mode: false)
513
- directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
514
- report_uri_directive = csp_report_directive
515
- report_to_directive = csp_report_to_directive
516
- directives += ["#{report_uri_directive};"] if report_uri_directive
517
- directives += ["#{report_to_directive};"] if report_to_directive
518
- 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
+ )
519
543
  end
520
544
 
521
545
  # Enable X-Frame-Options header to prevent clickjacking
@@ -888,28 +912,6 @@ class Otto
888
912
  existing
889
913
  end
890
914
 
891
- # The `report-uri` directive to append to emitted policies, or nil when no
892
- # report URI is configured. No trailing semicolon (callers add their own
893
- # separator to match each policy style).
894
- #
895
- # @return [String, nil]
896
- def csp_report_directive
897
- return nil if @csp_report_uri.nil? || @csp_report_uri.empty?
898
-
899
- "report-uri #{@csp_report_uri}"
900
- end
901
-
902
- # The `report-to` directive (modern Reporting API) to append to emitted
903
- # policies, or nil when no reporting endpoint URL is configured. Its group
904
- # name matches the Reporting-Endpoints header. No trailing semicolon.
905
- #
906
- # @return [String, nil]
907
- def csp_report_to_directive
908
- return nil if @csp_report_to_url.nil? || @csp_report_to_url.empty?
909
-
910
- "report-to #{CSP_REPORTING_GROUP}"
911
- end
912
-
913
915
  # The `Reporting-Endpoints` response header value mapping the CSP reporting
914
916
  # group to the configured absolute endpoint URL, e.g.
915
917
  # `otto-csp="https://example.com/_/csp-report"`.
@@ -920,62 +922,18 @@ class Otto
920
922
  end
921
923
 
922
924
  # Build the stored static-CSP header value: the base policy plus the
923
- # optional report-uri and report-to directives. Byte-identical to the bare
924
- # policy when no reporting is configured, so existing static-CSP output is
925
- # unchanged.
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.
926
928
  #
927
929
  # @param policy [String] the base policy passed to {#enable_csp!}
928
930
  # @return [String]
929
931
  def build_static_csp(policy)
930
- [policy, csp_report_directive, csp_report_to_directive].compact.join('; ')
931
- end
932
-
933
- # Generate CSP directives for development environment
934
- #
935
- # Development mode allows inline scripts/styles and hot reloading connections
936
- # for better developer experience with build tools like Vite.
937
- #
938
- # @param nonce [String] The nonce value to include in script-src
939
- # @return [Array<String>] Array of CSP directive strings
940
- def development_csp_directives(nonce)
941
- [
942
- "default-src 'none';",
943
- "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
944
- "style-src 'self' 'unsafe-inline';",
945
- "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
946
- "img-src 'self' data:;",
947
- "font-src 'self';",
948
- "object-src 'none';",
949
- "base-uri 'self';",
950
- "form-action 'self';",
951
- "frame-ancestors 'none';",
952
- "manifest-src 'self';",
953
- "worker-src 'self' data:;",
954
- ]
955
- end
956
-
957
- # Generate CSP directives for production environment
958
- #
959
- # Production mode is more restrictive, only allowing HTTPS connections
960
- # and nonce-only scripts for enhanced XSS protection.
961
- #
962
- # @param nonce [String] The nonce value to include in script-src
963
- # @return [Array<String>] Array of CSP directive strings
964
- def production_csp_directives(nonce)
965
- [
966
- "default-src 'none';", # Restrict to same origin by default
967
- "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
968
- "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
969
- "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
970
- "img-src 'self' data:;", # Allow images from same origin and data URIs
971
- "font-src 'self';", # Allow fonts from same origin only
972
- "object-src 'none';", # Block <object>, <embed>, and <applet> elements
973
- "base-uri 'self';", # Restrict <base> tag targets to same origin
974
- "form-action 'self';", # Restrict form submissions to same origin
975
- "frame-ancestors 'none';", # Prevent site from being embedded in frames
976
- "manifest-src 'self';", # Allow web app manifests from same origin
977
- "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
978
- ]
932
+ Otto::Security::CSP::Policy.static_policy(
933
+ policy,
934
+ report_uri: @csp_report_uri,
935
+ report_to_url: @csp_report_to_url
936
+ )
979
937
  end
980
938
  end
981
939
 
@@ -198,6 +198,22 @@ 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
+
201
217
  # Enable turnkey CSP violation reporting: set the report URI (appends a
202
218
  # `report-uri` directive to emitted policies), register the callback, and
203
219
  # inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
@@ -135,6 +135,38 @@ 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
+
138
170
  # Enable turnkey Content Security Policy violation reporting.
139
171
  #
140
172
  # This is the receiving half of Otto's CSP support. It:
@@ -0,0 +1,91 @@
1
+ # lib/otto/security/csp/emit_middleware.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'writer'
6
+ require_relative 'nonce'
7
+
8
+ class Otto
9
+ module Security
10
+ module CSP
11
+ # Rack middleware that emits a nonce-based Content-Security-Policy on the
12
+ # way out — the EMITTING sibling of {Otto::Security::CSP::ReportMiddleware}.
13
+ #
14
+ # It is a passive BACKSTOP: it runs the CSP through {Otto::Security::CSP::Writer}
15
+ # in `:backstop` mode, so it fills the gap for responses that would
16
+ # otherwise ship without a CSP but NEVER clobbers one a route or another
17
+ # layer already set. All the emission invariants (enabled / HTML-only /
18
+ # nonce-present / don't-clobber / lowercase key) are the Writer's, so this
19
+ # middleware carries none of that guard logic itself.
20
+ #
21
+ # DEFAULT: emit-if-consumed. It emits only when the request actually
22
+ # consumed a nonce (a view called {Otto::Request#csp_nonce}, memoizing it
23
+ # into the env). This is the safe default: a nonce-only `script-src` on an
24
+ # HTML page whose templates never stamped that nonce would block EVERY
25
+ # script on the page. "CSP responses whose request consumed a nonce" is
26
+ # sound; "CSP all HTML responses" is not.
27
+ #
28
+ # EAGER (opt-in): with `eager: true` it MINTS a nonce for every otherwise
29
+ # eligible response, even one that never touched it. Only safe when the app
30
+ # either uses no nonce-gated inline scripts or stamps the nonce another way;
31
+ # otherwise it reintroduces the blocked-script hazard above.
32
+ #
33
+ # INERT unless {Otto::Security::Config#csp_nonce_enabled?}. When nonce-CSP
34
+ # is off it is a transparent pass-through (and never mints a nonce).
35
+ class EmitMiddleware
36
+ # @param app [#call] the inner Rack app
37
+ # @param config [Otto::Security::Config, nil] security config (the
38
+ # middleware stack injects this); a nil config yields an inert instance
39
+ # @param eager [Boolean] mint-and-emit for every eligible response rather
40
+ # than only emit-if-consumed
41
+ # @param development_mode [Boolean, #call, nil] whether to emit the
42
+ # development directive set. A callable is invoked per request with the
43
+ # env (e.g. `->(env) { OT.conf.dig('development', 'enabled') }`); a plain
44
+ # value is used as-is; nil means production.
45
+ def initialize(app, config = nil, eager: false, development_mode: nil)
46
+ @app = app
47
+ @config = config || Otto::Security::Config.new
48
+ @eager = eager
49
+ @development_mode = development_mode
50
+ end
51
+
52
+ def call(env)
53
+ status, headers, body = @app.call(env)
54
+ apply_backstop(env, headers) if @config.csp_nonce_enabled?
55
+ [status, headers, body]
56
+ end
57
+
58
+ private
59
+
60
+ # Resolve the nonce per the eager/consumed policy and, when present, let
61
+ # the Writer apply the backstop CSP. The Writer re-checks every guard, so
62
+ # a non-HTML or already-CSP'd response is left untouched.
63
+ def apply_backstop(env, headers)
64
+ nonce = resolve_nonce(env)
65
+ return if nonce.nil? || nonce.empty?
66
+
67
+ Otto::Security::CSP::Writer.apply(
68
+ headers, nonce,
69
+ config: @config, mode: :backstop, development_mode: development_mode?(env)
70
+ )
71
+ end
72
+
73
+ # Eager mode mints a nonce for this request; the default emits only a
74
+ # nonce the request already consumed (memoized in env by a view).
75
+ def resolve_nonce(env)
76
+ return Otto::Security::CSP.nonce(env) if @eager
77
+ return Otto::Security::CSP.nonce(env) if Otto::Security::CSP.nonce?(env)
78
+
79
+ nil
80
+ end
81
+
82
+ def development_mode?(env)
83
+ mode = @development_mode
84
+ return mode.call(env) if mode.respond_to?(:call)
85
+
86
+ !!mode
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,78 @@
1
+ # lib/otto/security/csp/nonce.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+
7
+ class Otto
8
+ module Security
9
+ # Content-Security-Policy support. The framework-owned lazy nonce accessor
10
+ # lives directly on this module ({.nonce} / {.nonce?}), beside the {Policy}
11
+ # builder, the {Writer} apply core, the {Parser}, and the report/emit
12
+ # middlewares.
13
+ module CSP
14
+ # Default Rack env key the per-request nonce is memoized under. Registered
15
+ # as documentation in {Otto::EnvKeys::NONCE}; per that module's convention
16
+ # the string literal (not the constant) is what the codebase passes around,
17
+ # so this DEFAULT_NONCE_KEY exists for the CSP code's own use and the two
18
+ # are kept identical.
19
+ DEFAULT_NONCE_KEY = 'otto.nonce'
20
+
21
+ module_function
22
+
23
+ # Framework-owned, request-scoped, LAZY CSP nonce.
24
+ #
25
+ # Generates a fresh base64 nonce on first access and memoizes it into the
26
+ # request env under the resolved key, so every later reader observes ONE
27
+ # value: the views that stamp `nonce="…"` onto `<script>`/`<link>` tags and
28
+ # the {Otto::Security::CSP::EmitMiddleware} that writes the `script-src
29
+ # 'nonce-…'` header both read it here. The header's nonce matching the
30
+ # views' nonce is therefore a STRUCTURAL property, not a convention each app
31
+ # re-implements (Rails' `request.content_security_policy_nonce` model).
32
+ #
33
+ # An untouched request never generates a nonce and pays nothing — which is
34
+ # also why the emit-if-consumed middleware is safe: it only emits a
35
+ # nonce-only policy for a request whose views actually consumed the nonce.
36
+ #
37
+ # A value already present under the key (e.g. an app that still mints its
38
+ # own under the same convention) is honored, not overwritten.
39
+ #
40
+ # @param env [Hash] the Rack request env (mutated: the nonce is memoized in)
41
+ # @param key [String, nil] override the env key; nil resolves it from the
42
+ # security config's {Otto::Security::Config#csp_nonce_key} (or the default)
43
+ # @return [String] the request's nonce
44
+ def nonce(env, key: nil)
45
+ resolved = key || nonce_key(env)
46
+ existing = env[resolved]
47
+ return existing if existing && !existing.empty?
48
+
49
+ env[resolved] = SecureRandom.base64(16)
50
+ end
51
+
52
+ # Whether a nonce was already minted for this request, WITHOUT minting one.
53
+ # This is the emit-if-consumed predicate.
54
+ #
55
+ # @param env [Hash]
56
+ # @param key [String, nil] see {.nonce}
57
+ # @return [Boolean]
58
+ def nonce?(env, key: nil)
59
+ value = env[key || nonce_key(env)]
60
+ !value.nil? && !value.empty?
61
+ end
62
+
63
+ # The env key the nonce lives under: the app's configured convention
64
+ # ({Otto::Security::Config#csp_nonce_key}) when a security config is present
65
+ # on the env, else the framework default. Lets an app with an existing
66
+ # convention (e.g. `onetime.nonce`) adopt the accessor without renaming its
67
+ # env key.
68
+ #
69
+ # @param env [Hash]
70
+ # @return [String]
71
+ def nonce_key(env)
72
+ config = env['otto.security_config']
73
+ configured = config.csp_nonce_key if config.respond_to?(:csp_nonce_key)
74
+ configured && !configured.empty? ? configured : DEFAULT_NONCE_KEY
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,141 @@
1
+ # lib/otto/security/csp/policy.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ module CSP
8
+ # Assembles Content-Security-Policy strings from Otto's directive sets and
9
+ # the optional reporting directives.
10
+ #
11
+ # This is the policy-BUILDING half of Otto's CSP support, extracted from
12
+ # {Otto::Security::Config} so the domain (directive sets, report-uri /
13
+ # report-to assembly) lives beside the parser and the middlewares under
14
+ # {Otto::Security::CSP}. {Otto::Security::Config} keeps thin delegating
15
+ # facades ({Otto::Security::Config#generate_nonce_csp} and its static
16
+ # counterpart), so callers and output are unchanged — the assembly logic
17
+ # simply has a home of its own now.
18
+ #
19
+ # All methods are pure functions of their arguments (the report URI/URL are
20
+ # passed in, not read from global state), so the same policy string can be
21
+ # produced from any surface without a Config in hand.
22
+ module Policy
23
+ module_function
24
+
25
+ # Endpoint group name shared by the CSP `report-to` directive and the
26
+ # `Reporting-Endpoints` response header (modern Reporting API). Browsers
27
+ # match the directive's group to the header's key, so both must agree.
28
+ # {Otto::Security::Config::CSP_REPORTING_GROUP} aliases this so the two
29
+ # can never drift.
30
+ REPORTING_GROUP = 'otto-csp'
31
+
32
+ # Build the per-request nonce CSP policy string.
33
+ #
34
+ # Byte-identical to Otto's historical {Otto::Security::Config#generate_nonce_csp}
35
+ # output: the base directive set (development or production) followed by
36
+ # the optional `report-uri` and `report-to` directives, each terminated
37
+ # with `;` and joined by a single space.
38
+ #
39
+ # @param nonce [String] nonce value injected into `script-src`
40
+ # @param development_mode [Boolean] use the development directive set
41
+ # @param report_uri [String, nil] path for the `report-uri` directive
42
+ # (omitted when nil/empty)
43
+ # @param report_to_url [String, nil] absolute URL configured for the
44
+ # modern Reporting API; its presence (not its value) toggles the
45
+ # `report-to <group>` directive (omitted when nil/empty)
46
+ # @return [String] complete CSP policy string
47
+ def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil)
48
+ directives = development_mode ? development_directives(nonce) : production_directives(nonce)
49
+ uri_directive = report_uri_directive(report_uri)
50
+ to_directive = report_to_directive(report_to_url)
51
+ directives += ["#{uri_directive};"] if uri_directive
52
+ directives += ["#{to_directive};"] if to_directive
53
+ directives.join(' ')
54
+ end
55
+
56
+ # Build a static CSP header value: a base policy plus the optional
57
+ # reporting directives, joined `'; '`. Byte-identical to the bare policy
58
+ # when no reporting is configured.
59
+ #
60
+ # @param base [String] the base policy (e.g. from {Otto::Security::Config#enable_csp!})
61
+ # @param report_uri [String, nil] path for the `report-uri` directive
62
+ # @param report_to_url [String, nil] absolute URL toggling `report-to`
63
+ # @return [String]
64
+ def static_policy(base, report_uri: nil, report_to_url: nil)
65
+ [base, report_uri_directive(report_uri), report_to_directive(report_to_url)].compact.join('; ')
66
+ end
67
+
68
+ # The `report-uri` directive, or nil when no report URI is configured.
69
+ # No trailing semicolon (callers add their own separator).
70
+ #
71
+ # @param uri [String, nil]
72
+ # @return [String, nil]
73
+ def report_uri_directive(uri)
74
+ return nil if uri.nil? || uri.empty?
75
+
76
+ "report-uri #{uri}"
77
+ end
78
+
79
+ # The `report-to` directive (modern Reporting API), or nil when no
80
+ # reporting endpoint URL is configured. Its group name matches the
81
+ # Reporting-Endpoints header. No trailing semicolon.
82
+ #
83
+ # @param url [String, nil]
84
+ # @return [String, nil]
85
+ def report_to_directive(url)
86
+ return nil if url.nil? || url.empty?
87
+
88
+ "report-to #{REPORTING_GROUP}"
89
+ end
90
+
91
+ # CSP directives for the development environment.
92
+ #
93
+ # Development mode allows inline scripts/styles and hot reloading
94
+ # connections for better developer experience with build tools like Vite.
95
+ #
96
+ # @param nonce [String] nonce value injected into `script-src`
97
+ # @return [Array<String>] directive strings, each terminated with `;`
98
+ def development_directives(nonce)
99
+ [
100
+ "default-src 'none';",
101
+ "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
102
+ "style-src 'self' 'unsafe-inline';",
103
+ "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
104
+ "img-src 'self' data:;",
105
+ "font-src 'self';",
106
+ "object-src 'none';",
107
+ "base-uri 'self';",
108
+ "form-action 'self';",
109
+ "frame-ancestors 'none';",
110
+ "manifest-src 'self';",
111
+ "worker-src 'self' data:;",
112
+ ]
113
+ end
114
+
115
+ # CSP directives for the production environment.
116
+ #
117
+ # Production mode is more restrictive, only allowing HTTPS connections
118
+ # and nonce-only scripts for enhanced XSS protection.
119
+ #
120
+ # @param nonce [String] nonce value injected into `script-src`
121
+ # @return [Array<String>] directive strings, each terminated with `;`
122
+ def production_directives(nonce)
123
+ [
124
+ "default-src 'none';", # Restrict to same origin by default
125
+ "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
126
+ "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
127
+ "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
128
+ "img-src 'self' data:;", # Allow images from same origin and data URIs
129
+ "font-src 'self';", # Allow fonts from same origin only
130
+ "object-src 'none';", # Block <object>, <embed>, and <applet> elements
131
+ "base-uri 'self';", # Restrict <base> tag targets to same origin
132
+ "form-action 'self';", # Restrict form submissions to same origin
133
+ "frame-ancestors 'none';", # Prevent site from being embedded in frames
134
+ "manifest-src 'self';", # Allow web app manifests from same origin
135
+ "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
136
+ ]
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,197 @@
1
+ # lib/otto/security/csp/writer.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ module CSP
8
+ # The single structural apply core for nonce-based Content-Security-Policy
9
+ # emission. Every in-framework surface that writes a nonce CSP onto a
10
+ # response — {Otto::Response#apply_csp}, {Otto::Security::CSP::EmitMiddleware},
11
+ # and the deprecated {Otto::Response#send_csp_headers} shim — routes through
12
+ # {.apply}, so the emission invariants are properties of ONE method rather
13
+ # than guard logic re-implemented (and re-reviewed) at each surface:
14
+ #
15
+ # - **Enabled only.** No header unless the security config has nonce-CSP on.
16
+ # - **Nonce present.** A nil/empty nonce never produces a broken
17
+ # `script-src 'nonce-'` policy; it skips.
18
+ # - **HTML only.** Non-HTML responses (JSON, redirects, static assets) are
19
+ # left untouched.
20
+ # - **Passive layers never clobber.** In `:backstop` mode an existing CSP is
21
+ # deferred to; only an explicit `:override` replaces one.
22
+ #
23
+ # Writes are **in-place and key-scoped**: {.apply} finds any case-variant of
24
+ # the CSP key (Rack 3 mandates lowercase response-header keys, but a
25
+ # canonical-/mixed-cased key from a downstream layer is a spec violation this
26
+ # corrects in place), deletes it, and writes the lowercase key into the
27
+ # CALLER'S headers hash. There is no wrapping, no copy, and no
28
+ # "callers-must-use-the-return-value" contract — the `[status, headers,
29
+ # body]` tuple never needs reassignment. A frozen headers hash therefore
30
+ # fails loud (FrozenError) on write, surfacing the downstream SPEC violation
31
+ # rather than silently dropping the policy.
32
+ #
33
+ # The return is a {Result}, not the headers: `result.applied?`,
34
+ # `result.policy`, `result.skip_reason` give uniform observability across
35
+ # every surface (and drive the optional debug log) without any cleverness to
36
+ # detect "did anything happen".
37
+ class Writer
38
+ # Canonical (lowercase, per Rack 3 SPEC) response-header keys.
39
+ CSP_HEADER = 'content-security-policy'
40
+ CONTENT_TYPE_HEADER = 'content-type'
41
+
42
+ # Emission modes. `:override` is a deliberate per-request call that
43
+ # REPLACES any existing CSP (the caller owns this response's policy).
44
+ # `:backstop` is a passive layer that DEFERS to an existing CSP (it only
45
+ # fills the gap, never clobbers).
46
+ MODES = %i[override backstop].freeze
47
+
48
+ # Outcome of an {Writer.apply} call.
49
+ #
50
+ # `applied?` is the single source of truth for "did a header get
51
+ # written". `policy` is the emitted policy on success, or the pre-existing
52
+ # policy when a `:backstop` deferred to one. `skip_reason` is one of
53
+ # `:disabled`, `:blank_nonce`, `:non_html`, `:existing_csp` when skipped,
54
+ # else nil.
55
+ class Result
56
+ # Recognized skip reasons, in the order {Writer.apply} evaluates them.
57
+ SKIP_REASONS = %i[disabled blank_nonce non_html existing_csp].freeze
58
+
59
+ attr_reader :policy, :skip_reason, :mode
60
+
61
+ def initialize(applied:, mode:, policy: nil, skip_reason: nil)
62
+ @applied = applied
63
+ @mode = mode
64
+ @policy = policy
65
+ @skip_reason = skip_reason
66
+ end
67
+
68
+ # Build an "applied" result for a written policy.
69
+ def self.applied(policy, mode:)
70
+ new(applied: true, mode: mode, policy: policy)
71
+ end
72
+
73
+ # Build a "skipped" result. `policy` carries the pre-existing policy for
74
+ # the `:existing_csp` case (observability), nil otherwise.
75
+ def self.skipped(reason, mode:, policy: nil)
76
+ new(applied: false, mode: mode, skip_reason: reason, policy: policy)
77
+ end
78
+
79
+ # @return [Boolean] true when a CSP header was written
80
+ def applied?
81
+ @applied
82
+ end
83
+
84
+ # @return [Boolean] true when no header was written
85
+ def skipped?
86
+ !@applied
87
+ end
88
+ end
89
+
90
+ # Apply a nonce-based CSP to the caller's response headers, in place.
91
+ #
92
+ # @param headers [Hash] the Rack response headers hash, mutated in place.
93
+ # MUST be mutable (Rack 3 SPEC); a frozen hash raises FrozenError.
94
+ # @param nonce [String, nil] the per-request nonce.
95
+ # @param config [Otto::Security::Config, nil] source of the enabled gate
96
+ # and the policy string ({Otto::Security::Config#generate_nonce_csp}).
97
+ # @param mode [Symbol] one of {MODES}.
98
+ # @param development_mode [Boolean] use the development directive set.
99
+ # @return [Result]
100
+ # @raise [ArgumentError] if mode is not one of {MODES}
101
+ # @raise [FrozenError] if a write is attempted against a frozen headers hash
102
+ def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
103
+ unless MODES.include?(mode)
104
+ raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
105
+ end
106
+
107
+ result = evaluate(headers, nonce, config, mode, development_mode)
108
+ log_debug(config, result)
109
+ result
110
+ end
111
+
112
+ # Guarded core: returns a Result and performs the in-place write when it
113
+ # applies. Guards are evaluated most-fundamental first so the reported
114
+ # skip_reason is stable and meaningful.
115
+ def self.evaluate(headers, nonce, config, mode, development_mode)
116
+ return Result.skipped(:disabled, mode: mode) unless enabled?(config)
117
+ return Result.skipped(:blank_nonce, mode: mode) if blank?(nonce)
118
+ return Result.skipped(:non_html, mode: mode) unless html_response?(headers)
119
+
120
+ existing = existing_csp(headers)
121
+ return Result.skipped(:existing_csp, mode: mode, policy: existing) if existing && mode == :backstop
122
+
123
+ policy = config.generate_nonce_csp(nonce, development_mode: development_mode)
124
+ write_csp(headers, policy)
125
+ Result.applied(policy, mode: mode)
126
+ end
127
+ private_class_method :evaluate
128
+
129
+ # In-place, key-scoped write. Delete any case-variant of the CSP key
130
+ # (correcting a downstream SPEC violation), then write the canonical
131
+ # lowercase key into the caller's hash. Variant keys are collected before
132
+ # deleting so we never mutate the hash while iterating it.
133
+ def self.write_csp(headers, policy)
134
+ variant_keys = headers.keys.select { |key| key != CSP_HEADER && key.to_s.casecmp?(CSP_HEADER) }
135
+ variant_keys.each { |key| headers.delete(key) }
136
+ headers[CSP_HEADER] = policy
137
+ end
138
+ private_class_method :write_csp
139
+
140
+ # Whether the config has nonce-CSP enabled (nil/foreign configs are "off").
141
+ def self.enabled?(config)
142
+ config.respond_to?(:csp_nonce_enabled?) && config.csp_nonce_enabled?
143
+ end
144
+ private_class_method :enabled?
145
+
146
+ # Whether the response is HTML, by the leading media type of its
147
+ # Content-Type (case-insensitive; charset and other parameters ignored).
148
+ # The media type must be exactly `text/html` — matched on the token
149
+ # before any `;`, so `text/html; charset=utf-8` is HTML but `text/html5`
150
+ # or `text/html-foo` is not. Absent Content-Type is treated as non-HTML:
151
+ # a nonce-only CSP on a response the templates never stamped would block
152
+ # every script.
153
+ def self.html_response?(headers)
154
+ content_type = lookup(headers, CONTENT_TYPE_HEADER)
155
+ return false if content_type.nil?
156
+
157
+ media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase
158
+ media_type == 'text/html'
159
+ end
160
+ private_class_method :html_response?
161
+
162
+ # The existing CSP value (any case-variant key), or nil.
163
+ def self.existing_csp(headers)
164
+ lookup(headers, CSP_HEADER)
165
+ end
166
+ private_class_method :existing_csp
167
+
168
+ # Case-insensitive header read: fast path for the canonical lowercase key,
169
+ # else a scan for a case-variant.
170
+ def self.lookup(headers, name)
171
+ return headers[name] if headers.key?(name)
172
+
173
+ headers.each { |key, value| return value if key.to_s.casecmp?(name) }
174
+ nil
175
+ end
176
+ private_class_method :lookup
177
+
178
+ def self.blank?(value)
179
+ value.nil? || value.to_s.empty?
180
+ end
181
+ private_class_method :blank?
182
+
183
+ # Uniform debug observability: when the config opts into CSP debugging,
184
+ # log the outcome — applied policy OR skip reason — so "why didn't my page
185
+ # get a CSP?" no longer needs a debugger.
186
+ def self.log_debug(config, result)
187
+ return unless config.respond_to?(:debug_csp?) && config.debug_csp?
188
+ return unless defined?(Otto.logger) && Otto.logger
189
+
190
+ detail = result.applied? ? "applied (#{result.mode}) #{result.policy}" : "skipped (#{result.skip_reason})"
191
+ Otto.logger.debug("[CSP] #{detail}")
192
+ end
193
+ private_class_method :log_debug
194
+ end
195
+ end
196
+ end
197
+ end
@@ -3,17 +3,29 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  #
6
- # Index file for Content-Security-Policy violation reporting components.
6
+ # Index file for Content-Security-Policy components both halves of Otto's CSP
7
+ # support live under Otto::Security::CSP.
7
8
  #
8
- # Otto emits CSP headers via Otto::Security::Config (static #enable_csp! and
9
- # nonce-based #generate_nonce_csp / Otto::Response#send_csp_headers). This
10
- # module adds the receiving half: a turnkey violation-report endpoint plus a
11
- # callback API, so an application can collect CSP reports with a few lines of
12
- # config instead of hand-rolling a parser, size cap, and CSRF exemption.
9
+ # EMISSION (delano/otto#180):
10
+ # - Policy — builds the policy string (directive sets, report-uri/report-to).
11
+ # - Nonce — the framework-owned lazy nonce (Otto::Security::CSP.nonce /
12
+ # Otto::Request#csp_nonce), memoized in env['otto.nonce'].
13
+ # - Writer — the single structural apply core (in-place, key-scoped writes,
14
+ # Result object, :override / :backstop modes) that every surface
15
+ # routes through: Otto::Response#apply_csp, the EmitMiddleware,
16
+ # and the deprecated Otto::Response#send_csp_headers shim.
17
+ # - EmitMiddleware — passive backstop that emits a nonce CSP for responses whose
18
+ # request consumed a nonce (emit-if-consumed). See
19
+ # Otto::Security::Core#enable_csp_emission!.
13
20
  #
14
- # See Otto::Security::Core#enable_csp_reporting! for the primary entry point and
15
- # delano/otto#174 for the design.
21
+ # RECEPTION (delano/otto#174):
22
+ # - Report, Parser, ReportMiddleware — a turnkey violation-report endpoint plus a
23
+ # callback API. See Otto::Security::Core#enable_csp_reporting!.
16
24
 
25
+ require_relative 'csp/policy'
26
+ require_relative 'csp/nonce'
27
+ require_relative 'csp/writer'
17
28
  require_relative 'csp/report'
18
29
  require_relative 'csp/parser'
19
30
  require_relative 'csp/report_middleware'
31
+ require_relative 'csp/emit_middleware'
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.4.0'
6
+ VERSION = '2.5.0'
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -294,9 +294,13 @@ files:
294
294
  - lib/otto/security/constant_resolver.rb
295
295
  - lib/otto/security/core.rb
296
296
  - lib/otto/security/csp.rb
297
+ - lib/otto/security/csp/emit_middleware.rb
298
+ - lib/otto/security/csp/nonce.rb
297
299
  - lib/otto/security/csp/parser.rb
300
+ - lib/otto/security/csp/policy.rb
298
301
  - lib/otto/security/csp/report.rb
299
302
  - lib/otto/security/csp/report_middleware.rb
303
+ - lib/otto/security/csp/writer.rb
300
304
  - lib/otto/security/csrf.rb
301
305
  - lib/otto/security/middleware/csrf_middleware.rb
302
306
  - lib/otto/security/middleware/ip_privacy_middleware.rb