← All reports

Align ContentSecurityPolicySource::pathMatches() with CSP3 spec path matching algorithm

MediumWebCore CSP engineCrossOrigin

CVE: CVE-2026-28907 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may prevent Content Security Policy from being enforced Apple's description: The issue was addressed with improved input validation. Credit: Cantina

9a19d07 | Bugzilla 308675

Medium — no memory is corrupted here, and the exit condition is a server that normalizes %2F the way WebKit's URL parser refuses to. But path-scoped CSP exists precisely to survive an injection primitive, and this returns true for URLs that resolve outside the allowlisted directory, so the mitigation folds exactly when it is being relied on.

Content Security Policy is a string-comparison engine wearing a trust boundary's clothes: every allowlist decision reduces to whether a candidate URL's scheme, host, port and path line up with a source expression the page author wrote. The path half of that comparison is the interesting half, because a path is not a flat string — it is a delimiter-structured sequence of components, and the delimiter itself has a percent-encoded spelling. ContentSecurityPolicySource::pathMatches() is where WebKit decides whether a URL's path lies under an allowlisted directory, and its correctness rests entirely on when percent-decoding happens relative to when the path is split.

The angle: A page with an injection foothold can craft a same-host script URL containing %2F..%2F that a path-scoped policy accepts while the server resolves it outside the allowlisted directory, restoring script loading from user-content paths CSP was deployed to fence off.

Source/WebCore/page/csp/ContentSecurityPolicySource.cpp

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;
}

Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp

template<typename CharacterType> String ContentSecurityPolicySourceList::parsePath(StringParsingBuffer<CharacterType>& buffer)
{
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()));
}

LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html

+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'],
+
+ // 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'],
+];

The change is a full replacement of pathMatches() with a transcription of CSP3 §6.7.2.12, plus one line removed from the directive parser and a seven-case layout test.

The old body was three statements. It decoded the entire URL path in one call, then branched on whether the directive's path ended in /: directory sources got path.startsWith(m_path), exact sources got path == m_path. Both operands were flat strings by the time either comparison ran, and both had already been decoded — the URL side in pathMatches(), the directive side back in ContentSecurityPolicySourceList::parsePath() at policy-parse time.

The new body walks the spec's steps in order, and the step numbering in the comments maps one-to-one onto the algorithm. An empty m_path still short-circuits to true. A directive path of exactly "/" is special-cased against an empty URL path. exactMatch is derived from whether m_path ends in /. Then both paths — directive and URL — are split on the literal '/' character with splitAllowingEmptyEntries('/'), which preserves zero-length entries so that "/security/csp/" yields ["", "security", "csp", ""]. Two segment-count guards follow: path A having more segments than path B is an immediate false, and an exact match additionally requires the counts to be equal. For directory matches, A's trailing empty segment is dropped, which is what lets B carry additional segments beyond the directory prefix. Only in step 8 does decoding appear, and it appears inside the loop, applied to one segment at a time on both sides before the pairwise comparison.

The parsePath() line is the other half of the fix. Dropping PAL::decodeURLEscapeSequences() there means the directive's stored m_path retains its raw percent-encoding, so that both operands arrive at step 8 having been decoded exactly zero times, and both are decoded exactly once, in the same place, by the same call.

  Before (decode → split implicitly by prefix test):
    url.path()  "/security/csp%2F..%2Fresources/script.js"
      └─ decodeURLEscapeSequences ─► "/security/csp/../resources/script.js"
           └─ startsWith("/security/csp/") ─────────────────► TRUE  (bypass)

  After (split on literal '/' → decode per segment):
    url.path()  "/security/csp%2F..%2Fresources/script.js"
      └─ split('/') ─► ["", "security", "csp%2F..%2Fresources", "script.js"]
           └─ decode segment[2] ─► "csp/../resources"  ≠  "csp" ─► FALSE

The layout test drives seven iframes through multiple-iframe-test.js: one positive control (an ordinary in-directory load that must still be allowed), then six negative cases covering single-level traversal, multi-level traversal, lowercase %2f, mixed %2f/%2F in one URL, four consecutive dot segments, and traversal that walks past root. The expected output is six Refused to load console messages and seven PASS frames.

Content Security Policy source expressions. CSP lets a page declare where each class of subresource may come from. A directive value such as script-src 127.0.0.1:8000/security/contentSecurityPolicy/ is parsed at policy-ingest time into scheme, host, port and path parts, stored as a ContentSecurityPolicySource. Every candidate resource load is then checked against each source in the list.

The four-component check. ContentSecurityPolicySource::matches() evaluates schemeMatches() && hostMatches() && portMatches() && (didReceiveRedirectResponse || pathMatches()). Path checking is deliberately skipped after a redirect — that is spec-mandated, to avoid turning CSP violation reports into an oracle for redirect targets. pathMatches() is the last of the four, and it is the only one whose operands are internally structured.

Path matching in CSP3 (§6.7.2.12). The spec defines matching over path components, not over the path as a whole. Both the source expression's path and the URL's path are split on '/', each resulting segment is percent-decoded, and corresponding segments are compared pairwise. A source path ending in '/' is a directory match, meaning the URL may carry additional trailing segments; a source path not ending in '/' is an exact match, and the segment counts must be equal.

