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.
@@ -90,7 +90,7 @@ class Otto
90
90
  :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
91
91
  :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
92
92
  :csp_report_uri, :csp_report_to_url, :csp_violation_callback,
93
- :csp_directive_overrides
93
+ :csp_directive_overrides, :csp_request_extras_enabled
94
94
 
95
95
  # Initialize security configuration with safe defaults
96
96
  #
@@ -119,6 +119,7 @@ class Otto
119
119
  @csp_report_to_url = nil
120
120
  @csp_violation_callback = nil
121
121
  @csp_directive_overrides = {}
122
+ @csp_request_extras_enabled = false
122
123
  @csp_script_src_override_warned = false
123
124
  @rate_limiting_config = { custom_rules: {} }
124
125
  @ip_privacy_config = Otto::Privacy::Config.new
@@ -512,6 +513,39 @@ class Otto
512
513
  @csp_nonce_enabled
513
514
  end
514
515
 
516
+ # Enable the request-scoped CSP directive extras channel (delano/otto#243)
517
+ #
518
+ # Off by default: `env['otto.csp.extra_directives']` is a write surface
519
+ # that ANY middleware in the Rack stack can reach — a lower-trust
520
+ # position than boot code — so the channel does not exist until the app
521
+ # explicitly opts in here. Until then the Writer ignores the env key
522
+ # entirely (no sanitize work, no logs).
523
+ #
524
+ # With the channel enabled, a handler (or middleware) can widen
525
+ # directives with values only known at request time by writing a hash of
526
+ # directive name => additional origin tokens to the env before the
527
+ # response is finalized. Extras are additive-only and sanitized
528
+ # defensively; see {Otto::Security::CSP::RequestExtras}.
529
+ #
530
+ # @return [void]
531
+ # @raise [FrozenError] if configuration is frozen
532
+ #
533
+ # @example At boot, alongside nonce CSP
534
+ # config.enable_csp_with_nonce!
535
+ # config.enable_csp_request_extras!
536
+ def enable_csp_request_extras!
537
+ ensure_not_frozen!
538
+
539
+ @csp_request_extras_enabled = true
540
+ end
541
+
542
+ # Check if the request-scoped CSP directive extras channel is enabled
543
+ #
544
+ # @return [Boolean] true when {#enable_csp_request_extras!} was called
545
+ def csp_request_extras_enabled?
546
+ @csp_request_extras_enabled
547
+ end
548
+
515
549
  # Set the Rack env key the framework-owned lazy nonce is memoized under
516
550
  # ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
517
551
  # `'otto.nonce'`; override it for an app with an existing convention (e.g.
@@ -649,14 +683,25 @@ class Otto
649
683
  #
650
684
  # @param nonce [String] The nonce value to include in the CSP
651
685
  # @param development_mode [Boolean] Whether to use development-friendly directives
686
+ # @param extra_directives [Hash{String=>Array<String>}, nil] request-scoped
687
+ # extra source tokens appended additively after the overrides merge
688
+ # (see {Otto::Security::CSP::Policy.append_extra_sources}, delano/otto#243).
689
+ # Per-request data — passed through, never stored on this (deep-frozen
690
+ # in production) config.
691
+ # @yield [applied, dropped] forwarded to
692
+ # {Otto::Security::CSP::Policy.nonce_policy}: the extras entries that
693
+ # actually landed in the policy and the entries dropped because their
694
+ # directive was absent.
652
695
  # @return [String] Complete CSP policy string
653
- def generate_nonce_csp(nonce, development_mode: false)
696
+ def generate_nonce_csp(nonce, development_mode: false, extra_directives: nil, &extras_outcome)
654
697
  Otto::Security::CSP::Policy.nonce_policy(
655
698
  nonce,
656
- development_mode: development_mode,
657
- report_uri: @csp_report_uri,
658
- report_to_url: @csp_report_to_url,
659
- directive_overrides: @csp_directive_overrides
699
+ development_mode: development_mode,
700
+ report_uri: @csp_report_uri,
701
+ report_to_url: @csp_report_to_url,
702
+ directive_overrides: @csp_directive_overrides,
703
+ extra_directives: extra_directives,
704
+ &extras_outcome
660
705
  )
