Module: Otto::Security::CSP::Policy
- Defined in:
- lib/otto/security/csp/policy.rb
Overview
Assembles Content-Security-Policy strings from Otto’s directive sets and the optional reporting directives.
This is the policy-BUILDING half of Otto’s CSP support, extracted from Otto::Security::Config so the domain (directive sets, report-uri / report-to assembly) lives beside the parser and the middlewares under Otto::Security::CSP. Otto::Security::Config keeps thin delegating facades (Otto::Security::Config#generate_nonce_csp and its static counterpart), so callers and output are unchanged — the assembly logic simply has a home of its own now.
All methods are pure functions of their arguments (the report URI/URL are passed in, not read from global state), so the same policy string can be produced from any surface without a Config in hand.
Constant Summary collapse
- REPORTING_GROUP =
Endpoint group name shared by the CSP
report-todirective and theReporting-Endpointsresponse header (modern Reporting API). Browsers match the directive’s group to the header’s key, so both must agree. Otto::Security::Config::CSP_REPORTING_GROUP aliases this so the two can never drift. 'otto-csp'- VALUELESS_DIRECTIVES =
CSP directives that take NO value at all. Appending a source to one of these does not widen it — it makes the directive SYNTACTICALLY malformed (
upgrade-insecure-requests https://x;), and a browser drops a malformed directive wholesale. So an extras entry keyed to one of them would silently DISABLE the directive it names, the exact inverse of the additive contract. append_extra_sources therefore leaves them byte-identical and reports the entry as dropped.Deliberately only these two:
sandbox,trusted-types, andrequire-trusted-types-fortake non-source values, which makes appending an origin to them useless, but the result is still valid syntax the browser honours — no silent policy loss.upgrade-insecure-requestsremains a CSP extension. Conversely,block-all-mixed-contentis obsolete in the Mixed Content standard, but is retained here to protect legacy policies that still emit it. %w[upgrade-insecure-requests block-all-mixed-content].freeze
Class Method Summary collapse
-
.append_extra_sources(directives, extras) ⇒ Array(Array<String>, Hash, Hash)
Fold request-scoped extra source tokens ADDITIVELY into a built directive set (delano/otto#243).
-
.append_sources_to(directive, tokens) ⇒ String
Append tokens to one
;-terminated directive string, deduplicated against its existing sources. -
.build_directive(name, value) ⇒ String?
Build a single
;-terminated directive string from a name and an override value, or nil when the value signals removal (nil/false). -
.development_directives(nonce) ⇒ Array<String>
CSP directives for the development environment.
-
.directive_name(directive) ⇒ String
The directive name (first token) of a
;-terminated directive string, run through Policy.normalize_directive_name so the built policy and the extras/override hashes are compared on ONE normalization. -
.merge_directives(directives, overrides) ⇒ Array<String>
Merge per-directive overrides into a base directive set.
-
.nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil, extra_directives: nil) {|applied, dropped| ... } ⇒ String
Build the per-request nonce CSP policy string.
-
.normalize_directive_name(name) ⇒ String
Normalize one directive name: stripped, lowercased, underscores mapped to hyphens (no CSP directive contains an underscore), so a Symbol like
:worker_srcaddresses theworker-srcdirective. -
.normalize_overrides(overrides) ⇒ Hash{String=>Object}
Normalize an overrides hash to lowercased, hyphenated String keys so lookups are case-insensitive and Symbol/String keys are interchangeable (see Policy.normalize_directive_name).
-
.production_directives(nonce) ⇒ Array<String>
CSP directives for the production environment.
-
.reject_injection!(label, text) ⇒ void
Raise ArgumentError when +text+ carries a CSP directive/token separator (
;, newline, or carriage return) that would let an override break out of its directive and inject another. -
.report_to_directive(url) ⇒ String?
The
report-todirective (modern Reporting API), or nil when no reporting endpoint URL is configured. -
.report_uri_directive(uri) ⇒ String?
The
report-uridirective, or nil when no report URI is configured. -
.static_policy(base, report_uri: nil, report_to_url: nil) ⇒ String
Build a static CSP header value: a base policy plus the optional reporting directives, joined
'; '. -
.valueless_directive?(name) ⇒ Boolean
True when +name+ addresses a directive that takes no value at all (see VALUELESS_DIRECTIVES).
Class Method Details
.append_extra_sources(directives, extras) ⇒ Array(Array<String>, Hash, Hash)
Fold request-scoped extra source tokens ADDITIVELY into a built directive set (delano/otto#243). A SIBLING of merge_directives, deliberately not a change to it: boot-time overrides REPLACE a directive’s sources wholesale, request-time extras only ever APPEND.
Semantics:
- A directive PRESENT in the built list gets the extra tokens
appended to its source list, deduplicated (a token already present
is not appended again).
- A directive ABSENT from the built list (base set minus any
boot-override removals) is DROPPED and reported in the return:
directives like form-action do not fall back to default-src,
so CREATING one here would tighten the policy (suddenly blocking
unrelated forms), and re-adding a directive a boot override
deliberately removed (nil/false override) would resurrect it.
- A directive that takes NO value (VALUELESS_DIRECTIVES, e.g.
upgrade-insecure-requests) is left BYTE-IDENTICAL and its entry
is DROPPED: appending a source there would emit
upgrade-insecure-requests https://x;, which browsers treat as
malformed and discard — an extras key would silently turn the
directive OFF instead of widening it.
- An entry whose token list is nil/empty leaves the base directive
BYTE-IDENTICAL — the directive string is returned as-is, never
rebuilt, so worker-src 'self' blob:; can never collapse into a
bare worker-src;.
Callers pass PRE-SANITIZED extras
(RequestExtras.from_env guarantees no
;/CR/LF and origin-only tokens), so this helper does not
re-validate the grammar; it stays defensive only about shape
(nil/empty values are skipped gracefully, request-time input must
never raise).
Pure — like everything in this module, a function of its arguments with no logging and no env access. Dropped entries are RETURNED, not logged, so the one caller with the request in hand (Writer) can log them with full request context.
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/otto/security/csp/policy.rb', line 237 def append_extra_sources(directives, extras) return [directives, {}, {}] if extras.nil? || extras.empty? remaining = extras.dup applied = {} merged = directives.map do |directive| name = directive_name(directive) tokens = remaining.delete(name) next directive if tokens.nil? || Array(tokens).empty? if valueless_directive?(name) # Put the entry back so it surfaces in `dropped` rather than # vanishing: the caller with the request in hand must be able to # log that these tokens never landed. remaining[name] = tokens next directive end applied[name] = tokens append_sources_to(directive, tokens) end dropped = remaining.reject { |_name, tokens| Array(tokens).empty? } [merged, applied, dropped] end |
.append_sources_to(directive, tokens) ⇒ String
Append tokens to one ;-terminated directive string, deduplicated
against its existing sources. Returns the directive UNCHANGED when
every token is already present (the byte-identical invariant).
270 271 272 273 274 275 276 277 278 |
# File 'lib/otto/security/csp/policy.rb', line 270 def append_sources_to(directive, tokens) body = directive.to_s.strip.delete_suffix(';') name, sources = body.split(/\s+/, 2) existing = sources.to_s.split(/\s+/) additions = Array(tokens).map(&:to_s).reject { |token| token.empty? || existing.include?(token) } return directive if additions.empty? "#{name} #{(existing + additions).join(' ')};" end |
.build_directive(name, value) ⇒ String?
Build a single ;-terminated directive string from a name and an
override value, or nil when the value signals removal (nil/false).
The directive name and each source token are validated against CSP’s
separator characters: a name or token containing ; (which separates
directives), a newline, or a carriage return raises ArgumentError
rather than silently injecting extra directives — a real footgun when
overrides come from env/config files. (The false removal sentinel is
checked before Array so a bare false never becomes a [false]
source list.)
346 347 348 349 350 351 352 353 354 355 356 357 358 |
# File 'lib/otto/security/csp/policy.rb', line 346 def build_directive(name, value) return nil if value.nil? || value == false reject_injection!('directive name', name) sources = Array(value).filter_map do |token| str = token.to_s.strip next if str.empty? reject_injection!("source for #{name}", str) str end.join(' ') sources.empty? ? "#{name};" : "#{name} #{sources};" end |
.development_directives(nonce) ⇒ Array<String>
CSP directives for the development environment.
Development mode allows nonce-authorized inline scripts, inline styles, HTTP(S) scripts, and hot-reloading connections for build tools such as Vite. HTTP(S) script sources support both same-origin reverse proxies (for example Caddy) and direct local Vite servers on another port.
384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 |
# File 'lib/otto/security/csp/policy.rb', line 384 def development_directives(nonce) [ "default-src 'none';", "script-src 'self' 'nonce-#{nonce}' http: https:;", "style-src 'self' 'unsafe-inline';", "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools "img-src 'self' data:;", "font-src 'self';", "object-src 'none';", "base-uri 'self';", "form-action 'self';", "frame-ancestors 'none';", "manifest-src 'self';", "worker-src 'self' blob:;", ] end |
.directive_name(directive) ⇒ String
The directive name (first token) of a ;-terminated directive string,
run through normalize_directive_name so the built policy and the
extras/override hashes are compared on ONE normalization.
315 316 317 |
# File 'lib/otto/security/csp/policy.rb', line 315 def directive_name(directive) normalize_directive_name(directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first) end |
.merge_directives(directives, overrides) ⇒ Array<String>
The per-request nonce is embedded in script-src (production)
and cannot be reproduced in a static override, so replacing (or
removing) script-src strips the nonce from the emitted header and
DEFEATS nonce protection: the browser then accepts any inline script
the page carries the nonce attribute on. Overriding script-src
while nonce mode is enabled therefore disables nonce enforcement;
Otto::Security::Config logs a warning when such an override is
configured. Override other directives freely.
Merge per-directive overrides into a base directive set.
This is the customization seam the hardcoded directive sets previously
lacked: a consuming app can adjust ANY directive (e.g. re-allow
data: workers via worker-src 'self' data: blob:) without
vendoring the gem. Order is
preserved — an override that matches an existing directive replaces it
in place; an override for a directive not in the base set is appended
after the base directives (before any reporting directives).
Override values:
- a String → the directive’s source list verbatim, e.g.
'worker-src' => "'self' blob:" yields worker-src 'self' blob:;
- an Array → sources joined with a single space, e.g.
%w['self' blob:]
- nil/false → REMOVE the directive from the emitted policy
Directive names are matched case-insensitively (CSP directive names are case-insensitive) and may be given as Strings or Symbols.
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/otto/security/csp/policy.rb', line 163 def merge_directives(directives, overrides) return directives if overrides.nil? || overrides.empty? normalized = normalize_overrides(overrides) consumed = {} merged = directives.filter_map do |directive| name = directive_name(directive) next directive unless normalized.key?(name) consumed[name] = true build_directive(name, normalized[name]) end normalized.each do |name, value| next if consumed[name] appended = build_directive(name, value) merged << appended if appended end merged end |
.nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil, extra_directives: nil) {|applied, dropped| ... } ⇒ String
Build the per-request nonce CSP policy string.
Byte-identical to Otto’s historical Otto::Security::Config#generate_nonce_csp
output: the base directive set (development or production) followed by
the optional report-uri and report-to directives, each terminated
with ; and joined by a single space.
81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/otto/security/csp/policy.rb', line 81 def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil, extra_directives: nil) directives = development_mode ? development_directives(nonce) : production_directives(nonce) directives = merge_directives(directives, directive_overrides) directives, applied_extras, dropped_extras = append_extra_sources(directives, extra_directives) yield(applied_extras, dropped_extras) if block_given? uri_directive = report_uri_directive(report_uri) to_directive = report_to_directive(report_to_url) directives += ["#{uri_directive};"] if uri_directive directives += ["#{to_directive};"] if to_directive directives.join(' ') end |
.normalize_directive_name(name) ⇒ String
Normalize one directive name: stripped, lowercased, underscores
mapped to hyphens (no CSP directive contains an underscore), so a
Symbol like :worker_src addresses the worker-src directive.
The SINGLE normalization used by both normalize_overrides and RequestExtras.from_env — the present/absent matching in append_extra_sources depends on both sides applying identical normalization, so it lives in exactly one place.
291 292 293 |
# File 'lib/otto/security/csp/policy.rb', line 291 def normalize_directive_name(name) name.to_s.strip.downcase.tr('_', '-') end |
.normalize_overrides(overrides) ⇒ Hash{String=>Object}
Normalize an overrides hash to lowercased, hyphenated String keys so lookups are case-insensitive and Symbol/String keys are interchangeable (see normalize_directive_name). Blank keys are dropped.
302 303 304 305 306 307 |
# File 'lib/otto/security/csp/policy.rb', line 302 def normalize_overrides(overrides) overrides.each_with_object({}) do |(key, value), acc| name = normalize_directive_name(key) acc[name] = value unless name.empty? end end |
.production_directives(nonce) ⇒ Array<String>
CSP directives for the production environment.
Production mode is more restrictive, only allowing HTTPS connections and nonce-only scripts for enhanced XSS protection.
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 |
# File 'lib/otto/security/csp/policy.rb', line 408 def production_directives(nonce) [ "default-src 'none';", # Restrict to same origin by default "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets "img-src 'self' data:;", # Allow images from same origin and data URIs "font-src 'self';", # Allow fonts from same origin only "object-src 'none';", # Block <object>, <embed>, and <applet> elements "base-uri 'self';", # Restrict <base> tag targets to same origin "form-action 'self';", # Restrict form submissions to same origin "frame-ancestors 'none';", # Prevent site from being embedded in frames "manifest-src 'self';", # Allow web app manifests from same origin "worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs ] end |
.reject_injection!(label, text) ⇒ void
This method returns an undefined value.
Raise ArgumentError when +text+ carries a CSP directive/token
separator (;, newline, or carriage return) that would let an
override break out of its directive and inject another.
368 369 370 371 372 373 |
# File 'lib/otto/security/csp/policy.rb', line 368 def reject_injection!(label, text) return unless text.match?(/[;\r\n]/) raise ArgumentError, "invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return" end |
.report_to_directive(url) ⇒ String?
The report-to directive (modern Reporting API), or nil when no
reporting endpoint URL is configured. Its group name matches the
Reporting-Endpoints header. No trailing semicolon.
123 124 125 126 127 |
# File 'lib/otto/security/csp/policy.rb', line 123 def report_to_directive(url) return nil if url.nil? || url.empty? "report-to #{REPORTING_GROUP}" end |
.report_uri_directive(uri) ⇒ String?
The report-uri directive, or nil when no report URI is configured.
No trailing semicolon (callers add their own separator).
111 112 113 114 115 |
# File 'lib/otto/security/csp/policy.rb', line 111 def report_uri_directive(uri) return nil if uri.nil? || uri.empty? "report-uri #{uri}" end |
.static_policy(base, report_uri: nil, report_to_url: nil) ⇒ String
Build a static CSP header value: a base policy plus the optional
reporting directives, joined '; '. Byte-identical to the bare policy
when no reporting is configured.
102 103 104 |
# File 'lib/otto/security/csp/policy.rb', line 102 def static_policy(base, report_uri: nil, report_to_url: nil) [base, report_uri_directive(report_uri), report_to_directive(report_to_url)].compact.join('; ') end |
.valueless_directive?(name) ⇒ Boolean
True when +name+ addresses a directive that takes no value at all
(see VALUELESS_DIRECTIVES). Normalizes through
normalize_directive_name, so :upgrade_insecure_requests and
'Upgrade-Insecure-Requests' are recognized like the canonical form.
326 327 328 |
# File 'lib/otto/security/csp/policy.rb', line 326 def valueless_directive?(name) VALUELESS_DIRECTIVES.include?(normalize_directive_name(name)) end |