Percent-encoding and PAL::decodeURLEscapeSequences(). %2F — and its case-insensitive twin %2f — is the escaped spelling of '/'. PAL::decodeURLEscapeSequences() is WebKit's utility that turns percent-escapes in a string back into the literal characters they denote; it does not care whether the character it produces happens to be structurally significant to the caller.

URL path normalization is delimiter-sensitive. The URL parser's dot-segment removal — the pass that collapses .. and . — operates on literal '/' separators in the parsed path. Percent-escaped separators are opaque bytes to it and survive parsing unchanged, which means url.path() can legitimately hold a single component whose text contains %2F..%2F. Origin servers, by contrast, commonly decode and normalize the request-target themselves, so the path a server resolves need not be the path the URL parser holds.

splitAllowingEmptyEntries('/'). WTF's string split that preserves zero-length entries. "/a/b/" yields ["", "a", "b", ""]. That trailing empty entry is exactly what encodes "this source path is a directory" in the new algorithm, which is why step 7 removes it before the comparison loop rather than treating it as a segment to match.

This is a canonicalization-order flaw: a security decision was made on a decoded flat string rather than on structurally split components, so the encoded form of the delimiter acquired structural meaning at exactly the wrong moment.

  Directive:  script-src 127.0.0.1:8000/security/contentSecurityPolicy/
  Request:    http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js

  URL parser   ─► path = "/security/contentSecurityPolicy%2F..%2Fresources/script.js"
                    (dot-segment removal sees no literal '/' inside %2F..%2F — no collapse)
  pathMatches  ─► decode whole path
                    "/security/contentSecurityPolicy/../resources/script.js"
                    startsWith("/security/contentSecurityPolicy/") ─► ALLOW
  Origin server─► decodes + normalizes ─► /security/resources/script.js   ◄── outside allowlist

The three rows in the diagram are three parties disagreeing about where a / is. WebKit's URL parser is correct to leave %2F alone: an escaped separator is not a separator, and collapsing .. across it would itself be a bug. The old pathMatches() then decoded that path wholesale, manufacturing two literal separators that the parser had deliberately declined to create, and handed the result to startsWith(). The prefix test now saw a string that begins with /security/contentSecurityPolicy/ — because the decode had just put that boundary there — and returned true. The server, doing its own decode-and-normalize on the request-target, resolves the same URL to /security/resources/script.js, a directory the policy author never allowlisted. The policy check and the fetch that follows it were evaluating two different paths.

The exact-match branch had the same defect in a quieter form. path == m_path compared two fully decoded strings, so any encoding difference that collapsed onto the expected value after decoding — a %2F..%2F prefix, or any segment whose encoded and decoded spellings differ in delimiter count — satisfied equality on a URL whose structure differed.

The new loop closes it by inverting the two operations. Splitting happens on the literal '/' in the encoded text, which means %2F can never manufacture a segment boundary; it is just three ordinary characters inside whatever segment contains it. The traversal payload stays trapped:

  segment[2] (URL)       "contentSecurityPolicy%2F..%2Fresources"
    └─ decode ─────────► "contentSecurityPolicy/../resources"
  segment[2] (directive) "contentSecurityPolicy"
                          ─────────────────────► not equal ─► return false

Decoding still happens — the spec requires it, so that %61 in a directive matches a literal a in a URL — but it happens after the structure has been fixed, and it operates on a unit that can no longer be subdivided by its own output. That is the invariant the old code violated and the new code restores: locate the delimiter before decoding can create one.

The parsePath() change is not cleanup. With per-segment decoding now happening at comparison time, leaving the eager decode in the parser would have left the directive side decoded twice and the URL side once. Under that asymmetry a directive containing %252F would collapse to a literal / in m_path while the URL side's %252F decoded only to %2F — a fresh mismatch in the opposite direction, and a reminder that decode count is as much a part of the comparison contract as decode placement. Storing the raw text puts both operands at zero decodes on entry to step 8 and exactly one on exit.

Reaching this requires nothing exotic. The URL is ordinary web content: any <script src> a page can emit, which is to say any page where an attacker already has the markup-injection foothold that path-scoped CSP is deployed to contain. What the bypass is conditional on is the far end — the origin server has to be one that decodes and normalizes %2F in the request-target, which many do and some deliberately do not. Where it holds, the attacker regains the ability to load script from same-host paths outside the allowlist: upload directories, user-content trees, JSONP endpoints. There is no memory-safety primitive here and no sandbox boundary crossed; the check runs in the WebContent process where CSP is enforced, and the loss is a defense-in-depth layer, not the renderer itself.

Percent-decoding the whole path before splitting it let %2F..%2F manufacture a directory boundary that satisfied the CSP prefix test while the server resolved the URL outside the allowlist.

Worth noting for future audits: the second half of the fix — deleting the decode in parsePath() — was load-bearing, not tidying. Any time a fix relocates a normalization step, both operands of every comparison consuming that value must be re-audited for decode-count parity, because moving the decode to one side silently changes the contract on the other. And matches() still skips pathMatches() entirely when didReceiveRedirectResponse is set; that is spec-mandated, but it means path restrictions are only ever as strong as the pre-redirect URL.