← All reports

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

MediumWebCore CSP engineCrossOrigin

CVE: CVE-2026-28907 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may prevent Content Security Policy from being enforced Apple's description: The issue was addressed with improved input validation. Credit: Cantina

9a19d07 | Bugzilla 308675

Medium — 여기서 메모리 손상은 발생하지 않으며, exit condition은 서버가 %2F를 정규화하는 방식이 WebKit의 URL parser가 거부하는 방식과 다르다는 데 있습니다. 다만 path-scoped CSP는 애초에 injection primitive를 견뎌내기 위해 존재하는 장치입니다. 이 함수는 allowlist에 지정된 디렉터리 바깥으로 resolve되는 URL에 대해서도 true를 반환하므로, 정작 mitigation이 필요한 순간에 그 mitigation이 무력화됩니다.

Content Security Policy는 trust boundary의 탈을 쓴 문자열 비교 엔진에 가깝습니다. 모든 allowlist 판정은 결국 후보 URL의 scheme, host, port, path가 페이지 작성자가 써 놓은 source expression과 일치하는지를 따지는 문제로 환원됩니다. 이 비교 중에서도 path 쪽이 흥미로운 이유는, path가 단순한 flat string이 아니라 delimiter로 구조화된 component의 나열이고, 그 delimiter 자체도 percent-encoding된 형태로 표현될 수 있기 때문입니다. ContentSecurityPolicySource::pathMatches()는 WebKit이 URL의 path가 allowlist에 등록된 디렉터리 하위에 있는지를 판단하는 지점이며, 이 판단의 정확성은 전적으로 percent-decoding이 path를 분할하는 시점에 대해 언제 일어나는지에 달려 있습니다.

관전 포인트: injection foothold를 확보한 페이지라면 %2F..%2F를 포함한 same-host script URL을 조작해낼 수 있습니다. path-scoped policy는 이 URL을 통과시키지만, 서버는 이를 allowlist에 등록된 디렉터리 바깥으로 resolve합니다. 결과적으로 CSP가 애초에 차단하려 했던 user-content 경로에서 script를 다시 로드할 수 있게 됩니다.

Source/WebCore/page/csp/ContentSecurityPolicySource.cpp

bool ContentSecurityPolicySource::pathMatches(const URL& url) const
{
+ // https://www.w3.org/TR/CSP3/#match-paths
+ // Path A is the source expression's path (m_path, from the CSP directive).
+ // Path B is the URL's path being checked against the policy.
+
+ // Step 1: empty path automatically matches.
if (m_path.isEmpty())
return true;
 
- auto path = PAL::decodeURLEscapeSequences(url.path());
+ auto urlPath = url.path();
+
+ // Step 2: "/" matches empty path.
+ if (m_path == "/"_s && urlPath.isEmpty())
+ return true;
+
+ // Step 3: directory match if path A ends with '/'.
+ bool exactMatch = !m_path.endsWith('/');
+
+ // 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;
 
- if (m_path.endsWith('/'))
 
- return path.startsWith(m_path);
+ // 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 path == m_path;
+ return true;
}

Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp

