Skip to content

Commit 286a79d

Browse files
tonghuarootfletchto99Copilot
authored
Merge commit from fork
* Fix CSP directive injection in sandbox / plugin-types / report-to (GHSA-rqq5-2gf9-4w4q) The 2020 source-list scrub (gsub /[\n;]/) was not applied to three caller-byte-interpolating builders: - build_sandbox_list_directive - build_media_type_list_directive (plugin-types) - build_report_to_directive When any of those caller-supplied values contained a `;` or CR/LF, the bytes landed verbatim in the Content-Security-Policy header. Because sandbox and plugin-types are emitted in alphabetical order before script-src, an injected `; script-src 'unsafe-inline' *` wins via CSP's first-occurrence rule and disables script-src. Mirror the existing source-list scrub: replace `;`, `\n`, `\r` with a space and emit a Kernel.warn (same UX as build_source_list_directive). Adds three regression specs covering all three builders. Co-Authored-By: tonghuaroot <tonghuaroot@gmail.com> Signed-off-by: tonghuaroot <tonghuaroot@gmail.com> * Apply review feedback: unify source-list scrub, single warn per directive Three refinements to the GHSA fix: 1. Unify the legacy build_source_list_directive scrub with the new scrub_directive_value helper. The new helper's regex is [\n\r;] (strict superset of the legacy [\n;]); routing the source-list path through it closes the bare-\r smuggling gap on naive downstreams without expanding the fix surface. 2. Scrub the joined directive string once instead of per-token, so high-cardinality input emits a single Kernel.warn per directive rather than N warns. Removes scrub_directive_tokens entirely (only caller was the per-token map). Behavior pinned by a new spec that asserts exactly one warn for a 3-semicolon input. 3. Move the helper docstring above the method it documents (it was above the constant). Reword to reflect the per-directive warn contract. Side effect of (1): two existing source-list deprecation specs asserted the legacy warn format (contains a ;); the unified helper uses $~[0].inspect (contains a ";") which renders \n as "\n" in the warning rather than embedding a literal newline. Updated the two pre-existing expectations to match — the new format is unambiguously more readable for humans and the GHSA-fix author already chose .inspect for the new helper. Tests: 321 examples, 0 failures (+2 new regression specs: source-list \r injection, single-warn semantics). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Close test gaps: pin sandbox warn text + add report-only regression spec Two follow-ups on the review's test-coverage thread: 1. Tightened the sandbox-injection spec from allow(Kernel).to receive(:warn) to expect(...).to receive(:warn).with(...).once. Pins both halves of the contract: the scrub neutralizes the injection AND the deprecation warning fires exactly once per offending value. Catches a future regression where someone reverts the joined-string scrub to per-token (which would emit N warns instead of 1). 2. Added a report-only regression spec. Content-Security-Policy builds value through the same #value method regardless of :report_only, so the existing fix transparently covers Content-Security-Policy-Report-Only — but no spec asserts that. A future refactor that split the enforced and report-only builders could silently leave Report-Only exploitable while the enforced spec stayed green. Tests: 322 examples, 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Signed-off-by: tonghuaroot <tonghuaroot@gmail.com> Co-authored-by: Matt Langlois <fletchto99@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c29127f commit 286a79d

2 files changed

Lines changed: 106 additions & 11 deletions

File tree

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def build_sandbox_list_directive(directive)
8787
elsif sandbox_list && sandbox_list.any?
8888
[
8989
symbol_to_hyphen_case(directive),
90-
sandbox_list.uniq
90+
scrub_directive_value(directive, sandbox_list.uniq.join(" "))
9191
].join(" ")
9292
end
9393
end
@@ -97,15 +97,37 @@ def build_media_type_list_directive(directive)
9797
if media_type_list && media_type_list.any?
9898
[
9999
symbol_to_hyphen_case(directive),
100-
media_type_list.uniq
100+
scrub_directive_value(directive, media_type_list.uniq.join(" "))
101101
].join(" ")
102102
end
103103
end
104104

