CVE-2026-28907
Overview
Impact:
Description: Processing maliciously crafted web content may prevent Content Security Policy from being enforced
Researchers: Cantina
Key insight. CSP path matching must decode per segment (CSP3), not decode the whole path and startsWith; whole-path decoding let %2e%2e/%2f traversal pass the check while the fetch normalized to a blocked path.
Patch Details
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;
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-28907 |
| Bugzilla | 308675 |
| Component | WebCore |
| Bug Class | Bypass |
| Severity | medium |
| Commit | 9a19d07c4f53bdf5… |
| Advisory | Apple Advisory |
Terminology
- 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
1. 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/.
2. 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).
3. 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.
4. 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.
5. 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
A CSP enforcement bypass (policy not enforced) enabling loads from paths CSP meant to block; medium-to-high. Enables loading attacker resources despite CSP, no memory safety.
Audit Directions
Whole-string decode before matching
Find security comparisons that decodeURLEscapeSequences on a full path/URL before comparing; prefer per-component decoding.
Other CSP directive matchers
Audit host/port/scheme matching for similar spec-divergence that encoding can exploit.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
ContentSecurityPolicySource::pathMatches |
Source/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::parsePath |
Source/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
Source Files
Source/WebCore/page/csp/ContentSecurityPolicySource.cppSource/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
Test Files
LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txtLayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt
new file mode 100644
index 000000000000..e19cf81f3d67
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt
@@ -0,0 +1,44 @@
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy%2f..%2fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2f..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security/contentSecurityPolicy/resources%2F..%2F..%2F..%2F..%2Fresources/script.js because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load http://127.0.0.1:8000/security%2F..%2F..%2F..%2F..%2Fetc/script.js because it does not appear in the script-src directive of the Content Security Policy.
+Percent-encoded path traversal sequences (%2F..%2F) should not bypass CSP path restrictions.
+
+
+
+--------
+Frame: '<!--frame1-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame2-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame3-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame4-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame5-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame6-->'
+--------
+PASS
+
+--------
+Frame: '<!--frame7-->'
+--------
+PASS
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html
new file mode 100644
index 000000000000..f5df984d9787
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src='resources/multiple-iframe-test.js'></script>
+<script>
+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'],
+];
+</script>
+</head>
+<body onload="test()">
+ <p>
+ Percent-encoded path traversal sequences (%2F..%2F) should not bypass CSP path restrictions.
+ </p>
+</body>
+</html>