template<typename CharacterType> String ContentSecurityPolicySourceList::parsePath(StringParsingBuffer<CharacterType>& buffer)
{
ASSERT(buffer.position() <= buffer.end());
ASSERT(buffer.atEnd() || (*buffer == '#' || *buffer == '?'));
 
**Patch Details**
 
이번 변경은 `pathMatches()` 전체를 CSP3 §6.7.2.12 알고리즘을 그대로 옮긴 형태로 교체하고, directive parser에서 한 줄을 제거하며, 7개 케이스로 구성된 layout test를 추가하는 구조입니다.
 
기존 구현은 세 줄짜리 로직이었습니다. URL 전체 경로를 한 번에 디코딩한 뒤, directive의 경로가 `/`로 끝나는지에 따라 분기했습니다. 디렉터리 소스는 `path.startsWith(m_path)`로, exact 소스는 `path == m_path`로 비교했습니다. 두 비교 시점에는 이미 양쪽 모두 평평한 문자열 형태였고, 디코딩도 이미 끝난 상태였습니다. URL 쪽은 `pathMatches()`에서, directive 쪽은 정책 파싱 시점에 `ContentSecurityPolicySourceList::parsePath()`에서 각각 디코딩되어 있었습니다.
 
새 구현은 스펙의 단계를 순서대로 따라가며, 주석의 단계 번호가 알고리즘과 1대1로 대응됩니다. `m_path`가 비어 있으면 여전히 곧바로 `true`를 반환합니다. directive 경로가 정확히 `"/"`인 경우는 빈 URL 경로에 대해 별도로 처리됩니다. `exactMatch` 여부는 `m_path`가 `/`로 끝나는지로 결정됩니다. 이어서 directive와 URL 양쪽 경로 모두 *리터럴* `'/'` 문자를 기준으로 `splitAllowingEmptyEntries('/')`로 분리되는데, 이 함수는 길이 0인 항목도 보존하므로 `"/security/csp/"`는 `["", "security", "csp", ""]`로 나뉩니다. 이어서 두 개의 세그먼트 개수 검사가 따라옵니다. path A의 세그먼트 수가 path B보다 많으면 즉시 `false`이고, exact match의 경우에는 추가로 개수가 정확히 같아야 합니다. 디렉터리 매치의 경우 A의 마지막 빈 세그먼트가 제거되는데, 이 덕분에 B가 디렉터리 prefix 이후로 세그먼트를 더 가질 수 있게 됩니다. 디코딩은 8단계에서야 등장하며, 그것도 루프 *내부*에서 한 번에 한 세그먼트씩, 양쪽 모두에 대해 쌍별 비교 직전에 적용됩니다.
 
`parsePath()` 쪽 한 줄 삭제가 이번 fix의 나머지 절반입니다. 거기서 `PAL::decodeURLEscapeSequences()` 호출을 제거함으로써, directive에 저장되는 `m_path`는 raw percent-encoding 상태를 그대로 유지하게 됩니다. 그 결과 두 operand 모두 8단계에 진입하는 시점에는 디코딩 횟수가 정확히 0회이고, 정확히 1회만 같은 위치, 같은 호출로 디코딩됩니다.
 

Before (decode → split implicitly by prefix test): url.path() "/security/csp%2F..%2Fresources/script.js" └─ decodeURLEscapeSequences ─► "/security/csp/../resources/script.js" └─ startsWith("/security/csp/") ─────────────────► TRUE (bypass)

After (split on literal '/' → decode per segment): url.path() "/security/csp%2F..%2Fresources/script.js" └─ split('/') ─► ["", "security", "csp%2F..%2Fresources", "script.js"] └─ decode segment[2] ─► "csp/../resources" ≠ "csp" ─► FALSE


layout test는 `multiple-iframe-test.js`를 통해 7개의 iframe을 구동합니다. 먼저 positive control 하나를 두어, 디렉터리 내부의 정상적인 로드는 여전히 허용되어야 함을 확인합니다. 이어서 negative case 6개가 뒤따르는데, single-level traversal, multi-level traversal, 소문자 `%2f`, 하나의 URL 안에 `%2f`와 `%2F`가 섞인 경우, 점(`.`) 세그먼트 4개가 연속되는 경우, root를 넘어서는 traversal을 각각 다룹니다. 예상 출력은 `Refused to load` 콘솔 메시지 6개와 `PASS` 프레임 7개입니다.

**Background**

**Content Security Policy source expressions.** CSP는 각 리소스 클래스가 어디서 로드될 수 있는지를 페이지가 선언할 수 있게 해줍니다. `script-src 127.0.0.1:8000/security/contentSecurityPolicy/`와 같은 directive 값은 정책 수집 시점에 scheme, host, port, path 부분으로 파싱되어 `ContentSecurityPolicySource`로 저장됩니다. 이후 모든 후보 리소스 로드는 이 목록의 각 source에 대해 검사됩니다.

**The four-component check.** `ContentSecurityPolicySource::matches()`는 `schemeMatches() && hostMatches() && portMatches() && (didReceiveRedirectResponse || pathMatches())`를 평가합니다. path 검사는 redirect 이후에는 의도적으로 건너뜁니다. 이는 스펙에서 명시한 요구사항으로, CSP violation report가 redirect target을 알아내는 oracle이 되는 것을 막기 위한 조치입니다. `pathMatches()`는 네 조건 중 마지막이며, operand가 내부적으로 구조화되어 있는 유일한 검사이기도 합니다.

**Path matching in CSP3 (§6.7.2.12).** 스펙은 경로 전체가 아니라 경로의 *구성 요소* 단위로 매칭을 정의합니다. source expression의 경로와 URL의 경로 모두 `'/'`를 기준으로 분리되고, 각 세그먼트는 percent-decode되며, 대응하는 세그먼트끼리 쌍별로 비교됩니다. source path가 `'/'`로 끝나면 디렉터리 매치를 의미하며, 이 경우 URL은 추가적인 trailing 세그먼트를 가질 수 있습니다. source path가 `'/'`로 끝나지 않으면 exact match를 의미하며, 이때는 세그먼트 개수가 같아야 합니다.

**Percent-encoding and `PAL::decodeURLEscapeSequences()`.** `%2F` — 그리고 대소문자를 구분하지 않는 짝인 `%2f` — 는 `'/'`를 이스케이프한 표기입니다. `PAL::decodeURLEscapeSequences()`는 문자열 안의 percent-escape를 실제 문자로 되돌리는 WebKit의 유틸리티 함수로, 그 결과로 만들어지는 문자가 호출자 입장에서 구조적으로 의미를 갖는지는 신경 쓰지 않습니다.

**URL path normalization is delimiter-sensitive.** URL parser의 dot-segment 제거 과정 — `..`와 `.`를 축약하는 pass — 은 파싱된 경로 안의 리터럴 `'/'` 구분자를 기준으로 동작합니다. percent로 이스케이프된 구분자는 이 과정에서 불투명한 바이트로 취급되어 파싱 이후에도 그대로 살아남습니다. 이 때문에 `url.path()`는 텍스트 안에 `%2F..%2F`를 포함한 하나의 컴포넌트를 정당하게 가질 수 있습니다. 반면 origin 서버는 흔히 request-target을 자체적으로 디코딩하고 정규화하므로, 서버가 실제로 해석하는 경로는 URL parser가 들고 있는 경로와 다를 수 있습니다.

**`splitAllowingEmptyEntries('/')`.** 길이 0인 항목도 보존하는 WTF의 문자열 split 함수입니다. `"/a/b/"`는 `["", "a", "b", ""]`로 나뉩니다. 이 trailing 빈 항목이 바로 새 알고리즘에서 "이 source path는 디렉터리다"라는 정보를 인코딩하는 부분이며, 그래서 7단계에서는 이를 비교 루프에 넣을 세그먼트로 취급하는 대신 미리 제거합니다.

<!-- GATE:analysis -->

**Analysis**

이번 문제는 canonicalization 순서에 관한 결함입니다. 보안 판단이 구조적으로 분리된 컴포넌트가 아니라 디코딩된 평평한 문자열 위에서 이루어졌고, 그 결과 구분자의 인코딩된 형태가 하필 가장 위험한 시점에 구조적 의미를 갖게 되었습니다.

Directive: script-src 127.0.0.1:8000/security/contentSecurityPolicy/ Request: http://127.0.0.1:8000/security/contentSecurityPolicy%2F..%2Fresources/script.js

URL parser ─► path = "/security/contentSecurityPolicy%2F..%2Fresources/script.js" (dot-segment removal sees no literal '/' inside %2F..%2F — no collapse) pathMatches ─► decode whole path "/security/contentSecurityPolicy/../resources/script.js" startsWith("/security/contentSecurityPolicy/") ─► ALLOW Origin server─► decodes + normalizes ─► /security/resources/script.js ◄── outside allowlist


위 다이어그램의 세 행은 `/`가 어디에 있는지에 대해 세 주체가 서로 다르게 판단하고 있음을 보여줍니다. WebKit의 URL parser가 `%2F`를 건드리지 않은 것 자체는 옳은 동작입니다. 이스케이프된 구분자는 구분자가 아니며, 그것을 가로질러 `..`를 축약해버리면 오히려 그것이 버그가 됩니다. 문제는 기존 `pathMatches()`가 그 경로를 통째로 디코딩하면서, parser가 의도적으로 만들지 않았던 두 개의 리터럴 구분자를 새로 만들어내고, 그 결과를 `startsWith()`에 넘겼다는 데 있습니다. prefix 검사는 `/security/contentSecurityPolicy/`로 시작하는 문자열을 보게 되는데, 이는 방금 전 디코딩이 그 경계를 만들어 넣었기 때문이며, 결과적으로 `true`를 반환합니다. 반면 서버는 request-target에 대해 자체적으로 decode-and-normalize를 수행하여 같은 URL을 `/security/resources/script.js`로 해석합니다. 이는 정책 작성자가 애초에 allowlist에 넣지 않은 디렉터리입니다. 즉 정책 검사와 그 뒤에 이어지는 실제 fetch가 서로 다른 경로를 평가하고 있었던 셈입니다.

exact-match 분기도 같은 결함을 조용한 형태로 가지고 있었습니다. `path == m_path`는 완전히 디코딩된 두 문자열을 비교했기 때문에, 디코딩 후 우연히 기대값과 일치하게 되는 인코딩 차이 — `%2F..%2F` prefix라든가, 인코딩된 형태와 디코딩된 형태의 구분자 개수가 다른 세그먼트 — 가 있다면, 구조적으로는 다른 URL임에도 equality를 통과할 수 있었습니다.

새 루프는 두 연산의 순서를 뒤바꿔 이 문제를 막습니다. 분리(split)는 *인코딩된* 텍스트 안의 리터럴 `'/'`를 기준으로 이루어지므로, `%2F`가 세그먼트 경계를 새로 만들어낼 수 없습니다. 그것이 속한 세그먼트 안의 평범한 세 글자로 취급될 뿐입니다. traversal payload는 그 안에 갇히게 됩니다.

segment[2] (URL) "contentSecurityPolicy%2F..%2Fresources" └─ decode ─────────► "contentSecurityPolicy/../resources" segment[2] (directive) "contentSecurityPolicy" ─────────────────────► not equal ─► return false ```

디코딩 자체는 여전히 수행됩니다. 스펙이 요구하는 동작이기 때문입니다. 예를 들어 directive의 %61이 URL의 리터럴 a와 매칭되려면 디코딩이 필요합니다. 다만 이제는 구조가 먼저 확정된 뒤에 디코딩이 일어나고, 그 디코딩은 자기 자신의 결과로 다시 나뉠 수 없는 단위 위에서 동작합니다. 이것이 바로 기존 코드가 위반했고 새 코드가 복원한 불변식입니다. 즉, 디코딩이 구분자를 만들어낼 수 있기 전에 먼저 구분자를 찾아두어야 한다는 원칙입니다.

parsePath() 변경은 단순한 정리 작업이 아닙니다. 세그먼트별 디코딩이 이제 비교 시점에 이루어지는 상황에서, parser 쪽의 eager decode를 그대로 남겨두었다면 directive 쪽은 두 번, URL 쪽은 한 번 디코딩되는 비대칭이 생겼을 것입니다. 이 비대칭 상태에서는 %252F를 포함한 directive가 m_path 안에서 리터럴 /로 축약되는 반면, URL 쪽의 %252F%2F까지만 디코딩되어, 반대 방향의 새로운 mismatch가 발생했을 것입니다. 이는 디코딩 위치뿐 아니라 디코딩 횟수도 비교 계약의 일부라는 점을 보여주는 사례입니다. raw 텍스트를 저장하는 방식은 두 operand 모두 8단계 진입 시점에 디코딩 0회, 종료 시점에 정확히 1회가 되도록 만들어 줍니다.

이 취약점에 도달하는 데 특별한 조건은 필요하지 않습니다. URL은 평범한 웹 콘텐츠입니다. 어떤 페이지든 <script src>로 발행할 수 있는 값이며, 이는 곧 path-scoped CSP가 막고자 하는 markup-injection 발판을 공격자가 이미 확보한 페이지라면 어디서나 가능하다는 뜻입니다. 이 bypass가 조건부인 지점은 오히려 반대편, 즉 origin 서버 쪽입니다. request-target 안의 %2F를 디코딩하고 정규화하는 서버여야 성립하는데, 실제로 많은 서버가 그렇게 동작하며, 일부는 의도적으로 그렇게 하지 않기도 합니다. 조건이 성립하는 경우, 공격자는 allowlist 밖에 있는 같은 호스트 상의 경로 — 업로드 디렉터리, 사용자 콘텐츠 트리, JSONP 엔드포인트 등 — 에서 script를 로드할 수 있는 능력을 다시 확보하게 됩니다. 여기에는 memory-safety primitive도 없고 sandbox 경계를 넘는 동작도 없습니다. 검사 자체는 CSP가 강제되는 WebContent process 안에서 실행되며, 잃는 것은 defense-in-depth 계층 하나이지 renderer 자체는 아닙니다.

경로 전체를 percent-decode한 뒤에야 split하는 구조 때문에, %2F..%2F가 CSP prefix 검사를 통과시키는 디렉터리 경계를 만들어낼 수 있었고, 그동안 서버는 같은 URL을 allowlist 밖으로 해석하고 있었습니다.

향후 감사를 위해 짚어둘 부분이 있습니다. 이번 fix의 나머지 절반 — parsePath()에서 decode를 삭제한 부분 — 은 단순 정리가 아니라 이 수정 전체를 지탱하는 핵심 요소였습니다. normalization 단계를 다른 위치로 옮기는 fix가 있을 때마다, 그 값을 소비하는 모든 비교의 양쪽 operand에 대해 디코딩 횟수의 대칭성을 다시 점검해야 합니다. 한쪽의 디코딩 위치만 옮기면 반대쪽의 계약이 조용히 깨지기 때문입니다. 그리고 matches()didReceiveRedirectResponse가 설정된 경우 여전히 pathMatches() 자체를 건너뜁니다. 이는 스펙이 요구하는 동작이지만, path 제한의 강도가 결국 redirect 이전 URL만큼만 보장된다는 의미이기도 합니다.