Class: Otto::Security::CSP::Writer

Inherits:
Object
  • Object
show all
Defined in:
lib/otto/security/csp/writer.rb

Overview

The single structural apply core for nonce-based Content-Security-Policy emission. Every in-framework surface that writes a nonce CSP onto a response — Response#apply_csp, EmitMiddleware, and the deprecated Response#send_csp_headers shim — routes through Writer.apply, so the emission invariants are properties of ONE method rather than guard logic re-implemented (and re-reviewed) at each surface:

  • Enabled only. No header unless the security config has nonce-CSP on.
  • Nonce present. A nil/empty nonce never produces a broken script-src 'nonce-' policy; it skips.
  • HTML only. Non-HTML responses (JSON, redirects, static assets) are left untouched.
  • Passive layers never clobber. In :backstop mode an existing CSP is deferred to; only an explicit :override replaces one.

Writes are in-place and key-scoped: Writer.apply finds any case-variant of the CSP key (Rack 3 mandates lowercase response-header keys, but a canonical-/mixed-cased key from a downstream layer is a spec violation this corrects in place), deletes it, and writes the lowercase key into the CALLER’S headers hash. There is no wrapping, no copy, and no “callers-must-use-the-return-value” contract — the [status, headers, body] tuple never needs reassignment. A frozen headers hash therefore fails loud (FrozenError) on write, surfacing the downstream SPEC violation rather than silently dropping the policy.

The return is a Result, not the headers: result.applied?, result.policy, result.skip_reason give uniform observability across every surface (and drive the optional debug log) without any cleverness to detect “did anything happen”.

Defined Under Namespace

Classes: Result

Constant Summary collapse

CSP_HEADER =

Canonical (lowercase, per Rack 3 SPEC) response-header keys.

'content-security-policy'
CONTENT_TYPE_HEADER =
'content-type'
MODES =

Emission modes. :override is a deliberate per-request call that REPLACES any existing CSP (the caller owns this response’s policy). :backstop is a passive layer that DEFERS to an existing CSP (it only fills the gap, never clobbers).

%i[override backstop].freeze

Class Method Summary collapse

Class Method Details

.apply(headers, nonce, config:, mode: :override, development_mode: false) ⇒ Result

Apply a nonce-based CSP to the caller’s response headers, in place.

Parameters:

  • headers (Hash)

    the Rack response headers hash, mutated in place. MUST be mutable (Rack 3 SPEC); a frozen hash raises FrozenError.

  • nonce (String, nil)

    the per-request nonce.

  • config (Otto::Security::Config, nil)

    source of the enabled gate and the policy string (Otto::Security::Config#generate_nonce_csp).

  • mode (Symbol) (defaults to: :override)

    one of MODES.

  • development_mode (Boolean) (defaults to: false)

    use the development directive set.

Returns:

Raises:

  • (ArgumentError)

    if mode is not one of MODES

  • (FrozenError)

    if a write is attempted against a frozen headers hash



102
103
104
105
106
107
108
109
110
# File 'lib/otto/security/csp/writer.rb', line 102

def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
  unless MODES.include?(mode)
    raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
  end

  result = evaluate(headers, nonce, config, mode, development_mode)
  log_debug(config, result)
  result
end