Module: Otto::Security::CSP::RequestExtras
- Defined in:
- lib/otto/security/csp/request_extras.rb
Overview
Reads and sanitizes request-scoped CSP directive extras from the Rack env (delano/otto#243).
This is the opt-in channel for widening CSP directives with values only
known at request time: the app enables it at boot with
Otto::Security::Config#enable_csp_request_extras! (default off — the
env key is a write surface any middleware can reach, so it does not
exist until boot code says so), then a handler writes a hash of
directive name => additional source tokens to
env['otto.csp.extra_directives'] before the response is finalized,
and the policy build folds the sanitized survivors in ADDITIVELY (see
Policy.append_extra_sources). The motivating
case is a multi-tenant app that must admit the resolved tenant’s SSO
IdP origin into form-action — per-request data no boot-time override
can express.
This module deliberately has the OPPOSITE failure mode of Policy: Policy is pure functions that raise on bad input (boot-time overrides should fail loud), while request-time extras must NEVER raise — a hostile or malformed value is dropped and logged, and the response still ships with the base policy intact.
Sanitization rules:
- Directive names are normalized via
Policy.normalize_directive_name (the same
normalization Policy.normalize_overrides applies to boot-time
overrides); blank keys are dropped. Two raw keys that normalize to
the same directive ('form_action' and 'form-action') have their
token lists merged (union) — nothing is silently overwritten.
- REFUSED_DIRECTIVES are dropped wholesale. For the script-src
family this is defence-in-depth policy, NOT nonce protection: extras
are additive, so the nonce source would survive an append — refusing
the family simply keeps the request channel away from the one
directive class that gates script execution. default-src is refused
because widening it widens every unlisted directive at once. Directives
with no value are refused because an appended origin makes the entire
directive malformed; Policy.append_extra_sources retains the same
guard for callers that bypass this sanitizer.
- Tokens must be ORIGINS: scheme://host[:port] with an http/https
scheme and a non-empty host — no path/query/fragment/userinfo, no
whitespace, no ;/CR/LF, no wildcards, no quotes. Keyword sources
('self', 'unsafe-inline', …) and scheme sources (data:,
https:) are rejected. Accepted tokens are normalized to
scheme://host[:port] with a downcased host and default ports
omitted. Hosts follow strip-then-validate: a single trailing dot
(the FQDN root form browsers do NOT treat as the same origin) is
stripped before validation, any remaining trailing dot rejects the
token, and a host containing % (percent-encoding that URI’s host
parser passes through literally) is rejected outright. An explicit
port must fall in 1..65535 — URI accepts arbitrarily large all-digit
ports that no browser can match.
Every key/token dropped during sanitization is logged at :warn via
Otto.structured_log with a distinct reason (:invalid_shape,
:refused_directive, :not_an_origin) and privacy-safe request
context. Entries dropped later, during the policy append (an absent
directive, a valueless directive supplied by a caller that bypassed this
sanitizer, or a config without extras support), are logged by
Writer — the same message, reason:
:absent_directive / :valueless_directive /
:config_without_extras_support.
Constant Summary collapse
- ENV_KEY =
The env key the consuming app writes. Hardcoded on purpose (not configurable) — it is a cross-gem contract; see also Otto::EnvKeys::CSP::EXTRA_DIRECTIVES (require ‘otto/env_keys’).
'otto.csp.extra_directives'- REFUSED_DIRECTIVES =
Directives the request channel refuses to touch, wholesale. See the module docs for why (defence-in-depth for the script family — NOT nonce-stripping, since appends keep the nonce — blast radius for default-src — and invalid syntax for directives with no value). Keep the policy-level guard too: Policy.nonce_policy(extra_directives:) is public and can bypass this sanitizer.
( %w[script-src script-src-elem script-src-attr default-src] + Policy::VALUELESS_DIRECTIVES ).freeze
- ALLOWED_SCHEMES =
The only schemes an extra origin may carry.
%w[http https].freeze
- FORBIDDEN_CHARS =
Characters that can never appear in an origin token: whitespace and
;/CR/LF (directive separators), quotes (keyword sources), and*(wildcards). Checked before URI parsing so hostile tokens are rejected even when URI would tolerate them. /[\s;'"*\r\n]/
Class Method Summary collapse
-
.from_env(env) ⇒ Hash{String=>Array<String>}?
Read and sanitize the request-scoped extras from the Rack env.
-
.log_drop(context, directive:, token:, reason:) ⇒ Object
Log one dropped key/token at :warn with privacy-safe request context.
-
.normalize_host(raw) ⇒ String?
Validate and normalize an origin host: downcased, strip-then-validate for trailing dots (a single trailing dot — the FQDN root form — is stripped, since browsers do not equate
example.com.withexample.com; any dot still trailing after the strip rejects the host), and any%rejects the host outright (URI’s host parser passes percent-encodings through literally, so accepting one would ship raw%00-style bytes in a response header). -
.normalize_origin(token) ⇒ String?
Validate and normalize a single token as an http(s) origin.
-
.sanitize_tokens(context, name, value) ⇒ Array<String>
Sanitize one directive’s token value into normalized origin strings.
Class Method Details
.from_env(env) ⇒ Hash{String=>Array<String>}?
Read and sanitize the request-scoped extras from the Rack env.
Never raises. Anything that fails validation is dropped and logged; whatever survives is returned.
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/otto/security/csp/request_extras.rb', line 111 def from_env(env) raw = env[ENV_KEY] return nil if raw.nil? # Compute the request context once per call and thread it through — # a hostile payload can produce many drops, and re-deriving the # context per token would defeat LoggingHelpers' compute-once-then- # merge pattern. context = Otto::LoggingHelpers.request_context(env) unless raw.is_a?(Hash) log_drop(context, directive: nil, token: raw, reason: :invalid_shape) return nil end extras = raw.each_with_object({}) do |(key, value), acc| name = Policy.normalize_directive_name(key) if name.empty? log_drop(context, directive: key, token: value, reason: :invalid_shape) next end if REFUSED_DIRECTIVES.include?(name) log_drop(context, directive: name, token: value, reason: :refused_directive) next end tokens = sanitize_tokens(context, name, value) next if tokens.empty? # Two raw keys can normalize to the same directive ('form_action' # and 'form-action'): union the token lists rather than letting # the later key silently clobber the earlier one. acc[name] = (acc[name] || []) | tokens end extras.empty? ? nil : extras end |
.log_drop(context, directive:, token:, reason:) ⇒ Object
Log one dropped key/token at :warn with privacy-safe request context. Never :debug — drops are actionable signal, and structured_log skips :debug unless Otto.debug is on.
243 244 245 246 247 248 249 250 251 252 |
# File 'lib/otto/security/csp/request_extras.rb', line 243 def log_drop(context, directive:, token:, reason:) Otto.structured_log( :warn, 'CSP request extra dropped', context.merge( directive: directive&.to_s, token: token.inspect.slice(0, 128), reason: reason ).compact ) end |
.normalize_host(raw) ⇒ String?
Validate and normalize an origin host: downcased, strip-then-validate
for trailing dots (a single trailing dot — the FQDN root form — is
stripped, since browsers do not equate example.com. with
example.com; any dot still trailing after the strip rejects the
host), and any % rejects the host outright (URI’s host parser
passes percent-encodings through literally, so accepting one would
ship raw %00-style bytes in a response header).
228 229 230 231 232 233 234 235 236 |
# File 'lib/otto/security/csp/request_extras.rb', line 228 def normalize_host(raw) host = raw.to_s.downcase return nil if host.empty? || host.include?('%') host = host.delete_suffix('.') return nil if host.empty? || host.end_with?('.') host end |
.normalize_origin(token) ⇒ String?
Validate and normalize a single token as an http(s) origin.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/otto/security/csp/request_extras.rb', line 187 def normalize_origin(token) return nil if token.empty? || token.match?(FORBIDDEN_CHARS) uri = begin URI.parse(token) rescue URI::InvalidURIError nil end return nil unless uri.is_a?(URI::HTTP) # URI::HTTPS is a subclass scheme = uri.scheme.to_s.downcase return nil unless ALLOWED_SCHEMES.include?(scheme) return nil if uri.userinfo return nil unless uri.path.to_s.empty? return nil if uri.query || uri.fragment host = normalize_host(uri.host) return nil if host.nil? # URI accepts arbitrarily large all-digit ports (and port 0); no # browser can match an origin outside the TCP port range, so an # out-of-range port is a silent-failure token — reject it. uri.port # is never nil for URI::HTTP (defaults apply), and the default ports # 80/443 are in range, so one check covers both shapes. return nil unless (1..65_535).cover?(uri.port) origin = "#{scheme}://#{host}" origin << ":#{uri.port}" unless uri.port == uri.default_port origin end |
.sanitize_tokens(context, name, value) ⇒ Array<String>
Sanitize one directive’s token value into normalized origin strings. A String value is treated as a whitespace-separated source list (the same ergonomics as a Policy.merge_directives String override); an Array is taken element-wise. Anything else drops the key.
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/otto/security/csp/request_extras.rb', line 157 def sanitize_tokens(context, name, value) candidates = case value when String then value.split when Array then value else log_drop(context, directive: name, token: value, reason: :invalid_shape) return [] end candidates.filter_map do |token| unless token.is_a?(String) log_drop(context, directive: name, token: token, reason: :invalid_shape) next end normalized = normalize_origin(token) if normalized.nil? log_drop(context, directive: name, token: token, reason: :not_an_origin) next end normalized end.uniq end |