← WebKit Silent-Fix Report — 2026-W21

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

severity high class Bypass confidence 0.80 WebCore CSP exploitable-grade
Roberto Rodriguez Wed May 20 17:41:03 2026 -0700 full: 9a19d07c4f53bdf52a375aa4adcdd2edaeb80e28 bug report ↗ view on GitHub ↗
Primitive: CSP path-restriction bypass via %2F..%2F percent-encoding
Triage note: CSP source-path matching aligned to CSP3 so encoded traversal can't bypass path restrictions.
Contents

The bug at a glance

This is a Content Security Policy path allow-list bypass in a security boundary that sites deliberately deploy to constrain which scripts may execute. Because the pre-patch matcher percent-decoded the entire URL path into one flat string before doing prefix/equality checks, an attacker who can influence a resource URL (for example via an open redirect, a reflected path, or a directory that serves attacker-controlled content) could smuggle %2F..%2F traversal sequences that satisfied a narrow script-src path but resolved to a script outside the intended directory. It does not corrupt memory, so it is not directly RCE, but defeating CSP removes the primary mitigation that sites rely on to contain XSS and to gate script provenance, which is why WebKit treats it as high severity and shipped it on a Safari rapid-response branch.

The attack surface is any page that ships a CSP with a path-restricted script-src (or other source-list directive), which is extremely common on hardened sites. Web content reaches pathMatches() every time the resource loader evaluates whether a to-be-fetched subresource URL is permitted by the policy; the attacker’s only requirement is to get the browser to attempt loading a URL whose raw path embeds encoded slashes and dot-segments. If a site allows script-src from a specific subdirectory but an endpoint under that origin can be reached with a %2F..%2F prefix that decodes to escape it, the flat-string decode made the traversal match the allowed prefix.

Root cause

The root cause is that the old ContentSecurityPolicySource::pathMatches() percent-decoded the URL’s path as a single opaque string and then performed a byte-level prefix or equality comparison against the source expression path m_path. The removed code was auto path = PAL::decodeURLEscapeSequences(url.path()); followed by if (m_path.endsWith('/')) return path.startsWith(m_path); return path == m_path;. Decoding first and comparing second is the fatal ordering: %2F decodes to a literal ‘/’, so a raw path segment like resources%2F..%2F..%2Fresources becomes the string resources/../../resources only AFTER the structural comparison logic would have wanted to treat it as one atomic segment. Worse, because startsWith() operated on the fully decoded blob, an allow-list of /security/contentSecurityPolicy/resources/ would prefix-match a decoded string that actually represents a traversal out of that directory, and the browser never re-normalized the dot-segments before matching.

The CSP3 spec (§6.7.2.12, the match-paths algorithm) is explicitly designed to avoid this by comparing structure before decoding. The patched pathMatches() implements the spec: it splits BOTH paths on the literal ‘/’ character using splitAllowingEmptyEntries(’/’), producing segment lists pathListA (from the directive) and pathListB (from the URL), and only then percent-decodes each segment individually inside the comparison loop: if (PAL::decodeURLEscapeSequences(pathListA[i]) != PAL::decodeURLEscapeSequences(pathListB[i])) return false;. Because the split happens on literal ‘/’ before any decoding, an encoded %2F can never introduce a segment boundary. The sequence %2F..%2F therefore stays trapped inside a single segment token; that token decodes to /../ internally but is compared as a whole against the corresponding directive segment, which will not match a clean segment name like resources. This structurally prevents encoded slashes from being reinterpreted as path separators.

The fix also enforces the spec’s segment-count invariants that the flat comparison lacked: exactMatch is derived from whether m_path ends with ‘/’; if pathListA has more segments than pathListB the match fails (a directive path can never be more specific than the URL); an exact (non-directory) match requires equal segment counts; and for a directory match the trailing empty segment produced by the final ‘/’ is removed from pathListA before the per-segment loop, so a directive of /dir/ matches any URL whose first segments are /dir/....

