otto 2.4.0 → 2.6.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/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +135 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/README.md +54 -0
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +1 -0
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +37 -1
- data/lib/otto/privacy/core.rb +18 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/request.rb +41 -0
- data/lib/otto/response.rb +80 -43
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/config.rb +137 -84
- 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 +273 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +20 -8
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -2
- metadata +13 -2
|
@@ -0,0 +1,273 @@
|
|
|
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
|
+
# @param directive_overrides [Hash, nil] per-directive overrides merged
|
|
47
|
+
# into the base set before reporting directives are appended. See
|
|
48
|
+
# {.merge_directives} for the accepted shape (replace a directive's
|
|
49
|
+
# sources, add a new directive, or remove one with a nil/false value).
|
|
50
|
+
# @return [String] complete CSP policy string
|
|
51
|
+
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
|
|
52
|
+
directives = development_mode ? development_directives(nonce) : production_directives(nonce)
|
|
53
|
+
directives = merge_directives(directives, directive_overrides)
|
|
54
|
+
uri_directive = report_uri_directive(report_uri)
|
|
55
|
+
to_directive = report_to_directive(report_to_url)
|
|
56
|
+
directives += ["#{uri_directive};"] if uri_directive
|
|
57
|
+
directives += ["#{to_directive};"] if to_directive
|
|
58
|
+
directives.join(' ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build a static CSP header value: a base policy plus the optional
|
|
62
|
+
# reporting directives, joined `'; '`. Byte-identical to the bare policy
|
|
63
|
+
# when no reporting is configured.
|
|
64
|
+
#
|
|
65
|
+
# @param base [String] the base policy (e.g. from {Otto::Security::Config#enable_csp!})
|
|
66
|
+
# @param report_uri [String, nil] path for the `report-uri` directive
|
|
67
|
+
# @param report_to_url [String, nil] absolute URL toggling `report-to`
|
|
68
|
+
# @return [String]
|
|
69
|
+
def static_policy(base, report_uri: nil, report_to_url: nil)
|
|
70
|
+
[base, report_uri_directive(report_uri), report_to_directive(report_to_url)].compact.join('; ')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The `report-uri` directive, or nil when no report URI is configured.
|
|
74
|
+
# No trailing semicolon (callers add their own separator).
|
|
75
|
+
#
|
|
76
|
+
# @param uri [String, nil]
|
|
77
|
+
# @return [String, nil]
|
|
78
|
+
def report_uri_directive(uri)
|
|
79
|
+
return nil if uri.nil? || uri.empty?
|
|
80
|
+
|
|
81
|
+
"report-uri #{uri}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The `report-to` directive (modern Reporting API), or nil when no
|
|
85
|
+
# reporting endpoint URL is configured. Its group name matches the
|
|
86
|
+
# Reporting-Endpoints header. No trailing semicolon.
|
|
87
|
+
#
|
|
88
|
+
# @param url [String, nil]
|
|
89
|
+
# @return [String, nil]
|
|
90
|
+
def report_to_directive(url)
|
|
91
|
+
return nil if url.nil? || url.empty?
|
|
92
|
+
|
|
93
|
+
"report-to #{REPORTING_GROUP}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Merge per-directive overrides into a base directive set.
|
|
97
|
+
#
|
|
98
|
+
# This is the customization seam the hardcoded directive sets previously
|
|
99
|
+
# lacked: a consuming app can adjust ANY directive (e.g. re-allow
|
|
100
|
+
# `data:` workers via `worker-src 'self' data: blob:`) without
|
|
101
|
+
# vendoring the gem. Order is
|
|
102
|
+
# preserved — an override that matches an existing directive replaces it
|
|
103
|
+
# in place; an override for a directive not in the base set is appended
|
|
104
|
+
# after the base directives (before any reporting directives).
|
|
105
|
+
#
|
|
106
|
+
# Override values:
|
|
107
|
+
# - a String → the directive's source list verbatim, e.g.
|
|
108
|
+
# `'worker-src' => "'self' blob:"` yields `worker-src 'self' blob:;`
|
|
109
|
+
# - an Array → sources joined with a single space, e.g.
|
|
110
|
+
# `%w['self' blob:]`
|
|
111
|
+
# - `nil`/`false` → REMOVE the directive from the emitted policy
|
|
112
|
+
#
|
|
113
|
+
# Directive names are matched case-insensitively (CSP directive names are
|
|
114
|
+
# case-insensitive) and may be given as Strings or Symbols.
|
|
115
|
+
#
|
|
116
|
+
# @note The per-request nonce is embedded in `script-src` (production)
|
|
117
|
+
# and cannot be reproduced in a static override, so replacing (or
|
|
118
|
+
# removing) `script-src` strips the nonce from the emitted header and
|
|
119
|
+
# DEFEATS nonce protection: the browser then accepts any inline script
|
|
120
|
+
# the page carries the nonce attribute on. Overriding `script-src`
|
|
121
|
+
# while nonce mode is enabled therefore disables nonce enforcement;
|
|
122
|
+
# {Otto::Security::Config} logs a warning when such an override is
|
|
123
|
+
# configured. Override other directives freely.
|
|
124
|
+
#
|
|
125
|
+
# @param directives [Array<String>] base directive strings, each `;`-terminated
|
|
126
|
+
# @param overrides [Hash, nil] directive name => source list / nil
|
|
127
|
+
# @return [Array<String>] merged directive strings, each `;`-terminated
|
|
128
|
+
# @raise [ArgumentError] if an override name or source token contains a
|
|
129
|
+
# `;`, newline, or carriage return (see {.build_directive})
|
|
130
|
+
def merge_directives(directives, overrides)
|
|
131
|
+
return directives if overrides.nil? || overrides.empty?
|
|
132
|
+
|
|
133
|
+
normalized = normalize_overrides(overrides)
|
|
134
|
+
consumed = {}
|
|
135
|
+
|
|
136
|
+
merged = directives.filter_map do |directive|
|
|
137
|
+
name = directive_name(directive)
|
|
138
|
+
next directive unless normalized.key?(name)
|
|
139
|
+
|
|
140
|
+
consumed[name] = true
|
|
141
|
+
build_directive(name, normalized[name])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
normalized.each do |name, value|
|
|
145
|
+
next if consumed[name]
|
|
146
|
+
|
|
147
|
+
appended = build_directive(name, value)
|
|
148
|
+
merged << appended if appended
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
merged
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalize an overrides hash to lowercased, hyphenated String keys so
|
|
155
|
+
# lookups are case-insensitive and Symbol/String keys are
|
|
156
|
+
# interchangeable. Underscores map to hyphens (no CSP directive contains
|
|
157
|
+
# an underscore) so a Symbol key like `:worker_src` addresses the
|
|
158
|
+
# `worker-src` directive. Blank keys are dropped.
|
|
159
|
+
#
|
|
160
|
+
# @param overrides [Hash]
|
|
161
|
+
# @return [Hash{String=>Object}]
|
|
162
|
+
def normalize_overrides(overrides)
|
|
163
|
+
overrides.each_with_object({}) do |(key, value), acc|
|
|
164
|
+
name = key.to_s.strip.downcase.tr('_', '-')
|
|
165
|
+
acc[name] = value unless name.empty?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The directive name (first token) of a `;`-terminated directive string,
|
|
170
|
+
# lowercased for case-insensitive matching.
|
|
171
|
+
#
|
|
172
|
+
# @param directive [String]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def directive_name(directive)
|
|
175
|
+
directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Build a single `;`-terminated directive string from a name and an
|
|
179
|
+
# override value, or nil when the value signals removal (nil/false).
|
|
180
|
+
#
|
|
181
|
+
# The directive name and each source token are validated against CSP's
|
|
182
|
+
# separator characters: a name or token containing `;` (which separates
|
|
183
|
+
# directives), a newline, or a carriage return raises {ArgumentError}
|
|
184
|
+
# rather than silently injecting extra directives — a real footgun when
|
|
185
|
+
# overrides come from env/config files. (The `false` removal sentinel is
|
|
186
|
+
# checked before {Array} so a bare `false` never becomes a `[false]`
|
|
187
|
+
# source list.)
|
|
188
|
+
#
|
|
189
|
+
# @param name [String] directive name
|
|
190
|
+
# @param value [String, Array, nil, false] source list, or nil/false to remove
|
|
191
|
+
# @return [String, nil]
|
|
192
|
+
# @raise [ArgumentError] if the name or a source token contains a `;`,
|
|
193
|
+
# newline, or carriage return
|
|
194
|
+
def build_directive(name, value)
|
|
195
|
+
return nil if value.nil? || value == false
|
|
196
|
+
|
|
197
|
+
reject_injection!('directive name', name)
|
|
198
|
+
sources = Array(value).filter_map do |token|
|
|
199
|
+
str = token.to_s.strip
|
|
200
|
+
next if str.empty?
|
|
201
|
+
|
|
202
|
+
reject_injection!("source for #{name}", str)
|
|
203
|
+
str
|
|
204
|
+
end.join(' ')
|
|
205
|
+
sources.empty? ? "#{name};" : "#{name} #{sources};"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Raise {ArgumentError} when +text+ carries a CSP directive/token
|
|
209
|
+
# separator (`;`, newline, or carriage return) that would let an
|
|
210
|
+
# override break out of its directive and inject another.
|
|
211
|
+
#
|
|
212
|
+
# @param label [String] what is being validated (for the error message)
|
|
213
|
+
# @param text [String]
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @raise [ArgumentError] if +text+ contains `;`, `\n`, or `\r`
|
|
216
|
+
def reject_injection!(label, text)
|
|
217
|
+
return unless text.match?(/[;\r\n]/)
|
|
218
|
+
|
|
219
|
+
raise ArgumentError,
|
|
220
|
+
"invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# CSP directives for the development environment.
|
|
224
|
+
#
|
|
225
|
+
# Development mode allows inline scripts/styles and hot reloading
|
|
226
|
+
# connections for better developer experience with build tools like Vite.
|
|
227
|
+
#
|
|
228
|
+
# @param nonce [String] nonce value injected into `script-src`
|
|
229
|
+
# @return [Array<String>] directive strings, each terminated with `;`
|
|
230
|
+
def development_directives(nonce)
|
|
231
|
+
[
|
|
232
|
+
"default-src 'none';",
|
|
233
|
+
"script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
|
|
234
|
+
"style-src 'self' 'unsafe-inline';",
|
|
235
|
+
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
|
236
|
+
"img-src 'self' data:;",
|
|
237
|
+
"font-src 'self';",
|
|
238
|
+
"object-src 'none';",
|
|
239
|
+
"base-uri 'self';",
|
|
240
|
+
"form-action 'self';",
|
|
241
|
+
"frame-ancestors 'none';",
|
|
242
|
+
"manifest-src 'self';",
|
|
243
|
+
"worker-src 'self' blob:;",
|
|
244
|
+
]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# CSP directives for the production environment.
|
|
248
|
+
#
|
|
249
|
+
# Production mode is more restrictive, only allowing HTTPS connections
|
|
250
|
+
# and nonce-only scripts for enhanced XSS protection.
|
|
251
|
+
#
|
|
252
|
+
# @param nonce [String] nonce value injected into `script-src`
|
|
253
|
+
# @return [Array<String>] directive strings, each terminated with `;`
|
|
254
|
+
def production_directives(nonce)
|
|
255
|
+
[
|
|
256
|
+
"default-src 'none';", # Restrict to same origin by default
|
|
257
|
+
"script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
|
|
258
|
+
"style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
|
|
259
|
+
"connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
|
|
260
|
+
"img-src 'self' data:;", # Allow images from same origin and data URIs
|
|
261
|
+
"font-src 'self';", # Allow fonts from same origin only
|
|
262
|
+
"object-src 'none';", # Block <object>, <embed>, and <applet> elements
|
|
263
|
+
"base-uri 'self';", # Restrict <base> tag targets to same origin
|
|
264
|
+
"form-action 'self';", # Restrict form submissions to same origin
|
|
265
|
+
"frame-ancestors 'none';", # Prevent site from being embedded in frames
|
|
266
|
+
"manifest-src 'self';", # Allow web app manifests from same origin
|
|
267
|
+
"worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs
|
|
268
|
+
]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
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'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# lib/otto/security/csrf_enforcement_wrapper.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'csrf_validation'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
# Per-route CSRF enforcement, applied at the handler layer.
|
|
10
|
+
#
|
|
11
|
+
# CSRF enforcement lives here rather than in the global +CSRFMiddleware+
|
|
12
|
+
# because +csrf=exempt+ is a per-route option: it is only known once a
|
|
13
|
+
# route has been matched (the middleware runs *ahead* of route matching and
|
|
14
|
+
# never sees route options, so a global block could not honor exemption —
|
|
15
|
+
# issue #186). This wrapper runs after matching, alongside +RouteAuthWrapper+,
|
|
16
|
+
# where +route_definition.csrf_exempt?+ is directly available. It is composed
|
|
17
|
+
# by +HandlerFactory+ only when CSRF protection is enabled, and wraps outside
|
|
18
|
+
# +RouteAuthWrapper+ so a forged unsafe request is rejected before any
|
|
19
|
+
# authentication work runs.
|
|
20
|
+
#
|
|
21
|
+
# The global +CSRFMiddleware+ retains only token *injection* into HTML
|
|
22
|
+
# responses (a response-shaping concern that is method/content-type based,
|
|
23
|
+
# not route based, so it stays global).
|
|
24
|
+
class CSRFEnforcementWrapper
|
|
25
|
+
include CSRFValidation
|
|
26
|
+
|
|
27
|
+
attr_reader :wrapped_handler, :route_definition, :config
|
|
28
|
+
|
|
29
|
+
# @param wrapped_handler [#call] the handler to guard
|
|
30
|
+
# @param route_definition [Otto::RouteDefinition] the matched route
|
|
31
|
+
# @param config [Otto::Security::Config] security config exposing CSRF settings
|
|
32
|
+
def initialize(wrapped_handler, route_definition, config)
|
|
33
|
+
@wrapped_handler = wrapped_handler
|
|
34
|
+
@route_definition = route_definition
|
|
35
|
+
@config = config
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param env [Hash] Rack environment
|
|
39
|
+
# @param extra_params [Hash] Additional parameters passed through to the handler
|
|
40
|
+
# @return [Array] Rack response tuple
|
|
41
|
+
def call(env, extra_params = {})
|
|
42
|
+
return wrapped_handler.call(env, extra_params) unless enforce?(env)
|
|
43
|
+
|
|
44
|
+
request = Otto::Request.new(env)
|
|
45
|
+
return wrapped_handler.call(env, extra_params) if valid_csrf_token?(request)
|
|
46
|
+
|
|
47
|
+
Otto.structured_log(:warn, 'CSRF validation failed',
|
|
48
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
49
|
+
handler: route_definition.definition,
|
|
50
|
+
referrer: request.referrer
|
|
51
|
+
))
|
|
52
|
+
csrf_error_response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Whether this request must present a valid CSRF token. Only unsafe
|
|
58
|
+
# methods on a non-exempt route are enforced when protection is enabled.
|
|
59
|
+
def enforce?(env)
|
|
60
|
+
return false unless config&.csrf_enabled?
|
|
61
|
+
return false if safe_method?(env['REQUEST_METHOD'])
|
|
62
|
+
return false if route_definition.csrf_exempt?
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# lib/otto/security/csrf_validation.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
# Shared CSRF token mechanics.
|
|
10
|
+
#
|
|
11
|
+
# Both the global +CSRFMiddleware+ (which injects tokens into HTML
|
|
12
|
+
# responses) and the per-route +CSRFEnforcementWrapper+ (which enforces
|
|
13
|
+
# tokens on unsafe requests, honoring +csrf=exempt+) mix this in so the
|
|
14
|
+
# two cannot drift on what counts as a safe method, where a token may be
|
|
15
|
+
# carried, or how a rejection is shaped. The including object must expose a
|
|
16
|
+
# +@config+ (an +Otto::Security::Config+).
|
|
17
|
+
module CSRFValidation
|
|
18
|
+
# HTTP methods that never mutate state and so are exempt from token
|
|
19
|
+
# validation (RFC 7231 safe methods plus TRACE).
|
|
20
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
21
|
+
|
|
22
|
+
# Static 403 body. Frozen once so a rejected request does not re-serialize
|
|
23
|
+
# the same JSON on every call.
|
|
24
|
+
CSRF_ERROR_BODY = {
|
|
25
|
+
error: 'CSRF token validation failed',
|
|
26
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
|
27
|
+
}.to_json.freeze
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def safe_method?(method)
|
|
32
|
+
SAFE_METHODS.include?(method.to_s.upcase)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_csrf_token?(request)
|
|
36
|
+
token = extract_csrf_token(request)
|
|
37
|
+
# Reject nil / blank / whitespace-only tokens up front, before creating
|
|
38
|
+
# a session or running HMAC verification — obviously-malformed input
|
|
39
|
+
# should not cause session churn (#186 review).
|
|
40
|
+
return false if token.nil? || token.strip.empty?
|
|
41
|
+
|
|
42
|
+
session_id = extract_session_id(request)
|
|
43
|
+
@config.verify_csrf_token(token, session_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def extract_csrf_token(request)
|
|
47
|
+
# Try form parameter first
|
|
48
|
+
token = request.params[@config.csrf_token_key]
|
|
49
|
+
|
|
50
|
+
# Try header if not in params
|
|
51
|
+
token ||= request.env[@config.csrf_header_key]
|
|
52
|
+
|
|
53
|
+
# Try alternative header format
|
|
54
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
|
55
|
+
|
|
56
|
+
token
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_session_id(request)
|
|
60
|
+
@config.get_or_create_session_id(request)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def csrf_error_response
|
|
64
|
+
[
|
|
65
|
+
403,
|
|
66
|
+
{
|
|
67
|
+
'content-type' => 'application/json',
|
|
68
|
+
'content-length' => CSRF_ERROR_BODY.bytesize.to_s,
|
|
69
|
+
},
|
|
70
|
+
[CSRF_ERROR_BODY],
|
|
71
|
+
]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|