661
706
  end
662
707
 
@@ -66,7 +66,8 @@ class Otto
66
66
 
67
67
  Otto::Security::CSP::Writer.apply(
68
68
  headers, nonce,
69
- config: @config, mode: :backstop, development_mode: development_mode?(env)
69
+ config: @config, mode: :backstop, development_mode: development_mode?(env),
70
+ env: env # request-scoped extras (env['otto.csp.extra_directives'], #243)
70
71
  )
71
72
  end
72
73
 
@@ -29,6 +29,23 @@ class Otto
29
29
  # can never drift.
30
30
  REPORTING_GROUP = 'otto-csp'
31
31
 
32
+ # CSP directives that take NO value at all. Appending a source to one
33
+ # of these does not widen it — it makes the directive SYNTACTICALLY
34
+ # malformed (`upgrade-insecure-requests https://x;`), and a browser
35
+ # drops a malformed directive wholesale. So an extras entry keyed to
36
+ # one of them would silently DISABLE the directive it names, the exact
37
+ # inverse of the additive contract. {.append_extra_sources} therefore
38
+ # leaves them byte-identical and reports the entry as dropped.
39
+ #
40
+ # Deliberately only these two: `sandbox`, `trusted-types`, and
41
+ # `require-trusted-types-for` take non-source values, which makes
42
+ # appending an origin to them useless, but the result is still valid
43
+ # syntax the browser honours — no silent policy loss.
44
+ # `upgrade-insecure-requests` remains a CSP extension. Conversely,
45
+ # `block-all-mixed-content` is obsolete in the Mixed Content standard,
46
+ # but is retained here to protect legacy policies that still emit it.
47
+ VALUELESS_DIRECTIVES = %w[upgrade-insecure-requests block-all-mixed-content].freeze
48
+
32
49
  # Build the per-request nonce CSP policy string.
33
50
  #
34
51
  # Byte-identical to Otto's historical {Otto::Security::Config#generate_nonce_csp}
@@ -47,10 +64,26 @@ class Otto
47
64
  # into the base set before reporting directives are appended. See
48
65
  # {.merge_directives} for the accepted shape (replace a directive's
49
66
  # sources, add a new directive, or remove one with a nil/false value).
67
+ # @param extra_directives [Hash{String=>Array<String>}, nil]
68
+ # request-scoped extra source tokens appended additively AFTER the
69
+ # overrides merge and BEFORE the reporting directives. Expected
70
+ # pre-sanitized (see {Otto::Security::CSP::RequestExtras.from_env});
71
+ # see {.append_extra_sources} for the semantics (append to present
72
+ # directives only, dedupe, drop absent-directive keys).
73
+ # @yield [applied, dropped] the extras outcome from
74
+ # {.append_extra_sources} (both empty hashes when no extras were
75
+ # given): the entries actually folded into the policy and the
76
+ # entries dropped because their directive was absent. This is the
77
+ # return channel {Otto::Security::CSP::Writer} uses to log drops
78
+ # with request context and report only real appends — the policy
79
+ # string stays the sole return value.
50
80
  # @return [String] complete CSP policy string
51
- def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
81
+ def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil,
82
+ directive_overrides: nil, extra_directives: nil)
52
83
  directives = development_mode ? development_directives(nonce) : production_directives(nonce)
53
84
  directives = merge_directives(directives, directive_overrides)
85
+ directives, applied_extras, dropped_extras = append_extra_sources(directives, extra_directives)
86
+ yield(applied_extras, dropped_extras) if block_given?
54
87
  uri_directive = report_uri_directive(report_uri)
55
88
  to_directive = report_to_directive(report_to_url)
56
89
  directives += ["#{uri_directive};"] if uri_directive
@@ -151,28 +184,147 @@ class Otto
151
184
  merged
152
185
  end
