otto 2.8.1 → 2.9.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: f94ca7311ea4fde801ec716a32f0136bd3802332ad654e368e69b7d6638c4d0f
4
- data.tar.gz: 319fcb9bf53b73dada223539de15cbc8eb7e700f6aa6e53046306fd8abe391a1
3
+ metadata.gz: 0bbfc031ec4ff48d3d0ce373a4a8cbbfac62571f80a07ef54427e71a3fc34444
4
+ data.tar.gz: 4cf1c8c6171a8af0f18dcc61c8c00b86c9bc829b573e8e3b727b1047ad2ce230
5
5
  SHA512:
6
- metadata.gz: f8097fc6d3b184a0e6775f728ecb3195301a36512ad270b22db1f6137d79b5a0f8a5173235f96b1d42408fd381248b2f669326e08b48c02505884e779a97c6d2
7
- data.tar.gz: 0c035928bf9bd52096567cdea4e725c44b6fc8055999564c606db8eff50c302ae863bc018a4366022ed8c5899965929edb06837eff8405bbbef76a8c0ced1320
6
+ metadata.gz: c4405b81202ecf23a1f09629ccc8392a70a1fa6d0287c46ba4590517f884e527ecca5d909475b6ff772ad857def5686991c99b9b93dde9489c040d3502662f73
7
+ data.tar.gz: 155b0b637c48466d3ad68f91f28647c6f7a7a6cf4c60aac343af270c09a0be98f6fa5c1764d94e4886a148ee8d9f226c1567118e865404b3add638dbe50ee2a5
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,42 @@ 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.9.0:
11
+
12
+ 2.9.0 — 2026-08-18
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Add opt-in request-scoped CSP directive extras for sources that are known
19
+ only while handling a request. Enable the feature at boot with
20
+ ``security_config.enable_csp_request_extras!``, then add approved source
21
+ origins by directive through ``env['otto.csp.extra_directives']``. This
22
+ supports cases such as adding a tenant's SSO provider to ``form-action``.
23
+ Disabled by default. (#243)
24
+
25
+ Fixed
26
+ -----
27
+
28
+ - Prevent request-scoped CSP extras from invalidating valueless directives,
29
+ including ``upgrade-insecure-requests`` and ``block-all-mixed-content``.
30
+ Unsupported extras are ignored and logged; valid extras for other
31
+ directives continue to apply. (#243)
32
+
33
+ Security
34
+ --------
35
+
36
+ - Validate request-scoped CSP extras before adding them to a response policy.
37
+ Extras can only add valid HTTP(S) origins to compatible directives already
38
+ present in the configured policy; script and default source directives are
39
+ excluded. Invalid or unsupported entries are ignored and logged without
40
+ affecting the remaining policy. (#243)
41
+
42
+ - Reject request-scoped CSP extras for valueless directives during input
43
+ validation, with equivalent protection for direct CSP policy generation.
44
+ (#243)
45
+
10
46
  .. _changelog-2.8.1:
11
47
 
12
48
  2.8.1 — 2026-08-16
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.8.1)
4
+ otto (2.9.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
data/README.md CHANGED
@@ -129,7 +129,70 @@ result.skip_reason # => nil (or :disabled / :blank_nonce / :non_html / :exis
129
129
 
130
130
  Apps with an existing nonce env-key convention can point the accessor at it with
131
131
  `app.security_config.csp_nonce_key = 'onetime.nonce'` — the views and the header
132
- still share one value.
132
+ still share one value. Boot-time policy shaping goes through
133
+ `security_config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }`,
134
+ which replaces (or with `nil`, removes) a directive's sources wholesale.
135
+
136
+ #### Request-scoped directive extras
137
+
138
+ Some directive values only exist at request time — the canonical case is a
139
+ multi-tenant app that must allow the resolved tenant's SSO IdP origin in
140
+ `form-action`. The channel is **boot-time opt-in** (the env key is a write
141
+ surface any middleware in the Rack stack can reach, so it does not exist until
142
+ boot code says so):
143
+
144
+ ```ruby
145
+ app.security_config.enable_csp_request_extras! # default: off
146
+ ```
147
+
148
+ With the channel enabled, a handler (or middleware) writes a hash of directive
149
+ name => additional source tokens to the env before the response is finalized:
150
+
151
+ ```ruby
152
+ def signin(req, res)
153
+ idp_origin = resolve_tenant(req).idp_origin # e.g. "https://login.example-idp.com"
154
+ req.env['otto.csp.extra_directives'] = { 'form-action' => [idp_origin] }
155
+ # ... render as usual; the emitted CSP now carries the origin
156
+ end
157
+ ```
158
+
159
+ Without the opt-in, the env key is ignored entirely — no sanitization, no
160
+ logs. The `Writer::Result` returned by the emission surfaces reports what
161
+ actually happened: `result.extra_directives` carries only the extras that
162
+ landed in the policy; rejected or dropped entries are excluded and logged with
163
+ request context instead.
164
+
165
+ The extras channel is **additive-only** and deliberately narrow:
166
+
167
+ - Tokens are appended to directives already present in the built policy,
168
+ deduplicated. A directive that is *absent* (not in the base set, or removed
169
+ by a boot override) is dropped — creating one at request time would tighten
170
+ the policy (`form-action` does not fall back to `default-src`), and
171
+ re-adding one would resurrect a deliberate removal.
172
+ - Directives that take **no value** (`upgrade-insecure-requests`,
173
+ `block-all-mixed-content`) are refused during sanitization: a source appended
174
+ there would emit `upgrade-insecure-requests https://…`, which browsers treat
175
+ as malformed and discard — an extras key would silently switch the directive
176
+ *off*. The policy assembler independently leaves such directives
177
+ byte-identical for direct callers that bypass the sanitizer.
178
+ - Only **origins** are accepted: `scheme://host[:port]` with an http(s) scheme
179
+ — no keywords (`'self'`, `'unsafe-inline'`), no scheme sources (`data:`,
180
+ `https:`), no wildcards, no paths, nothing that could smuggle a separator.
181
+ - `script-src` (and `-elem`/`-attr`) and `default-src` are refused outright.
182
+ For the script family that is defence-in-depth policy, not nonce protection
183
+ (extras append, so the nonce would survive); `default-src` is refused
184
+ because widening it widens every unlisted directive at once.
185
+ - Everything that fails validation is **dropped and logged** (`warn`, with the
186
+ directive, token, and reason) — a hostile value never raises, and the
187
+ response ships with the rest of the policy intact.
188
+
189
+ Otto validates defensively, but it is not the policy authority: the app
190
+ decides *which* origins to admit (resolve them from trusted per-request data,
191
+ never echo attacker-controlled input). Extras live only in the request env —
192
+ nothing is memoized on the (frozen-in-production) security config, so
193
+ concurrent requests can never bleed into each other. The constant
194
+ `Otto::EnvKeys::CSP::EXTRA_DIRECTIVES` (via `require 'otto/env_keys'`) names
195
+ the key for downstream apps.
133
196
 
134
197
  > [!NOTE]
135
198
  > `res.send_csp_headers(content_type, nonce)` is **deprecated** in favour of
data/lib/otto/env_keys.rb CHANGED
@@ -74,6 +74,28 @@ class Otto
74
74
  # (e.g. 'onetime.nonce'), so the header and views still share one value.
75
75
  NONCE = 'otto.nonce'
76
76
 
77
+ # Content-Security-Policy request-scoped keys
78
+ module CSP
79
+ # Request-scoped CSP directive extras (delano/otto#243).
80
+ # Type: Hash{String|Symbol directive-name => String|Array<String> tokens}
81
+ # Set by: the consuming application (a handler, logic class, or
82
+ # middleware) any time before the response is finalized
83
+ # Used by: Otto::Security::CSP::RequestExtras (read + sanitized) and
84
+ # folded ADDITIVELY into the nonce policy by
85
+ # Otto::Security::CSP::Policy.append_extra_sources at build time
86
+ # Note: BOOT-TIME OPT-IN — the channel does not exist until the app
87
+ # calls Otto::Security::Config#enable_csp_request_extras! (default
88
+ # off); without it the key is ignored entirely, no sanitize work and
89
+ # no logs. The env key is a write surface any middleware in the Rack
90
+ # stack can reach, a lower-trust position than boot code.
91
+ # Note: additive-only — extras can only APPEND origin tokens
92
+ # (scheme://host[:port], http/https) to directives already present in
93
+ # the built policy. The script-src family and default-src are refused
94
+ # outright, keyword/scheme sources and wildcards are dropped, and every
95
+ # drop is logged. See Otto::Security::CSP::RequestExtras.
96
+ EXTRA_DIRECTIVES = 'otto.csp.extra_directives'
97
+ end
98
+
77
99
  # Whether the request arrived via a trusted proxy. TRI-STATE.
78
100
  # Type: Boolean when present; the key may be ABSENT.
79
101
  # Set by: IPPrivacyMiddleware, evaluated on the original peer BEFORE
data/lib/otto/response.rb CHANGED
@@ -124,6 +124,11 @@ class Otto
124
124
  # @return [Otto::Security::CSP::Writer::Result] the outcome (applied?, policy,
125
125
  # skip_reason) for uniform observability
126
126
  #
127
+ # @note request-scoped directive extras (`env['otto.csp.extra_directives']`,
128
+ # delano/otto#243) are seen only when this response has its {#request}
129
+ # wired — the env is resolved via `request&.env`, so a bare Response
130
+ # degrades gracefully to the base policy.
131
+ #
127
132
  # @example
128
133
  # res['content-type'] = 'text/html; charset=utf-8'
129
134
  # res.apply_csp(req.csp_nonce)
@@ -131,7 +136,8 @@ class Otto
131
136
  config = security_config || (request&.env && request.env['otto.security_config'])
132
137
  Otto::Security::CSP::Writer.apply(
133
138
  headers, nonce,
134
- config: config, mode: mode, development_mode: development_mode
139
+ config: config, mode: mode, development_mode: development_mode,
140
+ env: request&.env # nil-safe: specs and bare responses have no request
135
141
  )
136
142
  end
137
143
 
@@ -90,7 +90,7 @@ class Otto
90
90
  :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
91
91
  :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
92
92
  :csp_report_uri, :csp_report_to_url, :csp_violation_callback,
93
- :csp_directive_overrides
93
+ :csp_directive_overrides, :csp_request_extras_enabled
94
94
 
95
95
  # Initialize security configuration with safe defaults
96
96
  #
@@ -119,6 +119,7 @@ class Otto
119
119
  @csp_report_to_url = nil
120
120
  @csp_violation_callback = nil
121
121
  @csp_directive_overrides = {}
122
+ @csp_request_extras_enabled = false
122
123
  @csp_script_src_override_warned = false
123
124
  @rate_limiting_config = { custom_rules: {} }
124
125
  @ip_privacy_config = Otto::Privacy::Config.new
@@ -512,6 +513,39 @@ class Otto
512
513
  @csp_nonce_enabled
513
514
  end
514
515
 
516
+ # Enable the request-scoped CSP directive extras channel (delano/otto#243)
517
+ #
518
+ # Off by default: `env['otto.csp.extra_directives']` is a write surface
519
+ # that ANY middleware in the Rack stack can reach — a lower-trust
520
+ # position than boot code — so the channel does not exist until the app
521
+ # explicitly opts in here. Until then the Writer ignores the env key
522
+ # entirely (no sanitize work, no logs).
523
+ #
524
+ # With the channel enabled, a handler (or middleware) can widen
525
+ # directives with values only known at request time by writing a hash of
526
+ # directive name => additional origin tokens to the env before the
527
+ # response is finalized. Extras are additive-only and sanitized
528
+ # defensively; see {Otto::Security::CSP::RequestExtras}.
529
+ #
530
+ # @return [void]
531
+ # @raise [FrozenError] if configuration is frozen
532
+ #
533
+ # @example At boot, alongside nonce CSP
534
+ # config.enable_csp_with_nonce!
535
+ # config.enable_csp_request_extras!
536
+ def enable_csp_request_extras!
537
+ ensure_not_frozen!
538
+
539
+ @csp_request_extras_enabled = true
540
+ end
541
+
542
+ # Check if the request-scoped CSP directive extras channel is enabled
543
+ #
544
+ # @return [Boolean] true when {#enable_csp_request_extras!} was called
545
+ def csp_request_extras_enabled?
546
+ @csp_request_extras_enabled
547
+ end
548
+
515
549
  # Set the Rack env key the framework-owned lazy nonce is memoized under
516
550
  # ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
517
551
  # `'otto.nonce'`; override it for an app with an existing convention (e.g.
@@ -649,14 +683,25 @@ class Otto
649
683
  #
650
684
  # @param nonce [String] The nonce value to include in the CSP
651
685
  # @param development_mode [Boolean] Whether to use development-friendly directives
686
+ # @param extra_directives [Hash{String=>Array<String>}, nil] request-scoped
687
+ # extra source tokens appended additively after the overrides merge
688
+ # (see {Otto::Security::CSP::Policy.append_extra_sources}, delano/otto#243).
689
+ # Per-request data — passed through, never stored on this (deep-frozen
690
+ # in production) config.
691
+ # @yield [applied, dropped] forwarded to
692
+ # {Otto::Security::CSP::Policy.nonce_policy}: the extras entries that
693
+ # actually landed in the policy and the entries dropped because their
694
+ # directive was absent.
652
695
  # @return [String] Complete CSP policy string
653
- def generate_nonce_csp(nonce, development_mode: false)
696
+ def generate_nonce_csp(nonce, development_mode: false, extra_directives: nil, &extras_outcome)
654
697
  Otto::Security::CSP::Policy.nonce_policy(
655
698
  nonce,
656
- development_mode: development_mode,
657
- report_uri: @csp_report_uri,
658
- report_to_url: @csp_report_to_url,
659
- directive_overrides: @csp_directive_overrides
699
+ development_mode: development_mode,
700
+ report_uri: @csp_report_uri,
701
+ report_to_url: @csp_report_to_url,
702
+ directive_overrides: @csp_directive_overrides,
703
+ extra_directives: extra_directives,
704
+ &extras_outcome
660
705
  )
661
706
  end
662
707
 
@@ -66,7 +66,8 @@ class Otto
66
66
 
67
67
  Otto::Security::CSP::Writer.apply(
68
68
  headers, nonce,
69
- config: @config, mode: :backstop, development_mode: development_mode?(env)
69
+ config: @config, mode: :backstop, development_mode: development_mode?(env),
70
+ env: env # request-scoped extras (env['otto.csp.extra_directives'], #243)
70
71
  )
71
72
  end
72
73
 
@@ -29,6 +29,23 @@ class Otto
29
29
  # can never drift.
30
30
  REPORTING_GROUP = 'otto-csp'
31
31
 
32
+ # CSP directives that take NO value at all. Appending a source to one
33
+ # of these does not widen it — it makes the directive SYNTACTICALLY
34
+ # malformed (`upgrade-insecure-requests https://x;`), and a browser
35
+ # drops a malformed directive wholesale. So an extras entry keyed to
36
+ # one of them would silently DISABLE the directive it names, the exact
37
+ # inverse of the additive contract. {.append_extra_sources} therefore
38
+ # leaves them byte-identical and reports the entry as dropped.
39
+ #
40
+ # Deliberately only these two: `sandbox`, `trusted-types`, and
41
+ # `require-trusted-types-for` take non-source values, which makes
42
+ # appending an origin to them useless, but the result is still valid
43
+ # syntax the browser honours — no silent policy loss.
44
+ # `upgrade-insecure-requests` remains a CSP extension. Conversely,
45
+ # `block-all-mixed-content` is obsolete in the Mixed Content standard,
46
+ # but is retained here to protect legacy policies that still emit it.
47
+ VALUELESS_DIRECTIVES = %w[upgrade-insecure-requests block-all-mixed-content].freeze
48
+
32
49
  # Build the per-request nonce CSP policy string.
33
50
  #
34
51
  # Byte-identical to Otto's historical {Otto::Security::Config#generate_nonce_csp}
@@ -47,10 +64,26 @@ class Otto
47
64
  # into the base set before reporting directives are appended. See
48
65
  # {.merge_directives} for the accepted shape (replace a directive's
49
66
  # sources, add a new directive, or remove one with a nil/false value).
67
+ # @param extra_directives [Hash{String=>Array<String>}, nil]
68
+ # request-scoped extra source tokens appended additively AFTER the
69
+ # overrides merge and BEFORE the reporting directives. Expected
70
+ # pre-sanitized (see {Otto::Security::CSP::RequestExtras.from_env});
71
+ # see {.append_extra_sources} for the semantics (append to present
72
+ # directives only, dedupe, drop absent-directive keys).
73
+ # @yield [applied, dropped] the extras outcome from
74
+ # {.append_extra_sources} (both empty hashes when no extras were
75
+ # given): the entries actually folded into the policy and the
76
+ # entries dropped because their directive was absent. This is the
77
+ # return channel {Otto::Security::CSP::Writer} uses to log drops
78
+ # with request context and report only real appends — the policy
79
+ # string stays the sole return value.
50
80
  # @return [String] complete CSP policy string
51
- def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
81
+ def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil,
82
+ directive_overrides: nil, extra_directives: nil)
52
83
  directives = development_mode ? development_directives(nonce) : production_directives(nonce)
53
84
  directives = merge_directives(directives, directive_overrides)
85
+ directives, applied_extras, dropped_extras = append_extra_sources(directives, extra_directives)
86
+ yield(applied_extras, dropped_extras) if block_given?
54
87
  uri_directive = report_uri_directive(report_uri)
55
88
  to_directive = report_to_directive(report_to_url)
56
89
  directives += ["#{uri_directive};"] if uri_directive
@@ -151,28 +184,147 @@ class Otto
151
184
  merged
152
185
  end
153
186
 
187
+ # Fold request-scoped extra source tokens ADDITIVELY into a built
188
+ # directive set (delano/otto#243). A SIBLING of {.merge_directives},
189
+ # deliberately not a change to it: boot-time overrides REPLACE a
190
+ # directive's sources wholesale, request-time extras only ever APPEND.
191
+ #
192
+ # Semantics:
193
+ # - A directive PRESENT in the built list gets the extra tokens
194
+ # appended to its source list, deduplicated (a token already present
195
+ # is not appended again).
196
+ # - A directive ABSENT from the built list (base set minus any
197
+ # boot-override removals) is DROPPED and reported in the return:
198
+ # directives like `form-action` do not fall back to `default-src`,
199
+ # so CREATING one here would tighten the policy (suddenly blocking
200
+ # unrelated forms), and re-adding a directive a boot override
201
+ # deliberately removed (nil/false override) would resurrect it.
202
+ # - A directive that takes NO value ({VALUELESS_DIRECTIVES}, e.g.
203
+ # `upgrade-insecure-requests`) is left BYTE-IDENTICAL and its entry
204
+ # is DROPPED: appending a source there would emit
205
+ # `upgrade-insecure-requests https://x;`, which browsers treat as
206
+ # malformed and discard — an extras key would silently turn the
207
+ # directive OFF instead of widening it.
208
+ # - An entry whose token list is nil/empty leaves the base directive
209
+ # BYTE-IDENTICAL — the directive string is returned as-is, never
210
+ # rebuilt, so `worker-src 'self' blob:;` can never collapse into a
211
+ # bare `worker-src;`.
212
+ #
213
+ # Callers pass PRE-SANITIZED extras
214
+ # ({Otto::Security::CSP::RequestExtras.from_env} guarantees no
215
+ # `;`/CR/LF and origin-only tokens), so this helper does not
216
+ # re-validate the grammar; it stays defensive only about shape
217
+ # (nil/empty values are skipped gracefully, request-time input must
218
+ # never raise).
219
+ #
220
+ # Pure — like everything in this module, a function of its arguments
221
+ # with no logging and no env access. Dropped entries are RETURNED,
222
+ # not logged, so the one caller with the request in hand
223
+ # ({Otto::Security::CSP::Writer}) can log them with full request
224
+ # context.
225
+ #
226
+ # @param directives [Array<String>] built directive strings, each
227
+ # `;`-terminated (post {.merge_directives})
228
+ # @param extras [Hash{String=>Array<String>}, nil] normalized directive
229
+ # name => extra source tokens
230
+ # @return [Array(Array<String>, Hash, Hash)] `[merged, applied, dropped]`:
231
+ # the directive strings with extras appended; the extras entries that
232
+ # addressed a PRESENT directive (their tokens are in the merged
233
+ # policy — a token already present is simply not duplicated); and
234
+ # the entries dropped because their directive was absent or takes no
235
+ # value ({VALUELESS_DIRECTIVES}). Entries with nil/empty token lists
236
+ # appear in neither hash.
237
+ def append_extra_sources(directives, extras)
238
+ return [directives, {}, {}] if extras.nil? || extras.empty?
239
+
240
+ remaining = extras.dup
241
+ applied = {}
242
+ merged = directives.map do |directive|
243
+ name = directive_name(directive)
244
+ tokens = remaining.delete(name)
245
+ next directive if tokens.nil? || Array(tokens).empty?
246
+
247
+ if valueless_directive?(name)
248
+ # Put the entry back so it surfaces in `dropped` rather than
249
+ # vanishing: the caller with the request in hand must be able to
250
+ # log that these tokens never landed.
251
+ remaining[name] = tokens
252
+ next directive
253
+ end
254
+
255
+ applied[name] = tokens
256
+ append_sources_to(directive, tokens)
257
+ end
258
+
259
+ dropped = remaining.reject { |_name, tokens| Array(tokens).empty? }
260
+ [merged, applied, dropped]
261
+ end
262
+
263
+ # Append tokens to one `;`-terminated directive string, deduplicated
264
+ # against its existing sources. Returns the directive UNCHANGED when
265
+ # every token is already present (the byte-identical invariant).
266
+ #
267
+ # @param directive [String] e.g. `"form-action 'self';"`
268
+ # @param tokens [Array<String>] pre-sanitized source tokens
269
+ # @return [String] `;`-terminated directive string
270
+ def append_sources_to(directive, tokens)
271
+ body = directive.to_s.strip.delete_suffix(';')
272
+ name, sources = body.split(/\s+/, 2)
273
+ existing = sources.to_s.split(/\s+/)
274
+ additions = Array(tokens).map(&:to_s).reject { |token| token.empty? || existing.include?(token) }
275
+ return directive if additions.empty?
276
+
277
+ "#{name} #{(existing + additions).join(' ')};"
278
+ end
279
+
280
+ # Normalize one directive name: stripped, lowercased, underscores
281
+ # mapped to hyphens (no CSP directive contains an underscore), so a
282
+ # Symbol like `:worker_src` addresses the `worker-src` directive.
283
+ #
284
+ # The SINGLE normalization used by both {.normalize_overrides} and
285
+ # {Otto::Security::CSP::RequestExtras.from_env} — the present/absent
286
+ # matching in {.append_extra_sources} depends on both sides applying
287
+ # identical normalization, so it lives in exactly one place.
288
+ #
289
+ # @param name [String, Symbol]
290
+ # @return [String] normalized directive name (may be empty for blank input)
291
+ def normalize_directive_name(name)
292
+ name.to_s.strip.downcase.tr('_', '-')
293
+ end
294
+
154
295
  # Normalize an overrides hash to lowercased, hyphenated String keys so
155
296
  # 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.
297
+ # interchangeable (see {.normalize_directive_name}). Blank keys are
298
+ # dropped.
159
299
  #
160
300
  # @param overrides [Hash]
161
301
  # @return [Hash{String=>Object}]
162
302
  def normalize_overrides(overrides)
163
303
  overrides.each_with_object({}) do |(key, value), acc|
164
- name = key.to_s.strip.downcase.tr('_', '-')
304
+ name = normalize_directive_name(key)
165
305
  acc[name] = value unless name.empty?
166
306
  end
167
307
  end
168
308
 
169
309
  # The directive name (first token) of a `;`-terminated directive string,
170
- # lowercased for case-insensitive matching.
310
+ # run through {.normalize_directive_name} so the built policy and the
311
+ # extras/override hashes are compared on ONE normalization.
171
312
  #
172
313
  # @param directive [String]
173
314
  # @return [String]
174
315
  def directive_name(directive)
175
- directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
316
+ normalize_directive_name(directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first)
317
+ end
318
+
319
+ # True when +name+ addresses a directive that takes no value at all
320
+ # (see {VALUELESS_DIRECTIVES}). Normalizes through
321
+ # {.normalize_directive_name}, so `:upgrade_insecure_requests` and
322
+ # `'Upgrade-Insecure-Requests'` are recognized like the canonical form.
323
+ #
324
+ # @param name [String, Symbol]
325
+ # @return [Boolean]
326
+ def valueless_directive?(name)
327
+ VALUELESS_DIRECTIVES.include?(normalize_directive_name(name))
176
328
  end
177
329
 
178
330
  # Build a single `;`-terminated directive string from a name and an
@@ -0,0 +1,256 @@
1
+ # lib/otto/security/csp/request_extras.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'uri'
6
+
7
+ require_relative 'policy'
8
+
9
+ class Otto
10
+ module Security
11
+ module CSP
12
+ # Reads and sanitizes request-scoped CSP directive extras from the Rack
13
+ # env (delano/otto#243).
14
+ #
15
+ # This is the opt-in channel for widening CSP directives with values only
16
+ # known at request time: the app enables it at boot with
17
+ # {Otto::Security::Config#enable_csp_request_extras!} (default off — the
18
+ # env key is a write surface any middleware can reach, so it does not
19
+ # exist until boot code says so), then a handler writes a hash of
20
+ # directive name => additional source tokens to
21
+ # `env['otto.csp.extra_directives']` before the response is finalized,
22
+ # and the policy build folds the sanitized survivors in ADDITIVELY (see
23
+ # {Otto::Security::CSP::Policy.append_extra_sources}). The motivating
24
+ # case is a multi-tenant app that must admit the resolved tenant's SSO
25
+ # IdP origin into `form-action` — per-request data no boot-time override
26
+ # can express.
27
+ #
28
+ # This module deliberately has the OPPOSITE failure mode of
29
+ # {Otto::Security::CSP::Policy}: Policy is pure functions that raise on
30
+ # bad input (boot-time overrides should fail loud), while request-time
31
+ # extras must NEVER raise — a hostile or malformed value is dropped and
32
+ # logged, and the response still ships with the base policy intact.
33
+ #
34
+ # Sanitization rules:
35
+ # - Directive names are normalized via
36
+ # {Otto::Security::CSP::Policy.normalize_directive_name} (the same
37
+ # normalization {Policy.normalize_overrides} applies to boot-time
38
+ # overrides); blank keys are dropped. Two raw keys that normalize to
39
+ # the same directive (`'form_action'` and `'form-action'`) have their
40
+ # token lists merged (union) — nothing is silently overwritten.
41
+ # - {REFUSED_DIRECTIVES} are dropped wholesale. For the `script-src`
42
+ # family this is defence-in-depth policy, NOT nonce protection: extras
43
+ # are additive, so the nonce source would survive an append — refusing
44
+ # the family simply keeps the request channel away from the one
45
+ # directive class that gates script execution. `default-src` is refused
46
+ # because widening it widens every unlisted directive at once. Directives
47
+ # with no value are refused because an appended origin makes the entire
48
+ # directive malformed; {Policy.append_extra_sources} retains the same
49
+ # guard for callers that bypass this sanitizer.
50
+ # - Tokens must be ORIGINS: `scheme://host[:port]` with an http/https
51
+ # scheme and a non-empty host — no path/query/fragment/userinfo, no
52
+ # whitespace, no `;`/CR/LF, no wildcards, no quotes. Keyword sources
53
+ # (`'self'`, `'unsafe-inline'`, ...) and scheme sources (`data:`,
54
+ # `https:`) are rejected. Accepted tokens are normalized to
55
+ # `scheme://host[:port]` with a downcased host and default ports
56
+ # omitted. Hosts follow strip-then-validate: a single trailing dot
57
+ # (the FQDN root form browsers do NOT treat as the same origin) is
58
+ # stripped before validation, any remaining trailing dot rejects the
59
+ # token, and a host containing `%` (percent-encoding that URI's host
60
+ # parser passes through literally) is rejected outright. An explicit
61
+ # port must fall in 1..65535 — URI accepts arbitrarily large all-digit
62
+ # ports that no browser can match.
63
+ #
64
+ # Every key/token dropped during sanitization is logged at :warn via
65
+ # {Otto.structured_log} with a distinct reason (`:invalid_shape`,
66
+ # `:refused_directive`, `:not_an_origin`) and privacy-safe request
67
+ # context. Entries dropped later, during the policy append (an absent
68
+ # directive, a valueless directive supplied by a caller that bypassed this
69
+ # sanitizer, or a config without extras support), are logged by
70
+ # {Otto::Security::CSP::Writer} — the same message, `reason:
71
+ # :absent_directive` / `:valueless_directive` /
72
+ # `:config_without_extras_support`.
73
+ module RequestExtras
74
+ # The env key the consuming app writes. Hardcoded on purpose (not
75
+ # configurable) — it is a cross-gem contract; see also
76
+ # Otto::EnvKeys::CSP::EXTRA_DIRECTIVES (require 'otto/env_keys').
77
+ ENV_KEY = 'otto.csp.extra_directives'
78
+
79
+ # Directives the request channel refuses to touch, wholesale. See the
80
+ # module docs for why (defence-in-depth for the script family — NOT
81
+ # nonce-stripping, since appends keep the nonce — blast radius for
82
+ # default-src — and invalid syntax for directives with no value).
83
+ # Keep the policy-level guard too: Policy.nonce_policy(extra_directives:)
84
+ # is public and can bypass this sanitizer.
85
+ REFUSED_DIRECTIVES = (
86
+ %w[script-src script-src-elem script-src-attr default-src] +
87
+ Policy::VALUELESS_DIRECTIVES
88
+ ).freeze
89
+
90
+ # The only schemes an extra origin may carry.
91
+ ALLOWED_SCHEMES = %w[http https].freeze
92
+
93
+ # Characters that can never appear in an origin token: whitespace and
94
+ # `;`/CR/LF (directive separators), quotes (keyword sources), and `*`
95
+ # (wildcards). Checked before URI parsing so hostile tokens are
96
+ # rejected even when URI would tolerate them.
97
+ FORBIDDEN_CHARS = /[\s;'"*\r\n]/
98
+
99
+ module_function
100
+
101
+ # Read and sanitize the request-scoped extras from the Rack env.
102
+ #
103
+ # Never raises. Anything that fails validation is dropped and logged;
104
+ # whatever survives is returned.
105
+ #
106
+ # @param env [Hash] the Rack environment
107
+ # @return [Hash{String=>Array<String>}, nil] normalized directive name
108
+ # => normalized origin tokens; nil when the env key is absent, the
109
+ # value is not a Hash, or nothing survived sanitization — callers
110
+ # never see an empty hash
111
+ def from_env(env)
112
+ raw = env[ENV_KEY]
113
+ return nil if raw.nil?
114
+
115
+ # Compute the request context once per call and thread it through —
116
+ # a hostile payload can produce many drops, and re-deriving the
117
+ # context per token would defeat LoggingHelpers' compute-once-then-
118
+ # merge pattern.
119
+ context = Otto::LoggingHelpers.request_context(env)
120
+
121
+ unless raw.is_a?(Hash)
122
+ log_drop(context, directive: nil, token: raw, reason: :invalid_shape)
123
+ return nil
124
+ end
125
+
126
+ extras = raw.each_with_object({}) do |(key, value), acc|
127
+ name = Policy.normalize_directive_name(key)
128
+ if name.empty?
129
+ log_drop(context, directive: key, token: value, reason: :invalid_shape)
130
+ next
131
+ end
132
+ if REFUSED_DIRECTIVES.include?(name)
133
+ log_drop(context, directive: name, token: value, reason: :refused_directive)
134
+ next
135
+ end
136
+
137
+ tokens = sanitize_tokens(context, name, value)
138
+ next if tokens.empty?
139
+
140
+ # Two raw keys can normalize to the same directive ('form_action'
141
+ # and 'form-action'): union the token lists rather than letting
142
+ # the later key silently clobber the earlier one.
143
+ acc[name] = (acc[name] || []) | tokens
144
+ end
145
+ extras.empty? ? nil : extras
146
+ end
147
+
148
+ # Sanitize one directive's token value into normalized origin strings.
149
+ # A String value is treated as a whitespace-separated source list (the
150
+ # same ergonomics as a {Policy.merge_directives} String override); an
151
+ # Array is taken element-wise. Anything else drops the key.
152
+ #
153
+ # @param context [Hash] precomputed request context (for log payloads)
154
+ # @param name [String] normalized directive name (for log context)
155
+ # @param value [String, Array<String>, Object]
156
+ # @return [Array<String>] surviving normalized origins (deduplicated)
157
+ def sanitize_tokens(context, name, value)
158
+ candidates =
159
+ case value
160
+ when String then value.split
161
+ when Array then value
162
+ else
163
+ log_drop(context, directive: name, token: value, reason: :invalid_shape)
164
+ return []
165
+ end
166
+
167
+ candidates.filter_map do |token|
168
+ unless token.is_a?(String)
169
+ log_drop(context, directive: name, token: token, reason: :invalid_shape)
170
+ next
171
+ end
172
+
173
+ normalized = normalize_origin(token)
174
+ if normalized.nil?
175
+ log_drop(context, directive: name, token: token, reason: :not_an_origin)
176
+ next
177
+ end
178
+ normalized
179
+ end.uniq
180
+ end
181
+
182
+ # Validate and normalize a single token as an http(s) origin.
183
+ #
184
+ # @param token [String]
185
+ # @return [String, nil] `scheme://host[:port]` (downcased host, default
186
+ # port omitted), or nil when the token is not an acceptable origin
187
+ def normalize_origin(token)
188
+ return nil if token.empty? || token.match?(FORBIDDEN_CHARS)
189
+
190
+ uri = begin
191
+ URI.parse(token)
192
+ rescue URI::InvalidURIError
193
+ nil
194
+ end
195
+
196
+ return nil unless uri.is_a?(URI::HTTP) # URI::HTTPS is a subclass
197
+
198
+ scheme = uri.scheme.to_s.downcase
199
+ return nil unless ALLOWED_SCHEMES.include?(scheme)
200
+ return nil if uri.userinfo
201
+ return nil unless uri.path.to_s.empty?
202
+ return nil if uri.query || uri.fragment
203
+
204
+ host = normalize_host(uri.host)
205
+ return nil if host.nil?
206
+ # URI accepts arbitrarily large all-digit ports (and port 0); no
207
+ # browser can match an origin outside the TCP port range, so an
208
+ # out-of-range port is a silent-failure token — reject it. uri.port
209
+ # is never nil for URI::HTTP (defaults apply), and the default ports
210
+ # 80/443 are in range, so one check covers both shapes.
211
+ return nil unless (1..65_535).cover?(uri.port)
212
+
213
+ origin = "#{scheme}://#{host}"
214
+ origin << ":#{uri.port}" unless uri.port == uri.default_port
215
+ origin
216
+ end
217
+
218
+ # Validate and normalize an origin host: downcased, strip-then-validate
219
+ # for trailing dots (a single trailing dot — the FQDN root form — is
220
+ # stripped, since browsers do not equate `example.com.` with
221
+ # `example.com`; any dot still trailing after the strip rejects the
222
+ # host), and any `%` rejects the host outright (URI's host parser
223
+ # passes percent-encodings through literally, so accepting one would
224
+ # ship raw `%00`-style bytes in a response header).
225
+ #
226
+ # @param raw [String, nil] the parsed URI host
227
+ # @return [String, nil] normalized host, or nil when unacceptable
228
+ def normalize_host(raw)
229
+ host = raw.to_s.downcase
230
+ return nil if host.empty? || host.include?('%')
231
+
232
+ host = host.delete_suffix('.')
233
+ return nil if host.empty? || host.end_with?('.')
234
+
235
+ host
236
+ end
237
+
238
+ # Log one dropped key/token at :warn with privacy-safe request context.
239
+ # Never :debug — drops are actionable signal, and structured_log skips
240
+ # :debug unless Otto.debug is on.
241
+ #
242
+ # @param context [Hash] precomputed request context ({Otto::LoggingHelpers.request_context})
243
+ def log_drop(context, directive:, token:, reason:)
244
+ Otto.structured_log(
245
+ :warn, 'CSP request extra dropped',
246
+ context.merge(
247
+ directive: directive&.to_s,
248
+ token: token.inspect.slice(0, 128),
249
+ reason: reason
250
+ ).compact
251
+ )
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative 'request_extras'
6
+
5
7
  class Otto
6
8
  module Security
7
9
  module CSP
@@ -39,6 +41,10 @@ class Otto
39
41
  CSP_HEADER = 'content-security-policy'
40
42
  CONTENT_TYPE_HEADER = 'content-type'
41
43
 
44
+ # Method#parameters types that declare a keyword parameter (used by
45
+ # {.supports_extra_directives?}).
46
+ KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
47
+
42
48
  # Emission modes. `:override` is a deliberate per-request call that
43
49
  # REPLACES any existing CSP (the caller owns this response's policy).
44
50
  # `:backstop` is a passive layer that DEFERS to an existing CSP (it only
@@ -51,23 +57,26 @@ class Otto
51
57
  # written". `policy` is the emitted policy on success, or the pre-existing
52
58
  # policy when a `:backstop` deferred to one. `skip_reason` is one of
53
59
  # `:disabled`, `:blank_nonce`, `:non_html`, `:existing_csp` when skipped,
54
- # else nil.
60
+ # else nil. `extra_directives` carries the request-scoped extras that
61
+ # were ACTUALLY folded into the policy — entries dropped during the
62
+ # append (absent directive) are excluded — or nil when none landed.
55
63
  class Result
56
64
  # Recognized skip reasons, in the order {Writer.apply} evaluates them.
57
65
  SKIP_REASONS = %i[disabled blank_nonce non_html existing_csp].freeze
58
66
 
59
- attr_reader :policy, :skip_reason, :mode
67
+ attr_reader :policy, :skip_reason, :mode, :extra_directives
60
68
 
61
- def initialize(applied:, mode:, policy: nil, skip_reason: nil)
69
+ def initialize(applied:, mode:, policy: nil, skip_reason: nil, extra_directives: nil)
62
70
  @applied = applied
63
71
  @mode = mode
64
72
  @policy = policy
65
73
  @skip_reason = skip_reason
74
+ @extra_directives = extra_directives
66
75
  end
67
76
 
68
77
  # Build an "applied" result for a written policy.
69
- def self.applied(policy, mode:)
70
- new(applied: true, mode: mode, policy: policy)
78
+ def self.applied(policy, mode:, extra_directives: nil)
79
+ new(applied: true, mode: mode, policy: policy, extra_directives: extra_directives)
71
80
  end
72
81
 
73
82
  # Build a "skipped" result. `policy` carries the pre-existing policy for
@@ -96,23 +105,35 @@ class Otto
96
105
  # and the policy string ({Otto::Security::Config#generate_nonce_csp}).
97
106
  # @param mode [Symbol] one of {MODES}.
98
107
  # @param development_mode [Boolean] use the development directive set.
108
+ # @param env [Hash, nil] the Rack environment. When given AND the
109
+ # config has opted the channel in
110
+ # ({Otto::Security::Config#enable_csp_request_extras!}), any
111
+ # request-scoped directive extras the app wrote to
112
+ # `env['otto.csp.extra_directives']` are sanitized
113
+ # ({Otto::Security::CSP::RequestExtras.from_env}) and folded
114
+ # ADDITIVELY into the policy. Nil (surfaces with no env in hand)
115
+ # simply builds the policy without extras; without the opt-in the
116
+ # env key is ignored entirely.
99
117
  # @return [Result]
100
118
  # @raise [ArgumentError] if mode is not one of {MODES}
101
119
  # @raise [FrozenError] if a write is attempted against a frozen headers hash
102
- def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
120
+ def self.apply(headers, nonce, config:, mode: :override, development_mode: false, env: nil)
103
121
  unless MODES.include?(mode)
104
122
  raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
105
123
  end
106
124
 
107
- result = evaluate(headers, nonce, config, mode, development_mode)
125
+ result = evaluate(headers, nonce, config, mode, development_mode, env)
108
126
  log_debug(config, result)
109
127
  result
110
128
  end
111
129
 
112
130
  # Guarded core: returns a Result and performs the in-place write when it
113
131
  # 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)
132
+ # skip_reason is stable and meaningful. Request-scoped extras are
133
+ # resolved only once every guard has passed — so a skipped response
134
+ # never logs extras drops for a policy that was never built — and only
135
+ # when the config opted the channel in (see {.resolve_extras}).
136
+ def self.evaluate(headers, nonce, config, mode, development_mode, env)
116
137
  return Result.skipped(:disabled, mode: mode) unless enabled?(config)
117
138
  return Result.skipped(:blank_nonce, mode: mode) if blank?(nonce)
118
139
  return Result.skipped(:non_html, mode: mode) unless html_response?(headers)
@@ -120,12 +141,126 @@ class Otto
120
141
  existing = existing_csp(headers)
121
142
  return Result.skipped(:existing_csp, mode: mode, policy: existing) if existing && mode == :backstop
122
143
 
123
- policy = config.generate_nonce_csp(nonce, development_mode: development_mode)
144
+ extras = resolve_extras(config, env)
145
+ policy, applied_extras = build_policy(config, nonce, development_mode, extras, env)
124
146
  write_csp(headers, policy)
125
- Result.applied(policy, mode: mode)
147
+ Result.applied(policy, mode: mode, extra_directives: applied_extras)
126
148
  end
127
149
  private_class_method :evaluate
128
150
 
151
+ # Build the policy string, folding in the request-scoped extras when
152
+ # the config supports them. Returns `[policy, applied_extras]` where
153
+ # applied_extras is the hash of extras entries that ACTUALLY landed in
154
+ # the policy (nil when none did), as reported back by
155
+ # {Policy.append_extra_sources} through the outcome block — never the
156
+ # pre-append input, so {Result#extra_directives} and the debug log can
157
+ # only claim what happened. Entries the append dropped (absent
158
+ # directive) are logged HERE, the one place with the env in hand, with
159
+ # full request context; Policy stays a pure function of its arguments.
160
+ #
161
+ # Configs are duck-typed (see {.enabled?}): one with the pre-#243
162
+ # `generate_nonce_csp` signature would raise ArgumentError on the
163
+ # `extra_directives:` kwarg at request time — violating the extras
164
+ # channel's never-raises invariant — so the kwarg is passed only when
165
+ # the signature declares it. Otherwise the historical call shape is
166
+ # used and the extras are dropped with a single structured warn
167
+ # (`reason: :config_without_extras_support`).
168
+ #
169
+ # Declaring the kwarg is only half the duck-config protocol: opting
170
+ # in means BOTH accepting `extra_directives:` AND invoking the
171
+ # outcome block. A config that takes the kwarg but never yields
172
+ # leaves the outcome unknown — its policy string is still used as
173
+ # returned, but no applied extras are reported
174
+ # ({Result#extra_directives} stays nil; never fabricated from the
175
+ # pre-append input) and a single structured warn
176
+ # (`reason: :config_outcome_not_reported`) flags the gap.
177
+ def self.build_policy(config, nonce, development_mode, extras, env)
178
+ return [config.generate_nonce_csp(nonce, development_mode: development_mode), nil] if extras.nil?
179
+
180
+ unless supports_extra_directives?(config)
181
+ Otto.structured_log(
182
+ :warn, 'CSP request extras dropped',
183
+ Otto::LoggingHelpers.request_context(env).merge(
184
+ directives: extras.keys.join(' '),
185
+ reason: :config_without_extras_support
186
+ )
187
+ )
188
+ return [config.generate_nonce_csp(nonce, development_mode: development_mode), nil]
189
+ end
190
+
191
+ applied = nil
192
+ reported = false
193
+ policy = config.generate_nonce_csp(
194
+ nonce, development_mode: development_mode, extra_directives: extras
195
+ ) do |applied_extras, dropped_extras|
196
+ reported = true
197
+ applied = applied_extras unless applied_extras.empty?
198
+ log_dropped_extras(env, dropped_extras)
199
+ end
200
+ unless reported
201
+ Otto.structured_log(
202
+ :warn, 'CSP request extras outcome unreported',
203
+ Otto::LoggingHelpers.request_context(env).merge(
204
+ directives: extras.keys.join(' '),
205
+ reason: :config_outcome_not_reported
206
+ )
207
+ )
208
+ end
209
+ [policy, applied]
210
+ end
211
+ private_class_method :build_policy
212
+
213
+ # Whether the config's generate_nonce_csp declares the
214
+ # `extra_directives:` keyword (or accepts arbitrary keywords).
215
+ def self.supports_extra_directives?(config)
216
+ parameters = config.method(:generate_nonce_csp).parameters
217
+ parameters.any? { |type, name| KEYWORD_PARAM_TYPES.include?(type) && name == :extra_directives } ||
218
+ parameters.any? { |type, _name| type == :keyrest }
219
+ rescue NameError
220
+ false
221
+ end
222
+ private_class_method :supports_extra_directives?
223
+
224
+ # Log each extras entry the policy build dropped, once per entry, with
225
+ # full request context. The reason distinguishes the two ways an entry
226
+ # fails to land: its directive was absent from the built policy
227
+ # (:absent_directive) or the directive takes no value at all, so a
228
+ # source could never be appended to it (:valueless_directive) — the
229
+ # latter is an app bug worth naming precisely, since the entry is a
230
+ # no-op rather than a policy gap.
231
+ def self.log_dropped_extras(env, dropped)
232
+ return if dropped.nil? || dropped.empty?
233
+
234
+ context = Otto::LoggingHelpers.request_context(env)
235
+ dropped.each do |name, tokens|
236
+ reason = Policy.valueless_directive?(name) ? :valueless_directive : :absent_directive
237
+ Otto.structured_log(
238
+ :warn, 'CSP request extra dropped',
239
+ context.merge(
240
+ directive: name,
241
+ token: Array(tokens).join(' ').inspect.slice(0, 128),
242
+ reason: reason
243
+ )
244
+ )
245
+ end
246
+ end
247
+ private_class_method :log_dropped_extras
248
+
249
+ # Resolve the request-scoped extras, gated on the boot-time opt-in.
250
+ # The env key is a write surface ANY middleware in the Rack stack can
251
+ # reach — a lower-trust position than boot code — so the channel does
252
+ # not exist until {Otto::Security::Config#enable_csp_request_extras!}
253
+ # was called: when disabled (including duck-typed configs without the
254
+ # predicate) the env key is ignored entirely, with no sanitize work
255
+ # and no logs.
256
+ def self.resolve_extras(config, env)
257
+ return nil unless env
258
+ return nil unless config.respond_to?(:csp_request_extras_enabled?) && config.csp_request_extras_enabled?
259
+
260
+ RequestExtras.from_env(env)
261
+ end
262
+ private_class_method :resolve_extras
263
+
129
264
  # In-place, key-scoped write. Delete any case-variant of the CSP key
130
265
  # (correcting a downstream SPEC violation), then write the canonical
131
266
  # lowercase key into the caller's hash. Variant keys are collected before
@@ -182,12 +317,18 @@ class Otto
182
317
 
183
318
  # Uniform debug observability: when the config opts into CSP debugging,
184
319
  # log the outcome — applied policy OR skip reason — so "why didn't my page
185
- # get a CSP?" no longer needs a debugger.
320
+ # get a CSP?" no longer needs a debugger. When request-scoped extras
321
+ # were folded in, note which directives and how many tokens: the
322
+ # EmitMiddleware backstop is otherwise silent about them, so this is
323
+ # the observable trace that a request's extras actually landed.
186
324
  def self.log_debug(config, result)
187
325
  return unless config.respond_to?(:debug_csp?) && config.debug_csp?
188
326
  return unless defined?(Otto.logger) && Otto.logger
189
327
 
190
328
  detail = result.applied? ? "applied (#{result.mode}) #{result.policy}" : "skipped (#{result.skip_reason})"
329
+ if (extras = result.extra_directives)
330
+ detail += " extras[#{extras.keys.join(' ')}](#{extras.each_value.sum(&:length)} tokens)"
331
+ end
191
332
  Otto.logger.debug("[CSP] #{detail}")
192
333
  end
193
334
  private_class_method :log_debug
@@ -14,6 +14,10 @@
14
14
  # Result object, :override / :backstop modes) that every surface
15
15
  # routes through: Otto::Response#apply_csp, the EmitMiddleware,
16
16
  # and the deprecated Otto::Response#send_csp_headers shim.
17
+ # - RequestExtras — reads + sanitizes the opt-in request-scoped directive
18
+ # extras from env['otto.csp.extra_directives'] (delano/otto#243);
19
+ # the Writer folds the survivors in additively via
20
+ # Policy.append_extra_sources.
17
21
  # - EmitMiddleware — passive backstop that emits a nonce CSP for responses whose
18
22
  # request consumed a nonce (emit-if-consumed). See
19
23
  # Otto::Security::Core#enable_csp_emission!.
@@ -23,6 +27,7 @@
23
27
  # callback API. See Otto::Security::Core#enable_csp_reporting!.
24
28
 
25
29
  require_relative 'csp/policy'
30
+ require_relative 'csp/request_extras'
26
31
  require_relative 'csp/nonce'
27
32
  require_relative 'csp/writer'
28
33
  require_relative 'csp/report'
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.8.1'
6
+ VERSION = '2.9.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.8.1
4
+ version: 2.9.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-08-16 00:00:00.000000000 Z
11
+ date: 2026-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -312,6 +312,7 @@ files:
312
312
  - lib/otto/security/csp/policy.rb
313
313
  - lib/otto/security/csp/report.rb
314
314
  - lib/otto/security/csp/report_middleware.rb
315
+ - lib/otto/security/csp/request_extras.rb
315
316
  - lib/otto/security/csp/writer.rb
316
317
  - lib/otto/security/csrf.rb
317
318
  - lib/otto/security/csrf_enforcement_wrapper.rb