CVE-2026-28907
Overview
Background
- CSP source path matching
- CSP restricts sources by path; matching must follow CSP3’s per-segment algorithm.
- Percent-encoding
- %2e (.) and %2f (/) encodings that, if decoded at the wrong time, can change a path’s structure.
- Path traversal
- Using ../ (or its encoding) to escape a directory restriction.
Root Cause Analysis
This fixes a Content Security Policy path-matching bypass via percent-encoding, by rewriting ContentSecurityPolicySource::pathMatches() to follow the CSP3 match-paths algorithm.
Before the fix, pathMatches() percent-decoded the ENTIRE URL path once (PAL::decodeURLEscapeSequences(url.path())) and then did a whole-string comparison: startsWith(m_path) for a directory source (path ending in ‘/’) or an exact ==. In addition, ContentSecurityPolicySourceList::parsePath() pre-decoded the source-expression path with decodeURLEscapeSequences. The violated invariant is that CSP path matching must be performed segment-by-segment, decoding each path segment individually (per CSP3), so that encoded separators and traversal sequences cannot change the path’s structure before comparison. Because the old code decoded the whole path first, an attacker could encode a traversal such as %2e%2e (==’..’) or %2f (==’/’) inside the URL: the early full decode turned ‘/trusted/%2e%2e/evil/x.js’ into ‘/trusted/../evil/x.js’, which startsWith(’/trusted/’) and therefore passed the CSP check, while the network layer later normalized the path to ‘/evil/x.js’ when actually fetching — so a resource CSP was meant to block loaded anyway, i.e. the policy was not enforced (the advisory’s impact).
The fix splits both the source path (path A) and the URL path (path B) strictly on ‘/’, requires A to have no more segments than B, requires equal segment counts for an exact (non-trailing-slash) match, drops the trailing empty segment for a directory match, and compares each segment only after percent-decoding that individual segment. parsePath() now stores the raw, undecoded source path so decoding happens consistently per segment at match time. This makes encoded ‘/’-and-’..’ tricks compare as literal, non-matching segments, restoring correct CSP path enforcement. The regression test (path-traversal-bypass-with-percent-encoding) exercises exactly this. Fully established by the diff.
Attack Path
- Target page ships a path-scoped CSP A page sets a Content Security Policy that allows a resource type only from a directory path, e.g. script-src https://host/trusted/.
- Introduce a percent-encoded traversal URL An attacker gets the page to reference a resource URL with encoded traversal inside the allowed prefix, e.g. https://host/trusted/%2e%2e/evil/x.js (or using %2f).
- Bypass the CSP path check Pre-patch pathMatches() decodes the whole path to /trusted/../evil/x.js and startsWith(’/trusted/’) returns true, so CSP treats the URL as allowed.
- Fetch resolves to the blocked path The network/URL layer normalizes /trusted/../evil/ to /evil/ when fetching, so the resource actually loads from a path CSP intended to block.
- CSP is not enforced The attacker-controlled script/resource loads despite the policy, defeating the path restriction the CSP was meant to impose.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifSource/WebCore/page/csp/ContentSecurityPolicySource.cpp |
modified |
Files Changed
LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txtLayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.htmlSource/WebCore/page/csp/ContentSecurityPolicySource.cppSource/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
Audit Directions
- Whole-string decode before matchingFind security comparisons that decodeURLEscapeSequences on a full path/URL before comparing; prefer per-component decoding.
- Other CSP directive matchersAudit host/port/scheme matching for similar spec-divergence that encoding can exploit.
Patch
diff --git a/Source/WebCore/page/csp/ContentSecurityPolicySource.cpp b/Source/WebCore/page/csp/ContentSecurityPolicySource.cpp
index 31ea89c0d4e7..52b2b9319c5e 100644
--- a/Source/WebCore/page/csp/ContentSecurityPolicySource.cpp
+++ b/Source/WebCore/page/csp/ContentSecurityPolicySource.cpp
@@ -124,15 +124,48 @@ bool ContentSecurityPolicySource::hostMatches(const URL& url) const
bool ContentSecurityPolicySource::pathMatches(const URL& url) const
{
+ // https://www.w3.org/TR/CSP3/#match-paths
+ // Path A is the source expression's path (m_path, from the CSP directive).
+ // Path B is the URL's path being checked against the policy.
+
+ // Step 1: empty path automatically matches.
if (m_path.isEmpty())
return true;
- auto path = PAL::decodeURLEscapeSequences(url.path());
+ auto urlPath = url.path();
+
+ // Step 2: "/" matches empty path.
+ if (m_path == "/"_s && urlPath.isEmpty())
+ return true;
+
+ // Step 3: directory match if path A ends with '/'.
+ bool exactMatch = !m_path.endsWith('/');
+
+ // Step 4: strictly split both on '/'.
+ auto pathListA = m_path.splitAllowingEmptyEntries('/');
+ auto pathListB = urlPath.toString().splitAllowingEmptyEntries('/');
+
+ // Step 5: path A must not have more segments than path B.
+ if (pathListA.size() > pathListB.size())
+ return false;
+
+ // Step 6: exact match requires same number of segments.
+ if (exactMatch && pathListA.size() != pathListB.size())
+ return false;
- if (m_path.endsWith('/'))
- return path.startsWith(m_path);
+ // Step 7: for directory match, remove trailing empty segment from A.
+ if (!exactMatch) {
+ ASSERT(pathListA.last().isEmpty());
+ pathListA.removeLast();
+ }
+
+ // Step 8: compare each segment after percent-decoding.
+ for (unsigned i = 0; i < pathListA.size(); ++i) {
+ if (PAL::decodeURLEscapeSequences(pathListA[i]) != PAL::decodeURLEscapeSequences(pathListB[i]))
+ return false;
+ }
- return path == m_path;
+ return true;
}
bool ContentSecurityPolicySource::portMatches(const URL& url, ShouldUpgradePorts shouldUpgradePorts) const
diff --git a/Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp b/Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
index 0d1b89ec1778..f22158dc9435 100644
--- a/Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
+++ b/Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
@@ -537,7 +537,7 @@ template<typename CharacterType> String ContentSecurityPolicySourceList::parsePa
ASSERT(buffer.position() <= buffer.end());
ASSERT(buffer.atEnd() || (*buffer == '#' || *buffer == '?'));
- return PAL::decodeURLEscapeSequences(begin.first(buffer.position() - begin.data()));
+ return String(begin.first(buffer.position() - begin.data()));
}
// port = ":" ( 1*DIGIT / "*" )
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt
new file mode 100644
index 000000000000..e19cf81f3d67
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt
@@ -0,0 +1,44 @@
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy%2f..%2fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2f..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2F..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security%2F..%2F..%2F..%2F..%2Fetc/script.js because it does not appear in the script-src directive of the Content Security Policy.
+Percent-encoded path traversal sequences (%2F..%2F) should not bypass CSP path restrictions.
+
+
+
+--------
+Frame: '<!--frame1-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame2-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame3-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame4-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame5-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame6-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame7-->'
+--------
+PASS
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html
new file mode 100644
index 000000000000..f5df984d9787
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src='resources/multiple-iframe-test.js'></script>
+<script>
+var tests = [
+ // Normal path within allowed dir.
+ ['yes', 'script-src 127.0.0.1:8000/security/', 'resources/script.js'],
+
+ // Multi-level %2F..%2F traversal outside allowed dir. Normalizes to /resources/script.js.
+ ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/', 'http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2Fresources/script.js'],
+
+ // Single-level %2F..%2F traversal. Normalizes to /security/resources/script.js.
+ ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/', 'http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js'],
+
+ // Lowercase %2f should also be blocked.
+ ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/', 'http://127.0.0.1:8000/security/contentSecurityPolicy%2f..%2fresources/script.js'],
+
+ // Mixed case %2f and %2F in the same URL.
+ ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/', 'http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2f..%2F..%2Fresources/script.js'],
+
+ // Four consecutive dot segments. Normalizes to /resources/script.js.
+ ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/', 'http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2F..%2F..%2Fresources/script.js'],
+
+ // Traversal past root clamps to /. Normalizes to /etc/script.js.
+ ['no', 'script-src 127.0.0.1:8000/security/', 'http://127.0.0.1:8000/security%2F..%2F..%2F..%2F..%2Fetc/script.js'],
+];
+</script>
+</head>
+<body onload="test()">
+ <p>
+ Percent-encoded path traversal sequences (%2F..%2F) should not bypass CSP path restrictions.
+ </p>
+</body>
+</html>
Regression Test / PoC
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt new file mode 100644 index 000000000000..e19cf81f3d67 --- /dev/null +++ b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt @@ -0,0 +1,44 @@ +CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy. +CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy. +CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy%2f..%2fresources/script.js because it does not appear in the script-src directive of the Content Security Policy. +CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2f..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy. +CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2F..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy. +CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security%2F..%2F..%2F..%2F..%2Fetc/script.js because it does not appear in the script-src directive of the Content Security Policy. +Percent-encoded path traversal sequences (%2F..%2F) should not bypass CSP path restrictions. + + + +-------- +Frame: '<!--frame1-->' +-------- +PASS + +-------- +Frame: '<!--frame2-->' +-------- +PASS + +-------- +Frame: '<!--frame3-->' +-------- +PASS + +-------- +Frame: '<!--frame4-->' +-------- +PASS + +-------- +Frame: '<!--frame5-->' +-------- +PASS + +-------- +Frame: '<!--frame6-->' +-------- +PASS + +-------- +Frame: '<!--frame7-->' +-------- +PASS diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html new file mode 100644 index 000000000000..f5df984d9787 --- /dev/null +++ b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html @@ -0,0 +1,35 @@ +<!DOCTYPE html> +<html> +<head> +<script src='resources/multiple-iframe-test.js'></script> +<script> +var tests = [ + // Normal path within allowed dir. + ['yes', 'script-src 127.0.0.1:8000/security/', 'resources/script.js'], + + // Multi-level %2F..%2F traversal outside allowed dir. Normalizes to /resources/script.js. + ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/', 'http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2Fresources/script.js'], + + // Single-level %2F..%2F traversal. Normalizes to /security/resources/script.js. + ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/', 'http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js'], + + // Lowercase %2f should also be blocked. + ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/', 'http://127.0.0.1:8000/security/contentSecurityPolicy%2f..%2fresources/script.js'], + + // Mixed case %2f and %2F in the same URL. + ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/', 'http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2f..%2F..%2Fresources/script.js'], + + // Four consecutive dot segments. Normalizes to /resources/script.js. + ['no', 'script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/', 'http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2F..%2F..%2Fresources/script.js'], + + // Traversal past root clamps to /. Normalizes to /etc/script.js. + ['no', 'script-src 127.0.0.1:8000/security/', 'http://127.0.0.1:8000/security%2F..%2F..%2F..%2F..%2Fetc/script.js'], +]; +</script> +</head> +<body onload="test()"> + <p> + Percent-encoded path traversal sequences (%2F..%2F) should not bypass CSP path restrictions. + </p> +</body> +</html>