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 |
|---|---|---|
ContentSecurityPolicySource::pathMatchesSource/WebCore/page/csp/ContentSecurityPolicySource.cpp |
modified | Reimplemented to the CSP3 match-paths algorithm: handles empty and '/' paths, splits source and URL paths on '/', enforces segment-count rules for exact vs directory matches, drops the trailing empty segment for directory matches, and compares each segment only after per-segment percent-decoding — instead of decoding the whole path and doing startsWith/==, which allowed encoded traversal to bypass the check. |
ContentSecurityPolicySourceList::parsePathSource/WebCore/page/csp/ContentSecurityPolicySourceList.cpp |
modified | Stores the raw, undecoded source-expression path (String(...)) instead of pre-decoding it with decodeURLEscapeSequences, so percent-decoding is applied consistently per segment at match time. |
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/Tools/MobileMiniBrowser/MobileMiniBrowserFramework/SceneDelegate.m b/Tools/MobileMiniBrowser/MobileMiniBrowserFramework/SceneDelegate.m
index 0fa324aaec3a..492142235cbf 100644
--- a/Tools/MobileMiniBrowser/MobileMiniBrowserFramework/SceneDelegate.m
+++ b/Tools/MobileMiniBrowser/MobileMiniBrowserFramework/SceneDelegate.m
@@ -42,8 +42,11 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op
UIWindow *window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
self.window = window;
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle bundleForClass:[SceneDelegate class]]];
WebViewController *viewController = (WebViewController *)[storyboard instantiateInitialViewController];
+#pragma clang diagnostic pop
window.rootViewController = viewController;
WKWebsiteDataStore *dataStore = viewController.dataStore;