153
186
 
187
+ # Fold request-scoped extra source tokens ADDITIVELY into a built
188
+ # directive set (delano/otto#243). A SIBLING of {.merge_directives},
189
+ # deliberately not a change to it: boot-time overrides REPLACE a
190
+ # directive's sources wholesale, request-time extras only ever APPEND.
191
+ #
192
+ # Semantics:
193
+ # - A directive PRESENT in the built list gets the extra tokens
194
+ # appended to its source list, deduplicated (a token already present
195
+ # is not appended again).
196
+ # - A directive ABSENT from the built list (base set minus any
197
+ # boot-override removals) is DROPPED and reported in the return:
198
+ # directives like `form-action` do not fall back to `default-src`,
199
+ # so CREATING one here would tighten the policy (suddenly blocking
200
+ # unrelated forms), and re-adding a directive a boot override
201
+ # deliberately removed (nil/false override) would resurrect it.
202
+ # - A directive that takes NO value ({VALUELESS_DIRECTIVES}, e.g.
203
+ # `upgrade-insecure-requests`) is left BYTE-IDENTICAL and its entry
204
+ # is DROPPED: appending a source there would emit
205
+ # `upgrade-insecure-requests https://x;`, which browsers treat as
206
+ # malformed and discard — an extras key would silently turn the
207
+ # directive OFF instead of widening it.
208
+ # - An entry whose token list is nil/empty leaves the base directive
209
+ # BYTE-IDENTICAL — the directive string is returned as-is, never
210
+ # rebuilt, so `worker-src 'self' blob:;` can never collapse into a
211
+ # bare `worker-src;`.
212
+ #
213
+ # Callers pass PRE-SANITIZED extras
214
+ # ({Otto::Security::CSP::RequestExtras.from_env} guarantees no
215
+ # `;`/CR/LF and origin-only tokens), so this helper does not
216
+ # re-validate the grammar; it stays defensive only about shape
217
+ # (nil/empty values are skipped gracefully, request-time input must
218
+ # never raise).
219
+ #
220
+ # Pure — like everything in this module, a function of its arguments
221
+ # with no logging and no env access. Dropped entries are RETURNED,
222
+ # not logged, so the one caller with the request in hand
223
+ # ({Otto::Security::CSP::Writer}) can log them with full request
224
+ # context.
225
+ #
226
+ # @param directives [Array<String>] built directive strings, each
227
+ # `;`-terminated (post {.merge_directives})
228
+ # @param extras [Hash{String=>Array<String>}, nil] normalized directive
229
+ # name => extra source tokens
230
+ # @return [Array(Array<String>, Hash, Hash)] `[merged, applied, dropped]`:
231
+ # the directive strings with extras appended; the extras entries that
232
+ # addressed a PRESENT directive (their tokens are in the merged
233
+ # policy — a token already present is simply not duplicated); and
234
+ # the entries dropped because their directive was absent or takes no
235
+ # value ({VALUELESS_DIRECTIVES}). Entries with nil/empty token lists
236
+ # appear in neither hash.
237
+ def append_extra_sources(directives, extras)
238
+ return [directives, {}, {}] if extras.nil? || extras.empty?
239
+
240
+ remaining = extras.dup
241
+ applied = {}
242
+ merged = directives.map do |directive|
243
+ name = directive_name(directive)
244
+ tokens = remaining.delete(name)
245
+ next directive if tokens.nil? || Array(tokens).empty?
246
+
247
+ if valueless_directive?(name)
248
+ # Put the entry back so it surfaces in `dropped` rather than
249
+ # vanishing: the caller with the request in hand must be able to
250
+ # log that these tokens never landed.
251
+ remaining[name] = tokens
252
+ next directive
253
+ end
254
+
255
+ applied[name] = tokens
256
+ append_sources_to(directive, tokens)
257
+ end
258
+
259
+ dropped = remaining.reject { |_name, tokens| Array(tokens).empty? }
260
+ [merged, applied, dropped]
261
+ end
262
+
263
+ # Append tokens to one `;`-terminated directive string, deduplicated
264
+ # against its existing sources. Returns the directive UNCHANGED when
265
+ # every token is already present (the byte-identical invariant).
266
+ #
267
+ # @param directive [String] e.g. `"form-action 'self';"`
268
+ # @param tokens [Array<String>] pre-sanitized source tokens
269
+ # @return [String] `;`-terminated directive string
270
+ def append_sources_to(directive, tokens)
271
+ body = directive.to_s.strip.delete_suffix(';')
272
+ name, sources = body.split(/\s+/, 2)
273
+ existing = sources.to_s.split(/\s+/)
274
+ additions = Array(tokens).map(&:to_s).reject { |token| token.empty? || existing.include?(token) }
275
+ return directive if additions.empty?
276
+
277
+ "#{name} #{(existing + additions).join(' ')};"
278
+ end
279
+
280
+ # Normalize one directive name: stripped, lowercased, underscores
281
+ # mapped to hyphens (no CSP directive contains an underscore), so a
282
+ # Symbol like `:worker_src` addresses the `worker-src` directive.
283
+ #
284
+ # The SINGLE normalization used by both {.normalize_overrides} and
285
+ # {Otto::Security::CSP::RequestExtras.from_env} — the present/absent
286
+ # matching in {.append_extra_sources} depends on both sides applying
287
+ # identical normalization, so it lives in exactly one place.
288
+ #
289
+ # @param name [String, Symbol]
290
+ # @return [String] normalized directive name (may be empty for blank input)
291
+ def normalize_directive_name(name)
292
+ name.to_s.strip.downcase.tr('_', '-')
293
+ end
294
+
154
295
  # Normalize an overrides hash to lowercased, hyphenated String keys so
