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 +4 -4
- data/CHANGELOG.rst +36 -0
- data/Gemfile.lock +1 -1
- data/README.md +64 -1
- data/lib/otto/env_keys.rb +22 -0
- data/lib/otto/response.rb +7 -1
- data/lib/otto/security/config.rb +51 -6
- data/lib/otto/security/csp/emit_middleware.rb +2 -1
- data/lib/otto/security/csp/policy.rb +159 -7
- data/lib/otto/security/csp/request_extras.rb +256 -0
- data/lib/otto/security/csp/writer.rb +153 -12
- data/lib/otto/security/csp.rb +5 -0
- data/lib/otto/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0bbfc031ec4ff48d3d0ce373a4a8cbbfac62571f80a07ef54427e71a3fc34444
|
|
4
|
+
data.tar.gz: 4cf1c8c6171a8af0f18dcc61c8c00b86c9bc829b573e8e3b727b1047ad2ce230
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -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:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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,
|
|
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
|
|
157
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/otto/security/csp.rb
CHANGED
|
@@ -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
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
|
+
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-
|
|
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
|