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-to directive and the Reporting-Endpoints response 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, and require-trusted-types-for take 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-requests remains a CSP extension. Conversely, block-all-mixed-content is 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

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.

Parameters:

  • directives (Array<String>)

    built directive strings, each ;-terminated (post merge_directives)

  • extras (Hash{String=>Array<String>}, nil)

    normalized directive name => extra source tokens

Returns:

  • (Array(Array<String>, Hash, Hash))

    [merged, applied, dropped]: the directive strings with extras appended; the extras entries that addressed a PRESENT directive (their tokens are in the merged policy — a token already present is simply not duplicated); and the entries dropped because their directive was absent or takes no value (VALUELESS_DIRECTIVES). Entries with nil/empty token lists appear in neither hash.



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).

Parameters:

  • directive (String)

    e.g. "form-action 'self';"

  • tokens (Array<String>)

    pre-sanitized source tokens

Returns:

  • (String)

    ;-terminated directive string



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.)

Parameters:

  • name (String)

    directive name

  • value (String, Array, nil, false)

    source list, or nil/false to remove

Returns:

  • (String, nil)

Raises:

  • (ArgumentError)

    if the name or a source token contains a ;, newline, or carriage return



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.

Parameters:

  • nonce (String)

    nonce value injected into script-src

Returns:

  • (Array<String>)

    directive strings, each terminated with ;



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.

Parameters:

  • directive (String)

Returns:

  • (String)


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>

Note:

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.

Parameters:

  • directives (Array<String>)

    base directive strings, each ;-terminated

  • overrides (Hash, nil)

    directive name => source list / nil

Returns:

  • (Array<String>)

    merged directive strings, each ;-terminated

Raises:

  • (ArgumentError)

    if an override name or source token contains a ;, newline, or carriage return (see build_directive)



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.

Parameters:

  • nonce (String)

    nonce value injected into script-src

  • development_mode (Boolean) (defaults to: false)

    use the development directive set

  • report_uri (String, nil) (defaults to: nil)

    path for the report-uri directive (omitted when nil/empty)

  • report_to_url (String, nil) (defaults to: nil)

    absolute URL configured for the modern Reporting API; its presence (not its value) toggles the report-to <group> directive (omitted when nil/empty)

  • directive_overrides (Hash, nil) (defaults to: nil)

    per-directive overrides merged into the base set before reporting directives are appended. See merge_directives for the accepted shape (replace a directive’s sources, add a new directive, or remove one with a nil/false value).

  • extra_directives (Hash{String=>Array<String>}, nil) (defaults to: nil)

    request-scoped extra source tokens appended additively AFTER the overrides merge and BEFORE the reporting directives. Expected pre-sanitized (see RequestExtras.from_env); see append_extra_sources for the semantics (append to present directives only, dedupe, drop absent-directive keys).

Yields:

  • (applied, dropped)

    the extras outcome from append_extra_sources (both empty hashes when no extras were given): the entries actually folded into the policy and the entries dropped because their directive was absent. This is the return channel Writer uses to log drops with request context and report only real appends — the policy string stays the sole return value.

Returns:

  • (String)

    complete CSP policy string



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.

Parameters:

  • name (String, Symbol)

Returns:

  • (String)

    normalized directive name (may be empty for blank input)



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.

Parameters:

  • overrides (Hash)

Returns:

  • (Hash{String=>Object})


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.

Parameters:

  • nonce (String)

    nonce value injected into script-src

Returns:

  • (Array<String>)

    directive strings, each terminated with ;



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.

Parameters:

  • label (String)

    what is being validated (for the error message)

  • text (String)

Raises:

  • (ArgumentError)

    if +text+ contains ;, \n, or \r



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.

Parameters:

  • url (String, nil)

Returns:

  • (String, nil)


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).

Parameters:

  • uri (String, nil)

Returns:

  • (String, nil)


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.

Parameters:

  • base (String)

    the base policy (e.g. from Otto::Security::Config#enable_csp!)

  • report_uri (String, nil) (defaults to: nil)

    path for the report-uri directive

  • report_to_url (String, nil) (defaults to: nil)

    absolute URL toggling report-to

Returns:

  • (String)


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.

Parameters:

  • name (String, Symbol)

Returns:

  • (Boolean)


326
327
328
# File 'lib/otto/security/csp/policy.rb', line 326

def valueless_directive?(name)
  VALUELESS_DIRECTIVES.include?(normalize_directive_name(name))
end