155
296
  # lookups are case-insensitive and Symbol/String keys are
156
- # interchangeable. Underscores map to hyphens (no CSP directive contains
157
- # an underscore) so a Symbol key like `:worker_src` addresses the
158
- # `worker-src` directive. Blank keys are dropped.
297
+ # interchangeable (see {.normalize_directive_name}). Blank keys are
298
+ # dropped.
159
299
  #
160
300
  # @param overrides [Hash]
161
301
  # @return [Hash{String=>Object}]
162
302
  def normalize_overrides(overrides)
163
303
  overrides.each_with_object({}) do |(key, value), acc|
164
- name = key.to_s.strip.downcase.tr('_', '-')
304
+ name = normalize_directive_name(key)
165
305
  acc[name] = value unless name.empty?
166
306
  end
167
307
  end
168
308
 
169
309
  # The directive name (first token) of a `;`-terminated directive string,
170
- # lowercased for case-insensitive matching.
310
+ # run through {.normalize_directive_name} so the built policy and the
311
+ # extras/override hashes are compared on ONE normalization.
171
312
  #
172
313
  # @param directive [String]
173
314
  # @return [String]
174
315
  def directive_name(directive)
175
- directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
316
+ normalize_directive_name(directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first)
317
+ end
318
+
319
+ # True when +name+ addresses a directive that takes no value at all
320
+ # (see {VALUELESS_DIRECTIVES}). Normalizes through
321
+ # {.normalize_directive_name}, so `:upgrade_insecure_requests` and
322
+ # `'Upgrade-Insecure-Requests'` are recognized like the canonical form.
323
+ #
324
+ # @param name [String, Symbol]
325
+ # @return [Boolean]
326
+ def valueless_directive?(name)
327
+ VALUELESS_DIRECTIVES.include?(normalize_directive_name(name))
176
328
  end
177
329
 
178
330
  # Build a single `;`-terminated directive string from a name and an
@@ -222,15 +374,17 @@ class Otto
222
374
 
223
375
  # CSP directives for the development environment.
224
376
  #
225
- # Development mode allows inline scripts/styles and hot reloading
226
- # connections for better developer experience with build tools like Vite.
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}' 'unsafe-inline';", # Allow inline scripts for development tools
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