The second half of the fix is in ContentSecurityPolicySourceList::parsePath(): it changed return PAL::decodeURLEscapeSequences(begin.first(...)); to return String(begin.first(...));. This is essential and complementary: the stored directive path m_path must be kept in its raw, still-encoded form, because pathMatches() now decodes per-segment at comparison time. If the directive path were pre-decoded at parse time while the URL path is decoded per-segment, the two sides would be normalized asymmetrically and could re-open a mismatch. Keeping both sides encoded until the same per-segment decode step guarantees symmetric, spec-conformant comparison.

Key code

ContentSecurityPolicySource::pathMatches() — spec-conformant per-segment matching (verbatim from diff)

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

    // 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 true;

Patch walkthrough

  • Source/WebCore/page/csp/ContentSecurityPolicySource.cpp — Rewrites pathMatches() from a decode-then-flat-compare into the CSP3 match-paths algorithm: split both the directive path (m_path) and the URL path on literal ‘/’, apply the empty-path and ‘/’-matches-empty special cases, enforce segment-count rules (A cannot have more segments than B; exact match needs equal counts), strip the trailing empty segment for directory matches, and compare corresponding segments only after decoding each segment independently. Encoded %2F can no longer create a synthetic separator.
  • Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp — In parsePath(), stops eagerly percent-decoding the parsed directive path and stores the raw substring via String(…). This keeps the directive path encoded so that pathMatches() decodes both operands symmetrically at the per-segment comparison step; decoding the directive early would desynchronize normalization and could reintroduce the bypass.
  • LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html — New layout test enumerating traversal payloads (single- and multi-level %2F..%2F, lowercase %2f, mixed case, over-root clamping to /etc) against several script-src path allow-lists, asserting all are refused while a legitimate in-directory script still loads.
  • LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt — Expected output showing each malicious URL produces a ‘Refused to load … does not appear in the script-src directive’ console message and every frame reports PASS.

Background

CSP source-path matching — A source expression in a CSP directive such as script-src can carry an optional path, e.g. https://cdn.example/js/. When the loader evaluates a candidate URL against the policy it checks scheme, host, port and path in turn. Path matching is meant to be a structural, segment-wise comparison so a directive can pin scripts to a subdirectory. If path matching is unsound, the entire directive’s containment guarantee collapses to whatever the weakest matched source allows.

CSP3 match-paths algorithm (§6.7.2.12) — The spec defines path matching over lists of path segments rather than raw strings. Both the source path and the URL path are split on ‘/’, each segment is percent-decoded independently, and corresponding segments are compared. A source path ending in ‘/’ is a directory (prefix) match; otherwise it is an exact match requiring equal segment counts. Splitting before decoding is the crux: it prevents an encoded separator from altering the segment structure.

Percent-encoded traversal (%2F..%2F) — %2F is the percent-encoding of ‘/’ and %2f is its lowercase form. If a matcher decodes a whole path before analyzing separators, %2F becomes a real slash and the string a%2F..%2Fb collapses to a/../b, which normalizes to b — escaping directory a. Deferring the decode to a per-segment step means %2F stays inside one token and is compared literally, so it never behaves as a boundary.

PAL::decodeURLEscapeSequences — WebKit’s routine that turns percent-escapes back into their literal bytes. Its placement in the pipeline is security-relevant: applied to the full path up front it flattens structure and enables traversal, whereas applied per-segment during comparison it is safe because the segmentation has already been fixed against the raw, undecoded ‘/’ characters.

splitAllowingEmptyEntries — A WTF String helper that splits on a delimiter while preserving empty tokens between consecutive delimiters and at the ends. CSP relies on this so that a trailing ‘/’ yields a trailing empty segment (marking a directory match) and so segment counts line up exactly with the number of ‘/’ characters, which the count-based invariants in steps 5–7 depend on.

