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