Align ContentSecurityPolicySource::pathMatches() with CSP3 spec path matching algorithm
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
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
Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html
Patch Details
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.
Background
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.
Analysis
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.
Insight
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.
Audit directions
-
Security decisions on a decoded flat string rather than structurally split components. The invariant is split on the delimiter in the encoded text, then decode each component — never the reverse. Narrow: grep
Source/WebCore/page/csp/andSource/WebCore/page/for remainingPAL::decodeURLEscapeSequences(calls whose result feeds astartsWith/==/containscomparison, and check the counterpart operand's decode count;ContentSecurityPolicySource::hostMatches()and theframe-ancestors/form-actionpaths are the immediate neighbours. Wider: the same shape appears in any WebKit comparison over a delimiter-structured string where decoding lands first — cookie path scoping,SecurityOrigin/OriginAccessEntrypath handling, service-worker scope matching, WebExtension match-pattern evaluation, file-URL sandbox path allowlists. The code-search shape is one decode call whose output is immediately fed to a prefix or equality test. Widest: this is the classic decode-before-parse / parser-differential class, and the invariant carries to any codebase comparing structured identifiers — HTTP proxies versus origin servers disagreeing on%2F, S3/GCS path-prefix IAM policies, reverse-proxylocationprefix rules, JVM and Go path-prefix authorization filters. Match tell at the widest rung: can any encoded byte in this string decode into the delimiter that defines its structure? If yes and decoding precedes splitting, it is a hit. -
Asymmetric normalization between the two operands of a security comparison — one side normalized at parse time, the other at check time, so a change to either drifts the pair out of sync. Narrow: verify, for every CSP source-expression component, that the directive-side value stored by
ContentSecurityPolicySourceList::parsePath/parseHost/parseSchemeand the URL-side value pulled fromURLundergo exactly the same normalization — case folding, percent-decoding, IDNA, trailing-separator handling — exactly once. Wider: the same audit applies wherever a policy is compiled once and evaluated many times against live inputs:OriginAccessEntry, CORS allowlist entries, WebExtension host permissions,SecurityOrigin::isSameOriginAsversus its serialized-string comparisons. The shape to notice in search results is a constructor or parser that transforms its argument before storing it. Widest: the reusable invariant is normalize both operands of an authorization comparison in one place, at one time, the same number of times, and it holds for any allowlist system compiled ahead of evaluation — firewall rule compilers, IAM policy engines, WAF signature matchers. Match tell: a stored policy field transformed at ingest, compared against a request field transformed at query time; count the transformations on each side, and unequal counts are the bug. -
Remaining spec-divergence surface in WebCore's CSP matching, audited the way this commit audited §6.7.2.12 — by diffing each helper against its spec section. Narrow: start with
schemeMatches()(§6.7.2.6, including the non-spec self-source upgrade/side-grade allowances already flagged in comments),hostMatches()/wildcardMatches()(§6.7.2.7, wildcard label-boundary handling and IDNA/uppercase hosts),portMatches()(§6.7.2.8, default-port equivalence during scheme upgrade), andContentSecurityPolicySourceList::isProtocolAllowedByStar(), which the source itself annotates as deliberately broader than spec. Wider: the same spec-transcription-drift class applies to WebKit's other allowlist evaluators written as hand-optimized shortcuts over a spec algorithm — mixed-content checks, referrer-policy downgrade rules,Sec-Fetchand sandbox-flag propagation. Widest: the principle is any hand-optimized fast path replacing a specified algorithm must be proven equivalent on the algorithm's own edge inputs, not just typical ones, and it applies to any implementation of a written standard — URL parsing, JWT validation, cookie-prefix rules, TLS name matching. The reusable technique is to build the spec's step list as a checklist and construct one adversarial input per step. Match tell: an implementation shorter than the spec's step list, with no comment explaining which steps were collapsed and why the collapse is sound.