Vulnerability window

  1. Directive parse — A site ships e.g. script-src 127.0.0.1:8000/security/contentSecurityPolicy/resources/. Pre-patch, parsePath() percent-decoded the directive path when storing it into m_path.
  2. Fetch attempt — Page (or attacker-influenced markup) requests a script whose raw URL path contains encoded traversal, e.g. …/resources%2F..%2F..%2Fresources/script.js, which the loader must check against the policy.
  3. Flat decode — Old pathMatches() called decodeURLEscapeSequences(url.path()), turning every %2F into a literal ‘/’ and producing a single normalized string.
  4. Prefix/equality check — Because m_path ended with ‘/’, the code did path.startsWith(m_path). The decoded blob’s leading bytes matched the allowed directory prefix even though the full path, once dot-segments were resolved, pointed outside that directory.
  5. Bypass — pathMatches() returned true; the script was deemed policy-compliant and allowed to load, defeating the path restriction and letting a script from an unintended location execute in the protected context.
  6. Post-patch — The URL path is split on literal ‘/’ before decoding, so the whole %2F..%2F run remains one segment that decodes to resources/../../resources and fails to equal the directive segment resources; the load is refused.

Proof of concept

This is the verbatim test vector table from the added layout test path-traversal-bypass-with-percent-encoding.html. Each triple is [expectedToLoad, cspPolicy, scriptURL]. The single ‘yes’ case confirms legitimate in-directory loads still succeed; every ’no’ case is a %2F/%2f traversal payload that pre-patch would have prefix-matched the allowed directory. The harness (multiple-iframe-test.js) installs each policy in its own frame and asserts the script’s load outcome matches expectation.

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

Exploitation

  1. Find a permissive path-restricted directive — Identify a target origin whose CSP pins script-src (or another fetch directive) to a subdirectory, and where content the attacker cannot normally reach as script lives elsewhere under the same host/port.
  2. Craft an encoded-traversal URL — Build a script URL whose raw path begins with the allowed directory’s encoded prefix and then uses %2F..%2F to climb out to the desired location, e.g. allowed_dir%2F..%2F..%2Ftarget/evil.js.
  3. Trigger the load — Get the browser to attempt the fetch — via injected markup on the page, an open redirect that lands under the origin, or any sink that emits the crafted URL as a script source.
  4. Outcome — Pre-patch the flat decode makes startsWith() accept the URL and the script executes despite being outside the pinned directory. This is a policy-containment bypass; it becomes code execution in the page only in combination with a way to host or reach attacker-controlled script bytes at the traversed location, so on its own it weakens CSP rather than granting arbitrary execution.

Detection & hunting

For defenders and SOC / detection engineers:

  • Encoded slashes in subresource URLs — Flag fetches whose raw path contains %2F or %2f adjacent to dot-segments (%2F..%2F, %2f..%2f) on pages that publish a path-restricted CSP. Legitimate resource paths almost never encode their own separators, so these are high-signal indicators of an attempted bypass.
  • CSP refusal telemetry — On patched builds, watch report-uri/report-to and console for ‘Refused to load … does not appear in the script-src directive’ events whose blocked URL contains percent-encoded slashes; a spike indicates active probing of the path allow-list.
  • Version gating — The fix landed as commits.webkit.org/313617@main and on the safari-7624.2.5.110 rapid-response branch. Clients older than that evaluate path matching with the vulnerable flat decode; treat script-src path restrictions on those versions as not enforced against encoded traversal.

Audit directions

  • Decode-before-parse ordering — grep for decodeURLEscapeSequences(url.path()) or decodeURLEscapeSequences applied to a whole path/query before any splitting; any security decision (allow-lists, same-origin, path pinning) made on a pre-decoded flat string is the traversal-normalization class of bug.
  • Asymmetric normalization of comparands — Search for places where one side of a URL/path comparison is decoded and the other is not (e.g. parse-time decode of stored config vs. runtime decode of input). Symmetric per-segment decoding is the correct pattern; asymmetry re-opens matching gaps.
  • String prefix checks as path containment — grep for startsWith(/path)/ or == against composed path strings in policy code (CSP, CORS, sandbox, cookie path). Prefix/equality on flat paths ignores segment structure and dot-segment resolution; these should be segment-list comparisons.
  • Other CSP source matchers — Audit hostMatches/portMatches/schemeMatches and other source-list directives (img-src, connect-src, frame-src) for the same decode-then-compare pattern, since pathMatches was the instance found but the anti-pattern class spans every source expression evaluator.

Before / after

Loading diff…