105105
def build_report_to_directive(directive)
106106
return unless endpoint_name = @config.directive_value(directive)
107107
if endpoint_name && endpoint_name.is_a?(String) && !endpoint_name.empty?
108-
[symbol_to_hyphen_case(directive), endpoint_name].join(" ")
108+
[symbol_to_hyphen_case(directive), scrub_directive_value(directive, endpoint_name)].join(" ")
109+
end
110+
end
111+
112+
# Bytes that would let a caller-controlled value break out of its
113+
# directive and inject sibling CSP directives. CR/LF are included
114+
# so naive downstreams that split on bare \r can't be used to
115+
# smuggle directives either.
116+
DIRECTIVE_INJECTION_REGEX = /[\n\r;]/.freeze
117+
118+
# Private: scrubs caller-controlled bytes that would let a value
119+
# break out of its CSP directive (CR, LF, semicolon). Shared across
120+
# every directive builder so sandbox / plugin-types / report-to /
121+
# source-list all reject the same byte set with the same warn UX.
122+
# Emits a single Kernel.warn per directive even when multiple
123+
# offending bytes are present.
124+
def scrub_directive_value(directive, value)
125+
str = value.to_s
126+
if str =~ DIRECTIVE_INJECTION_REGEX
127+
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.")
128+
str.gsub(DIRECTIVE_INJECTION_REGEX, " ")
129+
else
130+
str
109131
end
110132
end
111133

@@ -118,12 +140,7 @@ def build_source_list_directive(directive)
118140
source_list = @config.directive_value(directive)
119141
if source_list != OPT_OUT && source_list && source_list.any?
120142
minified_source_list = minify_source_list(directive, source_list).join(" ")
121-
122-
if minified_source_list =~ /(\n|;)/
123-
Kernel.warn("#{directive} contains a #{$1} in #{minified_source_list.inspect} which will raise an error in future versions. It has been replaced with a blank space.")
124-
end
125-
126-
escaped_source_list = minified_source_list.gsub(/[\n;]/, " ")
143+
escaped_source_list = scrub_directive_value(directive, minified_source_list)
127144
[symbol_to_hyphen_case(directive), escaped_source_list].join(" ").strip
128145
end
129146
end

spec/lib/secure_headers/headers/content_security_policy_spec.rb

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ module SecureHeaders
2929
end
3030

