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'
Class Method Summary collapse
-
.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, lowercased for case-insensitive matching. -
.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) ⇒ String
Build the per-request nonce CSP policy string.
-
.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.
-
.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
'; '.
Class Method Details
.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.)
194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/otto/security/csp/policy.rb', line 194 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 inline scripts/styles and hot reloading connections for better developer experience with build tools like Vite.
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/otto/security/csp/policy.rb', line 230 def development_directives(nonce) [ "default-src 'none';", "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools "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,
lowercased for case-insensitive matching.
174 175 176 |
# File 'lib/otto/security/csp/policy.rb', line 174 def directive_name(directive) directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase 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.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/otto/security/csp/policy.rb', line 130 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) ⇒ 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.
51 52 53 54 55 56 57 58 59 |
# File 'lib/otto/security/csp/policy.rb', line 51 def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil) directives = development_mode ? development_directives(nonce) : production_directives(nonce) directives = merge_directives(directives, directive_overrides) 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_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. Underscores map to hyphens (no CSP directive contains
an underscore) so a Symbol key like :worker_src addresses the
worker-src directive. Blank keys are dropped.
162 163 164 165 166 167 |
# File 'lib/otto/security/csp/policy.rb', line 162 def normalize_overrides(overrides) overrides.each_with_object({}) do |(key, value), acc| name = key.to_s.strip.downcase.tr('_', '-') 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.
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/otto/security/csp/policy.rb', line 254 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.
216 217 218 219 220 221 |
# File 'lib/otto/security/csp/policy.rb', line 216 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.
90 91 92 93 94 |
# File 'lib/otto/security/csp/policy.rb', line 90 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).
78 79 80 81 82 |
# File 'lib/otto/security/csp/policy.rb', line 78 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.
69 70 71 |
# File 'lib/otto/security/csp/policy.rb', line 69 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 |