otto 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ruby-lint.yml +9 -8
- data/.rubocop.yml +2 -1
- data/.rubocop_todo.yml +1384 -0
- data/CHANGELOG.rst +76 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +4 -4
- data/README.md +64 -1
- data/docs/.gitignore +1 -0
- data/docs/enrichment.md +128 -0
- data/docs/geo-country.md +2 -1
- data/lib/otto/core/error_handler.rb +15 -2
- data/lib/otto/env_keys.rb +46 -0
- data/lib/otto/privacy/anonymizer_resolver.rb +138 -0
- data/lib/otto/privacy/asn_resolver.rb +149 -0
- data/lib/otto/privacy/config.rb +159 -11
- data/lib/otto/privacy/core.rb +53 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +15 -2
- data/lib/otto/privacy.rb +9 -0
- data/lib/otto/request.rb +39 -0
- data/lib/otto/response.rb +7 -1
- data/lib/otto/security/config.rb +51 -6
- data/lib/otto/security/csp/emit_middleware.rb +2 -1
- data/lib/otto/security/csp/policy.rb +164 -10
- data/lib/otto/security/csp/request_extras.rb +256 -0
- data/lib/otto/security/csp/writer.rb +153 -12
- data/lib/otto/security/csp.rb +5 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +6 -1
- data/lib/otto/version.rb +1 -1
- metadata +7 -2
data/lib/otto/security/config.rb
CHANGED
|
@@ -90,7 +90,7 @@ class Otto
|
|
|
90
90
|
:csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
|
|
91
91
|
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
|
|
92
92
|
:csp_report_uri, :csp_report_to_url, :csp_violation_callback,
|
|
93
|
-
:csp_directive_overrides
|
|
93
|
+
:csp_directive_overrides, :csp_request_extras_enabled
|
|
94
94
|
|
|
95
95
|
# Initialize security configuration with safe defaults
|
|
96
96
|
#
|
|
@@ -119,6 +119,7 @@ class Otto
|
|
|
119
119
|
@csp_report_to_url = nil
|
|
120
120
|
@csp_violation_callback = nil
|
|
121
121
|
@csp_directive_overrides = {}
|
|
122
|
+
@csp_request_extras_enabled = false
|
|
122
123
|
@csp_script_src_override_warned = false
|
|
123
124
|
@rate_limiting_config = { custom_rules: {} }
|
|
124
125
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
@@ -512,6 +513,39 @@ class Otto
|
|
|
512
513
|
@csp_nonce_enabled
|
|
513
514
|
end
|
|
514
515
|
|
|
516
|
+
# Enable the request-scoped CSP directive extras channel (delano/otto#243)
|
|
517
|
+
#
|
|
518
|
+
# Off by default: `env['otto.csp.extra_directives']` is a write surface
|
|
519
|
+
# that ANY middleware in the Rack stack can reach — a lower-trust
|
|
520
|
+
# position than boot code — so the channel does not exist until the app
|
|
521
|
+
# explicitly opts in here. Until then the Writer ignores the env key
|
|
522
|
+
# entirely (no sanitize work, no logs).
|
|
523
|
+
#
|
|
524
|
+
# With the channel enabled, a handler (or middleware) can widen
|
|
525
|
+
# directives with values only known at request time by writing a hash of
|
|
526
|
+
# directive name => additional origin tokens to the env before the
|
|
527
|
+
# response is finalized. Extras are additive-only and sanitized
|
|
528
|
+
# defensively; see {Otto::Security::CSP::RequestExtras}.
|
|
529
|
+
#
|
|
530
|
+
# @return [void]
|
|
531
|
+
# @raise [FrozenError] if configuration is frozen
|
|
532
|
+
#
|
|
533
|
+
# @example At boot, alongside nonce CSP
|
|
534
|
+
# config.enable_csp_with_nonce!
|
|
535
|
+
# config.enable_csp_request_extras!
|
|
536
|
+
def enable_csp_request_extras!
|
|
537
|
+
ensure_not_frozen!
|
|
538
|
+
|
|
539
|
+
@csp_request_extras_enabled = true
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Check if the request-scoped CSP directive extras channel is enabled
|
|
543
|
+
#
|
|
544
|
+
# @return [Boolean] true when {#enable_csp_request_extras!} was called
|
|
545
|
+
def csp_request_extras_enabled?
|
|
546
|
+
@csp_request_extras_enabled
|
|
547
|
+
end
|
|
548
|
+
|
|
515
549
|
# Set the Rack env key the framework-owned lazy nonce is memoized under
|
|
516
550
|
# ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
|
|
517
551
|
# `'otto.nonce'`; override it for an app with an existing convention (e.g.
|
|
@@ -649,14 +683,25 @@ class Otto
|
|
|
649
683
|
#
|
|
650
684
|
# @param nonce [String] The nonce value to include in the CSP
|
|
651
685
|
# @param development_mode [Boolean] Whether to use development-friendly directives
|
|
686
|
+
# @param extra_directives [Hash{String=>Array<String>}, nil] request-scoped
|
|
687
|
+
# extra source tokens appended additively after the overrides merge
|
|
688
|
+
# (see {Otto::Security::CSP::Policy.append_extra_sources}, delano/otto#243).
|
|
689
|
+
# Per-request data — passed through, never stored on this (deep-frozen
|
|
690
|
+
# in production) config.
|
|
691
|
+
# @yield [applied, dropped] forwarded to
|
|
692
|
+
# {Otto::Security::CSP::Policy.nonce_policy}: the extras entries that
|
|
693
|
+
# actually landed in the policy and the entries dropped because their
|
|
694
|
+
# directive was absent.
|
|
652
695
|
# @return [String] Complete CSP policy string
|
|
653
|
-
def generate_nonce_csp(nonce, development_mode: false)
|
|
696
|
+
def generate_nonce_csp(nonce, development_mode: false, extra_directives: nil, &extras_outcome)
|
|
654
697
|
Otto::Security::CSP::Policy.nonce_policy(
|
|
655
698
|
nonce,
|
|
656
|
-
development_mode:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
699
|
+
development_mode: development_mode,
|
|
700
|
+
report_uri: @csp_report_uri,
|
|
701
|
+
report_to_url: @csp_report_to_url,
|
|
702
|
+
directive_overrides: @csp_directive_overrides,
|
|
703
|
+
extra_directives: extra_directives,
|
|
704
|
+
&extras_outcome
|
|
660
705
|
)
|
|
661
706
|
end
|
|
662
707
|
|
|
@@ -66,7 +66,8 @@ class Otto
|
|
|
66
66
|
|
|
67
67
|
Otto::Security::CSP::Writer.apply(
|
|
68
68
|
headers, nonce,
|
|
69
|
-
config: @config, mode: :backstop, development_mode: development_mode?(env)
|
|
69
|
+
config: @config, mode: :backstop, development_mode: development_mode?(env),
|
|
70
|
+
env: env # request-scoped extras (env['otto.csp.extra_directives'], #243)
|
|
70
71
|
)
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -29,6 +29,23 @@ class Otto
|
|
|
29
29
|
# can never drift.
|
|
30
30
|
REPORTING_GROUP = 'otto-csp'
|
|
31
31
|
|
|
32
|
+
# CSP directives that take NO value at all. Appending a source to one
|
|
33
|
+
# of these does not widen it — it makes the directive SYNTACTICALLY
|
|
34
|
+
# malformed (`upgrade-insecure-requests https://x;`), and a browser
|
|
35
|
+
# drops a malformed directive wholesale. So an extras entry keyed to
|
|
36
|
+
# one of them would silently DISABLE the directive it names, the exact
|
|
37
|
+
# inverse of the additive contract. {.append_extra_sources} therefore
|
|
38
|
+
# leaves them byte-identical and reports the entry as dropped.
|
|
39
|
+
#
|
|
40
|
+
# Deliberately only these two: `sandbox`, `trusted-types`, and
|
|
41
|
+
# `require-trusted-types-for` take non-source values, which makes
|
|
42
|
+
# appending an origin to them useless, but the result is still valid
|
|
43
|
+
# syntax the browser honours — no silent policy loss.
|
|
44
|
+
# `upgrade-insecure-requests` remains a CSP extension. Conversely,
|
|
45
|
+
# `block-all-mixed-content` is obsolete in the Mixed Content standard,
|
|
46
|
+
# but is retained here to protect legacy policies that still emit it.
|
|
47
|
+
VALUELESS_DIRECTIVES = %w[upgrade-insecure-requests block-all-mixed-content].freeze
|
|
48
|
+
|
|
32
49
|
# Build the per-request nonce CSP policy string.
|
|
33
50
|
#
|
|
34
51
|
# Byte-identical to Otto's historical {Otto::Security::Config#generate_nonce_csp}
|
|
@@ -47,10 +64,26 @@ class Otto
|
|
|
47
64
|
# into the base set before reporting directives are appended. See
|
|
48
65
|
# {.merge_directives} for the accepted shape (replace a directive's
|
|
49
66
|
# sources, add a new directive, or remove one with a nil/false value).
|
|
67
|
+
# @param extra_directives [Hash{String=>Array<String>}, nil]
|
|
68
|
+
# request-scoped extra source tokens appended additively AFTER the
|
|
69
|
+
# overrides merge and BEFORE the reporting directives. Expected
|
|
70
|
+
# pre-sanitized (see {Otto::Security::CSP::RequestExtras.from_env});
|
|
71
|
+
# see {.append_extra_sources} for the semantics (append to present
|
|
72
|
+
# directives only, dedupe, drop absent-directive keys).
|
|
73
|
+
# @yield [applied, dropped] the extras outcome from
|
|
74
|
+
# {.append_extra_sources} (both empty hashes when no extras were
|
|
75
|
+
# given): the entries actually folded into the policy and the
|
|
76
|
+
# entries dropped because their directive was absent. This is the
|
|
77
|
+
# return channel {Otto::Security::CSP::Writer} uses to log drops
|
|
78
|
+
# with request context and report only real appends — the policy
|
|
79
|
+
# string stays the sole return value.
|
|
50
80
|
# @return [String] complete CSP policy string
|
|
51
|
-
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil,
|
|
81
|
+
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil,
|
|
82
|
+
directive_overrides: nil, extra_directives: nil)
|
|
52
83
|
directives = development_mode ? development_directives(nonce) : production_directives(nonce)
|
|
53
84
|
directives = merge_directives(directives, directive_overrides)
|
|
85
|
+
directives, applied_extras, dropped_extras = append_extra_sources(directives, extra_directives)
|
|
86
|
+
yield(applied_extras, dropped_extras) if block_given?
|
|
54
87
|
uri_directive = report_uri_directive(report_uri)
|
|
55
88
|
to_directive = report_to_directive(report_to_url)
|
|
56
89
|
directives += ["#{uri_directive};"] if uri_directive
|
|
@@ -151,28 +184,147 @@ class Otto
|
|
|
151
184
|
merged
|
|
152
185
|
end
|
|
153
186
|
|
|
187
|
+
# Fold request-scoped extra source tokens ADDITIVELY into a built
|
|
188
|
+
# directive set (delano/otto#243). A SIBLING of {.merge_directives},
|
|
189
|
+
# deliberately not a change to it: boot-time overrides REPLACE a
|
|
190
|
+
# directive's sources wholesale, request-time extras only ever APPEND.
|
|
191
|
+
#
|
|
192
|
+
# Semantics:
|
|
193
|
+
# - A directive PRESENT in the built list gets the extra tokens
|
|
194
|
+
# appended to its source list, deduplicated (a token already present
|
|
195
|
+
# is not appended again).
|
|
196
|
+
# - A directive ABSENT from the built list (base set minus any
|
|
197
|
+
# boot-override removals) is DROPPED and reported in the return:
|
|
198
|
+
# directives like `form-action` do not fall back to `default-src`,
|
|
199
|
+
# so CREATING one here would tighten the policy (suddenly blocking
|
|
200
|
+
# unrelated forms), and re-adding a directive a boot override
|
|
201
|
+
# deliberately removed (nil/false override) would resurrect it.
|
|
202
|
+
# - A directive that takes NO value ({VALUELESS_DIRECTIVES}, e.g.
|
|
203
|
+
# `upgrade-insecure-requests`) is left BYTE-IDENTICAL and its entry
|
|
204
|
+
# is DROPPED: appending a source there would emit
|
|
205
|
+
# `upgrade-insecure-requests https://x;`, which browsers treat as
|
|
206
|
+
# malformed and discard — an extras key would silently turn the
|
|
207
|
+
# directive OFF instead of widening it.
|
|
208
|
+
# - An entry whose token list is nil/empty leaves the base directive
|
|
209
|
+
# BYTE-IDENTICAL — the directive string is returned as-is, never
|
|
210
|
+
# rebuilt, so `worker-src 'self' blob:;` can never collapse into a
|
|
211
|
+
# bare `worker-src;`.
|
|
212
|
+
#
|
|
213
|
+
# Callers pass PRE-SANITIZED extras
|
|
214
|
+
# ({Otto::Security::CSP::RequestExtras.from_env} guarantees no
|
|
215
|
+
# `;`/CR/LF and origin-only tokens), so this helper does not
|
|
216
|
+
# re-validate the grammar; it stays defensive only about shape
|
|
217
|
+
# (nil/empty values are skipped gracefully, request-time input must
|
|
218
|
+
# never raise).
|
|
219
|
+
#
|
|
220
|
+
# Pure — like everything in this module, a function of its arguments
|
|
221
|
+
# with no logging and no env access. Dropped entries are RETURNED,
|
|
222
|
+
# not logged, so the one caller with the request in hand
|
|
223
|
+
# ({Otto::Security::CSP::Writer}) can log them with full request
|
|
224
|
+
# context.
|
|
225
|
+
#
|
|
226
|
+
# @param directives [Array<String>] built directive strings, each
|
|
227
|
+
# `;`-terminated (post {.merge_directives})
|
|
228
|
+
# @param extras [Hash{String=>Array<String>}, nil] normalized directive
|
|
229
|
+
# name => extra source tokens
|
|
230
|
+
# @return [Array(Array<String>, Hash, Hash)] `[merged, applied, dropped]`:
|
|
231
|
+
# the directive strings with extras appended; the extras entries that
|
|
232
|
+
# addressed a PRESENT directive (their tokens are in the merged
|
|
233
|
+
# policy — a token already present is simply not duplicated); and
|
|
234
|
+
# the entries dropped because their directive was absent or takes no
|
|
235
|
+
# value ({VALUELESS_DIRECTIVES}). Entries with nil/empty token lists
|
|
236
|
+
# appear in neither hash.
|
|
237
|
+
def append_extra_sources(directives, extras)
|
|
238
|
+
return [directives, {}, {}] if extras.nil? || extras.empty?
|
|
239
|
+
|
|
240
|
+
remaining = extras.dup
|
|
241
|
+
applied = {}
|
|
242
|
+
merged = directives.map do |directive|
|
|
243
|
+
name = directive_name(directive)
|
|
244
|
+
tokens = remaining.delete(name)
|
|
245
|
+
next directive if tokens.nil? || Array(tokens).empty?
|
|
246
|
+
|
|
247
|
+
if valueless_directive?(name)
|
|
248
|
+
# Put the entry back so it surfaces in `dropped` rather than
|
|
249
|
+
# vanishing: the caller with the request in hand must be able to
|
|
250
|
+
# log that these tokens never landed.
|
|
251
|
+
remaining[name] = tokens
|
|
252
|
+
next directive
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
applied[name] = tokens
|
|
256
|
+
append_sources_to(directive, tokens)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
dropped = remaining.reject { |_name, tokens| Array(tokens).empty? }
|
|
260
|
+
[merged, applied, dropped]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Append tokens to one `;`-terminated directive string, deduplicated
|
|
264
|
+
# against its existing sources. Returns the directive UNCHANGED when
|
|
265
|
+
# every token is already present (the byte-identical invariant).
|
|
266
|
+
#
|
|
267
|
+
# @param directive [String] e.g. `"form-action 'self';"`
|
|
268
|
+
# @param tokens [Array<String>] pre-sanitized source tokens
|
|
269
|
+
# @return [String] `;`-terminated directive string
|
|
270
|
+
def append_sources_to(directive, tokens)
|
|
271
|
+
body = directive.to_s.strip.delete_suffix(';')
|
|
272
|
+
name, sources = body.split(/\s+/, 2)
|
|
273
|
+
existing = sources.to_s.split(/\s+/)
|
|
274
|
+
additions = Array(tokens).map(&:to_s).reject { |token| token.empty? || existing.include?(token) }
|
|
275
|
+
return directive if additions.empty?
|
|
276
|
+
|
|
277
|
+
"#{name} #{(existing + additions).join(' ')};"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Normalize one directive name: stripped, lowercased, underscores
|
|
281
|
+
# mapped to hyphens (no CSP directive contains an underscore), so a
|
|
282
|
+
# Symbol like `:worker_src` addresses the `worker-src` directive.
|
|
283
|
+
#
|
|
284
|
+
# The SINGLE normalization used by both {.normalize_overrides} and
|
|
285
|
+
# {Otto::Security::CSP::RequestExtras.from_env} — the present/absent
|
|
286
|
+
# matching in {.append_extra_sources} depends on both sides applying
|
|
287
|
+
# identical normalization, so it lives in exactly one place.
|
|
288
|
+
#
|
|
289
|
+
# @param name [String, Symbol]
|
|
290
|
+
# @return [String] normalized directive name (may be empty for blank input)
|
|
291
|
+
def normalize_directive_name(name)
|
|
292
|
+
name.to_s.strip.downcase.tr('_', '-')
|
|
293
|
+
end
|
|
294
|
+
|
|
154
295
|
# Normalize an overrides hash to lowercased, hyphenated String keys so
|
|
155
296
|
# lookups are case-insensitive and Symbol/String keys are
|
|
156
|
-
# interchangeable
|
|
157
|
-
#
|
|
158
|
-
# `worker-src` directive. Blank keys are dropped.
|
|
297
|
+
# interchangeable (see {.normalize_directive_name}). Blank keys are
|
|
298
|
+
# dropped.
|
|
159
299
|
#
|
|
160
300
|
# @param overrides [Hash]
|
|
161
301
|
# @return [Hash{String=>Object}]
|
|
162
302
|
def normalize_overrides(overrides)
|
|
163
303
|
overrides.each_with_object({}) do |(key, value), acc|
|
|
164
|
-
name = key
|
|
304
|
+
name = normalize_directive_name(key)
|
|
165
305
|
acc[name] = value unless name.empty?
|
|
166
306
|
end
|
|
167
307
|
end
|
|
168
308
|
|
|
169
309
|
# The directive name (first token) of a `;`-terminated directive string,
|
|
170
|
-
#
|
|
310
|
+
# run through {.normalize_directive_name} so the built policy and the
|
|
311
|
+
# extras/override hashes are compared on ONE normalization.
|
|
171
312
|
#
|
|
172
313
|
# @param directive [String]
|
|
173
314
|
# @return [String]
|
|
174
315
|
def directive_name(directive)
|
|
175
|
-
directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first
|
|
316
|
+
normalize_directive_name(directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# True when +name+ addresses a directive that takes no value at all
|
|
320
|
+
# (see {VALUELESS_DIRECTIVES}). Normalizes through
|
|
321
|
+
# {.normalize_directive_name}, so `:upgrade_insecure_requests` and
|
|
322
|
+
# `'Upgrade-Insecure-Requests'` are recognized like the canonical form.
|
|
323
|
+
#
|
|
324
|
+
# @param name [String, Symbol]
|
|
325
|
+
# @return [Boolean]
|
|
326
|
+
def valueless_directive?(name)
|
|
327
|
+
VALUELESS_DIRECTIVES.include?(normalize_directive_name(name))
|
|
176
328
|
end
|
|
177
329
|
|
|
178
330
|
# Build a single `;`-terminated directive string from a name and an
|
|
@@ -222,15 +374,17 @@ class Otto
|
|
|
222
374
|
|
|
223
375
|
# CSP directives for the development environment.
|
|
224
376
|
#
|
|
225
|
-
# Development mode allows inline scripts
|
|
226
|
-
#
|
|
377
|
+
# Development mode allows nonce-authorized inline scripts, inline styles,
|
|
378
|
+
# HTTP(S) scripts, and hot-reloading connections for build tools such as
|
|
379
|
+
# Vite. HTTP(S) script sources support both same-origin reverse proxies
|
|
380
|
+
# (for example Caddy) and direct local Vite servers on another port.
|
|
227
381
|
#
|
|
228
382
|
# @param nonce [String] nonce value injected into `script-src`
|
|
229
383
|
# @return [Array<String>] directive strings, each terminated with `;`
|
|
230
384
|
def development_directives(nonce)
|
|
231
385
|
[
|
|
232
386
|
"default-src 'none';",
|
|
233
|
-
"script-src 'nonce-#{nonce}'
|
|
387
|
+
"script-src 'self' 'nonce-#{nonce}' http: https:;",
|
|
234
388
|
"style-src 'self' 'unsafe-inline';",
|
|
235
389
|
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
|
236
390
|
"img-src 'self' data:;",
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# lib/otto/security/csp/request_extras.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative 'policy'
|
|
8
|
+
|
|
9
|
+
class Otto
|
|
10
|
+
module Security
|
|
11
|
+
module CSP
|
|
12
|
+
# Reads and sanitizes request-scoped CSP directive extras from the Rack
|
|
13
|
+
# env (delano/otto#243).
|
|
14
|
+
#
|
|
15
|
+
# This is the opt-in channel for widening CSP directives with values only
|
|
16
|
+
# known at request time: the app enables it at boot with
|
|
17
|
+
# {Otto::Security::Config#enable_csp_request_extras!} (default off — the
|
|
18
|
+
# env key is a write surface any middleware can reach, so it does not
|
|
19
|
+
# exist until boot code says so), then a handler writes a hash of
|
|
20
|
+
# directive name => additional source tokens to
|
|
21
|
+
# `env['otto.csp.extra_directives']` before the response is finalized,
|
|
22
|
+
# and the policy build folds the sanitized survivors in ADDITIVELY (see
|
|
23
|
+
# {Otto::Security::CSP::Policy.append_extra_sources}). The motivating
|
|
24
|
+
# case is a multi-tenant app that must admit the resolved tenant's SSO
|
|
25
|
+
# IdP origin into `form-action` — per-request data no boot-time override
|
|
26
|
+
# can express.
|
|
27
|
+
#
|
|
28
|
+
# This module deliberately has the OPPOSITE failure mode of
|
|
29
|
+
# {Otto::Security::CSP::Policy}: Policy is pure functions that raise on
|
|
30
|
+
# bad input (boot-time overrides should fail loud), while request-time
|
|
31
|
+
# extras must NEVER raise — a hostile or malformed value is dropped and
|
|
32
|
+
# logged, and the response still ships with the base policy intact.
|
|
33
|
+
#
|
|
34
|
+
# Sanitization rules:
|
|
35
|
+
# - Directive names are normalized via
|
|
36
|
+
# {Otto::Security::CSP::Policy.normalize_directive_name} (the same
|
|
37
|
+
# normalization {Policy.normalize_overrides} applies to boot-time
|
|
38
|
+
# overrides); blank keys are dropped. Two raw keys that normalize to
|
|
39
|
+
# the same directive (`'form_action'` and `'form-action'`) have their
|
|
40
|
+
# token lists merged (union) — nothing is silently overwritten.
|
|
41
|
+
# - {REFUSED_DIRECTIVES} are dropped wholesale. For the `script-src`
|
|
42
|
+
# family this is defence-in-depth policy, NOT nonce protection: extras
|
|
43
|
+
# are additive, so the nonce source would survive an append — refusing
|
|
44
|
+
# the family simply keeps the request channel away from the one
|
|
45
|
+
# directive class that gates script execution. `default-src` is refused
|
|
46
|
+
# because widening it widens every unlisted directive at once. Directives
|
|
47
|
+
# with no value are refused because an appended origin makes the entire
|
|
48
|
+
# directive malformed; {Policy.append_extra_sources} retains the same
|
|
49
|
+
# guard for callers that bypass this sanitizer.
|
|
50
|
+
# - Tokens must be ORIGINS: `scheme://host[:port]` with an http/https
|
|
51
|
+
# scheme and a non-empty host — no path/query/fragment/userinfo, no
|
|
52
|
+
# whitespace, no `;`/CR/LF, no wildcards, no quotes. Keyword sources
|
|
53
|
+
# (`'self'`, `'unsafe-inline'`, ...) and scheme sources (`data:`,
|
|
54
|
+
# `https:`) are rejected. Accepted tokens are normalized to
|
|
55
|
+
# `scheme://host[:port]` with a downcased host and default ports
|
|
56
|
+
# omitted. Hosts follow strip-then-validate: a single trailing dot
|
|
57
|
+
# (the FQDN root form browsers do NOT treat as the same origin) is
|
|
58
|
+
# stripped before validation, any remaining trailing dot rejects the
|
|
59
|
+
# token, and a host containing `%` (percent-encoding that URI's host
|
|
60
|
+
# parser passes through literally) is rejected outright. An explicit
|
|
61
|
+
# port must fall in 1..65535 — URI accepts arbitrarily large all-digit
|
|
62
|
+
# ports that no browser can match.
|
|
63
|
+
#
|
|
64
|
+
# Every key/token dropped during sanitization is logged at :warn via
|
|
65
|
+
# {Otto.structured_log} with a distinct reason (`:invalid_shape`,
|
|
66
|
+
# `:refused_directive`, `:not_an_origin`) and privacy-safe request
|
|
67
|
+
# context. Entries dropped later, during the policy append (an absent
|
|
68
|
+
# directive, a valueless directive supplied by a caller that bypassed this
|
|
69
|
+
# sanitizer, or a config without extras support), are logged by
|
|
70
|
+
# {Otto::Security::CSP::Writer} — the same message, `reason:
|
|
71
|
+
# :absent_directive` / `:valueless_directive` /
|
|
72
|
+
# `:config_without_extras_support`.
|
|
73
|
+
module RequestExtras
|
|
74
|
+
# The env key the consuming app writes. Hardcoded on purpose (not
|
|
75
|
+
# configurable) — it is a cross-gem contract; see also
|
|
76
|
+
# Otto::EnvKeys::CSP::EXTRA_DIRECTIVES (require 'otto/env_keys').
|
|
77
|
+
ENV_KEY = 'otto.csp.extra_directives'
|
|
78
|
+
|
|
79
|
+
# Directives the request channel refuses to touch, wholesale. See the
|
|
80
|
+
# module docs for why (defence-in-depth for the script family — NOT
|
|
81
|
+
# nonce-stripping, since appends keep the nonce — blast radius for
|
|
82
|
+
# default-src — and invalid syntax for directives with no value).
|
|
83
|
+
# Keep the policy-level guard too: Policy.nonce_policy(extra_directives:)
|
|
84
|
+
# is public and can bypass this sanitizer.
|
|
85
|
+
REFUSED_DIRECTIVES = (
|
|
86
|
+
%w[script-src script-src-elem script-src-attr default-src] +
|
|
87
|
+
Policy::VALUELESS_DIRECTIVES
|
|
88
|
+
).freeze
|
|
89
|
+
|
|
90
|
+
# The only schemes an extra origin may carry.
|
|
91
|
+
ALLOWED_SCHEMES = %w[http https].freeze
|
|
92
|
+
|
|
93
|
+
# Characters that can never appear in an origin token: whitespace and
|
|
94
|
+
# `;`/CR/LF (directive separators), quotes (keyword sources), and `*`
|
|
95
|
+
# (wildcards). Checked before URI parsing so hostile tokens are
|
|
96
|
+
# rejected even when URI would tolerate them.
|
|
97
|
+
FORBIDDEN_CHARS = /[\s;'"*\r\n]/
|
|
98
|
+
|
|
99
|
+
module_function
|
|
100
|
+
|
|
101
|
+
# Read and sanitize the request-scoped extras from the Rack env.
|
|
102
|
+
#
|
|
103
|
+
# Never raises. Anything that fails validation is dropped and logged;
|
|
104
|
+
# whatever survives is returned.
|
|
105
|
+
#
|
|
106
|
+
# @param env [Hash] the Rack environment
|
|
107
|
+
# @return [Hash{String=>Array<String>}, nil] normalized directive name
|
|
108
|
+
# => normalized origin tokens; nil when the env key is absent, the
|
|
109
|
+
# value is not a Hash, or nothing survived sanitization — callers
|
|
110
|
+
# never see an empty hash
|
|
111
|
+
def from_env(env)
|
|
112
|
+
raw = env[ENV_KEY]
|
|
113
|
+
return nil if raw.nil?
|
|
114
|
+
|
|
115
|
+
# Compute the request context once per call and thread it through —
|
|
116
|
+
# a hostile payload can produce many drops, and re-deriving the
|
|
117
|
+
# context per token would defeat LoggingHelpers' compute-once-then-
|
|
118
|
+
# merge pattern.
|
|
119
|
+
context = Otto::LoggingHelpers.request_context(env)
|
|
120
|
+
|
|
121
|
+
unless raw.is_a?(Hash)
|
|
122
|
+
log_drop(context, directive: nil, token: raw, reason: :invalid_shape)
|
|
123
|
+
return nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
extras = raw.each_with_object({}) do |(key, value), acc|
|
|
127
|
+
name = Policy.normalize_directive_name(key)
|
|
128
|
+
if name.empty?
|
|
129
|
+
log_drop(context, directive: key, token: value, reason: :invalid_shape)
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
if REFUSED_DIRECTIVES.include?(name)
|
|
133
|
+
log_drop(context, directive: name, token: value, reason: :refused_directive)
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
tokens = sanitize_tokens(context, name, value)
|
|
138
|
+
next if tokens.empty?
|
|
139
|
+
|
|
140
|
+
# Two raw keys can normalize to the same directive ('form_action'
|
|
141
|
+
# and 'form-action'): union the token lists rather than letting
|
|
142
|
+
# the later key silently clobber the earlier one.
|
|
143
|
+
acc[name] = (acc[name] || []) | tokens
|
|
144
|
+
end
|
|
145
|
+
extras.empty? ? nil : extras
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Sanitize one directive's token value into normalized origin strings.
|
|
149
|
+
# A String value is treated as a whitespace-separated source list (the
|
|
150
|
+
# same ergonomics as a {Policy.merge_directives} String override); an
|
|
151
|
+
# Array is taken element-wise. Anything else drops the key.
|
|
152
|
+
#
|
|
153
|
+
# @param context [Hash] precomputed request context (for log payloads)
|
|
154
|
+
# @param name [String] normalized directive name (for log context)
|
|
155
|
+
# @param value [String, Array<String>, Object]
|
|
156
|
+
# @return [Array<String>] surviving normalized origins (deduplicated)
|
|
157
|
+
def sanitize_tokens(context, name, value)
|
|
158
|
+
candidates =
|
|
159
|
+
case value
|
|
160
|
+
when String then value.split
|
|
161
|
+
when Array then value
|
|
162
|
+
else
|
|
163
|
+
log_drop(context, directive: name, token: value, reason: :invalid_shape)
|
|
164
|
+
return []
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
candidates.filter_map do |token|
|
|
168
|
+
unless token.is_a?(String)
|
|
169
|
+
log_drop(context, directive: name, token: token, reason: :invalid_shape)
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
normalized = normalize_origin(token)
|
|
174
|
+
if normalized.nil?
|
|
175
|
+
log_drop(context, directive: name, token: token, reason: :not_an_origin)
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
normalized
|
|
179
|
+
end.uniq
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Validate and normalize a single token as an http(s) origin.
|
|
183
|
+
#
|
|
184
|
+
# @param token [String]
|
|
185
|
+
# @return [String, nil] `scheme://host[:port]` (downcased host, default
|
|
186
|
+
# port omitted), or nil when the token is not an acceptable origin
|
|
187
|
+
def normalize_origin(token)
|
|
188
|
+
return nil if token.empty? || token.match?(FORBIDDEN_CHARS)
|
|
189
|
+
|
|
190
|
+
uri = begin
|
|
191
|
+
URI.parse(token)
|
|
192
|
+
rescue URI::InvalidURIError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
return nil unless uri.is_a?(URI::HTTP) # URI::HTTPS is a subclass
|
|
197
|
+
|
|
198
|
+
scheme = uri.scheme.to_s.downcase
|
|
199
|
+
return nil unless ALLOWED_SCHEMES.include?(scheme)
|
|
200
|
+
return nil if uri.userinfo
|
|
201
|
+
return nil unless uri.path.to_s.empty?
|
|
202
|
+
return nil if uri.query || uri.fragment
|
|
203
|
+
|
|
204
|
+
host = normalize_host(uri.host)
|
|
205
|
+
return nil if host.nil?
|
|
206
|
+
# URI accepts arbitrarily large all-digit ports (and port 0); no
|
|
207
|
+
# browser can match an origin outside the TCP port range, so an
|
|
208
|
+
# out-of-range port is a silent-failure token — reject it. uri.port
|
|
209
|
+
# is never nil for URI::HTTP (defaults apply), and the default ports
|
|
210
|
+
# 80/443 are in range, so one check covers both shapes.
|
|
211
|
+
return nil unless (1..65_535).cover?(uri.port)
|
|
212
|
+
|
|
213
|
+
origin = "#{scheme}://#{host}"
|
|
214
|
+
origin << ":#{uri.port}" unless uri.port == uri.default_port
|
|
215
|
+
origin
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate and normalize an origin host: downcased, strip-then-validate
|
|
219
|
+
# for trailing dots (a single trailing dot — the FQDN root form — is
|
|
220
|
+
# stripped, since browsers do not equate `example.com.` with
|
|
221
|
+
# `example.com`; any dot still trailing after the strip rejects the
|
|
222
|
+
# host), and any `%` rejects the host outright (URI's host parser
|
|
223
|
+
# passes percent-encodings through literally, so accepting one would
|
|
224
|
+
# ship raw `%00`-style bytes in a response header).
|
|
225
|
+
#
|
|
226
|
+
# @param raw [String, nil] the parsed URI host
|
|
227
|
+
# @return [String, nil] normalized host, or nil when unacceptable
|
|
228
|
+
def normalize_host(raw)
|
|
229
|
+
host = raw.to_s.downcase
|
|
230
|
+
return nil if host.empty? || host.include?('%')
|
|
231
|
+
|
|
232
|
+
host = host.delete_suffix('.')
|
|
233
|
+
return nil if host.empty? || host.end_with?('.')
|
|
234
|
+
|
|
235
|
+
host
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Log one dropped key/token at :warn with privacy-safe request context.
|
|
239
|
+
# Never :debug — drops are actionable signal, and structured_log skips
|
|
240
|
+
# :debug unless Otto.debug is on.
|
|
241
|
+
#
|
|
242
|
+
# @param context [Hash] precomputed request context ({Otto::LoggingHelpers.request_context})
|
|
243
|
+
def log_drop(context, directive:, token:, reason:)
|
|
244
|
+
Otto.structured_log(
|
|
245
|
+
:warn, 'CSP request extra dropped',
|
|
246
|
+
context.merge(
|
|
247
|
+
directive: directive&.to_s,
|
|
248
|
+
token: token.inspect.slice(0, 128),
|
|
249
|
+
reason: reason
|
|
250
|
+
).compact
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|