3131
it "deprecates and escapes semicolons in directive source lists" do
32-
expect(Kernel).to receive(:warn).with(%(frame_ancestors contains a ; in "google.com;script-src *;.;" which will raise an error in future versions. It has been replaced with a blank space.))
32+
expect(Kernel).to receive(:warn).with(%(frame_ancestors contains a ";" in "google.com;script-src *;.;" which will raise an error in future versions. It has been replaced with a blank space.))
3333
expect(ContentSecurityPolicy.new(frame_ancestors: %w(https://google.com;script-src https://*;.;)).value).to eq("frame-ancestors google.com script-src * .")
3434
end
3535

3636
it "deprecates and escapes semicolons in directive source lists" do
37-
expect(Kernel).to receive(:warn).with(%(frame_ancestors contains a \n in "\\nfoo.com\\nhacked" which will raise an error in future versions. It has been replaced with a blank space.))
37+
expect(Kernel).to receive(:warn).with(%(frame_ancestors contains a "\\n" in "\\nfoo.com\\nhacked" which will raise an error in future versions. It has been replaced with a blank space.))
3838
expect(ContentSecurityPolicy.new(frame_ancestors: ["\nfoo.com\nhacked"]).value).to eq("frame-ancestors foo.com hacked")
3939
end
4040

@@ -243,6 +243,84 @@ module SecureHeaders
243243
csp = ContentSecurityPolicy.new({ default_src: %w('self'), report_to: "reporting-endpoint-name" })
244244
expect(csp.value).to eq("default-src 'self'; report-to reporting-endpoint-name")
245245
end
246+
247+
it "strips semicolons and newlines from sandbox tokens to prevent directive injection" do
248+
# Pins both halves of the contract: scrub neutralizes the
249+
# injection AND the deprecation warning fires exactly once
250+
# for the offending value (the joined-string scrub means one
251+
# warn per directive regardless of token count or how many
252+
# offending bytes a token contains).
253+
expect(Kernel).to receive(:warn).with(%(sandbox contains a ";" in "allow-forms; script-src 'unsafe-inline' * allow-scripts" which will raise an error in future versions. It has been replaced with a blank space.)).once
254+
csp = ContentSecurityPolicy.new(
255+
default_src: %w('self'),
256+
sandbox: ["allow-forms; script-src 'unsafe-inline' *", "allow-scripts"],
257+
script_src: %w('self')
258+
)
259+
expect(csp.value).not_to match(/sandbox[^;]*;\s*script-src 'unsafe-inline'/)
260+
expect(csp.value).to include("sandbox allow-forms script-src 'unsafe-inline' * allow-scripts")
261+
end
262+
263+
it "strips semicolons and newlines from plugin-types tokens to prevent directive injection" do
264+
allow(Kernel).to receive(:warn)
265+
csp = ContentSecurityPolicy.new(
266+
default_src: %w('self'),
267+
plugin_types: ["application/pdf;\nscript-src 'unsafe-inline' *"],
268+
script_src: %w('self')
269+
)
270+
expect(csp.value).not_to match(/plugin-types[^;]*;\s*script-src 'unsafe-inline'/)
271+
expect(csp.value).not_to include("\n")
272+
expect(csp.value).not_to include("plugin-types application/pdf;")
273+
end
274+
275+
it "strips semicolons and newlines from report-to endpoint to prevent directive injection" do
276+
allow(Kernel).to receive(:warn)
277+
csp = ContentSecurityPolicy.new(
278+
default_src: %w('self'),
279+
report_to: "csp-endpoint; script-src 'unsafe-inline' *",
280+
script_src: %w('self')
281+
)
282+
expect(csp.value).not_to match(/report-to[^;]*;\s*script-src 'unsafe-inline'/)
283+
expect(csp.value).to include("report-to csp-endpoint script-src 'unsafe-inline' *")
284+
end
285+
286+
it "strips carriage returns from source-list values to prevent directive injection" do
287+
allow(Kernel).to receive(:warn)
288+
csp = ContentSecurityPolicy.new(
289+
default_src: %w('self'),
290+
script_src: ["'self'\rscript-src 'unsafe-inline' *"]
291+
)
292+
expect(csp.value).not_to include("\r")
293+
expect(csp.value).to include("script-src 'self' script-src 'unsafe-inline' *")
294+
end
295+
296+
it "emits a single Kernel.warn per directive even when multiple offending bytes are present" do
297+
# Per-directive (not per-token) warn semantics so high-cardinality
298+
# input can't spam stderr / error trackers.
299+
expect(Kernel).to receive(:warn).once
300+
ContentSecurityPolicy.new(
301+
default_src: %w('self'),
302+
sandbox: ["allow-forms;allow-scripts;allow-popups"]
303+
).value
304+
end
305+
306+
it "applies the directive-injection scrub in report-only mode too" do
307+
# Report-only CSP goes through the same `value` builder as
308+
# enforced CSP, so the scrub is structurally inherited — but
309+
# nothing pins that. A future refactor that split the builders
310+
# (e.g., to differentiate report-only semantics) could silently
311+
# leave Content-Security-Policy-Report-Only exploitable while
312+
# the enforced spec stayed green. Pin it explicitly.
313+
allow(Kernel).to receive(:warn)
314+
csp = ContentSecurityPolicy.new(
315+
default_src: %w('self'),
316+
sandbox: ["allow-forms; script-src 'unsafe-inline' *"],
317+
script_src: %w('self'),
318+
report_only: true
319+
)
320+
expect(csp.name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME)
321+
expect(csp.value).not_to match(/sandbox[^;]*;\s*script-src 'unsafe-inline'/)
322+
expect(csp.value).to include("sandbox allow-forms script-src 'unsafe-inline' *")
323+
end
246324
end
247325
end
248326
end

0 commit comments

Comments
 (0)