Released May 11, 2026

CVE-2026-28907

CVSS 8.1 severity high CWE-20CWE-116 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N

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.cpp
  • Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp

Test Files

  • LayoutTests/http/tests/security/contentSecurityPolicy/path-traversal-bypass-with-percent-encoding-expected.txt
  • LayoutTests/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>