← All issues

[5] CSP path matching bypassed via percent-encoded slashes

Severity: Medium | Component: WebCore CSP path matching | 9a19d07

Rated Medium because the diff fixes a deterministic CSP path-restriction bypass exploitable when combined with a separate URL-injection primitive on the allowed origin; impact is policy bypass, not memory corruption.

WebKit's pathMatches() diverged from the CSP3 spec by percent-decoding the entire URL path as a flat string, then doing prefix/equality checks. This made it vulnerable to %2F..%2F path-traversal bypasses. The fix adopts the spec's algorithm (§6.7.2.12): split both paths on literal /, percent-decode each segment, and compare corresponding pairs.

Source/WebCore/page/csp/ContentSecurityPolicySource.cpp

bool ContentSecurityPolicySource::pathMatches(const URL& url) const
{
+ // https://www.w3.org/TR/CSP3/#match-paths
if (m_path.isEmpty())
return true;
 
- auto path = PAL::decodeURLEscapeSequences(url.path());
+ auto urlPath = url.path();
+
+ if (m_path == "/"_s && urlPath.isEmpty())
+ return true;
+
+ bool exactMatch = !m_path.endsWith('/');
+ auto pathListA = m_path.splitAllowingEmptyEntries('/');
+ auto pathListB = urlPath.toString().splitAllowingEmptyEntries('/');
+
+ if (pathListA.size() > pathListB.size())
+ return false;
+ if (exactMatch && pathListA.size() != pathListB.size())
+ return false;
+ if (!exactMatch) {
+ ASSERT(pathListA.last().isEmpty());
+ pathListA.removeLast();
+ }
 
- if (m_path.endsWith('/'))
- return path.startsWith(m_path);
+ 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

- return PAL::decodeURLEscapeSequences(begin.first(buffer.position() - begin.data()));
+ return String(begin.first(buffer.position() - begin.data()));

The old implementation percent-decoded the entire URL path as one flat string and then did a startsWith/equality check against m_path. The new implementation splits both m_path and url.path() on the literal / via splitAllowingEmptyEntries('/'), applies steps 1–7 of the spec (empty-path fast path, / vs empty, directory-vs-exact match, segment count comparison, trailing empty-segment removal), and only then percent-decodes each segment in isolation before comparing. Complementarily, parsePath() no longer percent-decodes the directive's path at parse time — m_path now retains raw %2F sequences so the segment split is meaningful.

Decode-before-tokenise ordering in a path matcher: percent-decoding the full URL path before splitting on '/' lets encoded slashes (%2F) cross structural segment boundaries that the access-control policy is defined over.

A CSP directive carries a list of source expressions; each has a scheme, host, port and optional path component. URL fetching consults ContentSecurityPolicySource::matches(), which checks scheme/host/port/path. CSP3 §6.7.2.12 defines path matching as a segment-wise operation — both paths are split on literal /, each pair of segments is percent-decoded, and the decoded segments are compared. A directive ending in / is a directory match; otherwise it is an exact match. %2F is the percent-encoding of /, and the URL standard preserves %2F literally in the path component rather than decoding it to a structural separator, so /a%2Fb/c has two segments (a%2Fb and c), not three. PAL::decodeURLEscapeSequences has no notion of segment boundaries and turns %2F into a literal /.

pathMatches() performed the comparison on a fully percent-decoded URL path. PAL::decodeURLEscapeSequences(url.path()) turns %2F into a literal /, so an attacker URL like http://host/security/contentSecurityPolicy%2F..%2F..%2Fresources/script.js decodes to /security/contentSecurityPolicy/../../resources/script.js. The decoded string starts with /security/contentSecurityPolicy/, which startsWith(m_path) accepts — even though the URL's true path segment is the single literal blob contentSecurityPolicy%2F..%2F..%2Fresources that does NOT live under /security/contentSecurityPolicy/resources/ per the CSP3 segment-wise definition.

🔒

The decode-then-compare ordering at the heart of this bug is one of the oldest anti-patterns in web security — the analysis traces exactly which URLs slipped through and why CSP3's spec deliberately reverses the order.

Subscribe to read more

🔒

Four reusable audit directions for percent-encoded canonicalisation bypasses across WebKit policy layers, with specific starting points beyond CSP path matching.

Subscribe to read more