Align ContentSecurityPolicySource::pathMatches() with CSP3 spec path matching algorithm
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
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
Source/WebCore/page/csp/ContentSecurityPolicySourceList.cpp
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 밖으로 해석하고 있었습니다.
Insight
향후 감사를 위해 짚어둘 부분이 있습니다. 이번 fix의 나머지 절반 — parsePath()에서 decode를 삭제한 부분 — 은 단순 정리가 아니라 이 수정 전체를 지탱하는 핵심 요소였습니다. normalization 단계를 다른 위치로 옮기는 fix가 있을 때마다, 그 값을 소비하는 모든 비교의 양쪽 operand에 대해 디코딩 횟수의 대칭성을 다시 점검해야 합니다. 한쪽의 디코딩 위치만 옮기면 반대쪽의 계약이 조용히 깨지기 때문입니다. 그리고 matches()는 didReceiveRedirectResponse가 설정된 경우 여전히 pathMatches() 자체를 건너뜁니다. 이는 스펙이 요구하는 동작이지만, path 제한의 강도가 결국 redirect 이전 URL만큼만 보장된다는 의미이기도 합니다.
Audit directions
-
보안 판단이 구조적으로 분리된 컴포넌트가 아니라 디코딩된 평평한 문자열 위에서 이루어지는 패턴. 지켜야 할 불변식은 인코딩된 텍스트에서 먼저 구분자를 기준으로 분리하고, 그 다음에 각 컴포넌트를 디코딩한다 — 절대 역순으로는 하지 않는다입니다. Narrow:
Source/WebCore/page/csp/와Source/WebCore/page/에서 남아 있는PAL::decodeURLEscapeSequences(호출 중, 그 결과가startsWith/==/contains비교로 흘러 들어가는 지점을 검색하고, 상대편 operand의 디코딩 횟수를 함께 점검해야 합니다.ContentSecurityPolicySource::hostMatches()와frame-ancestors/form-action경로가 가장 가까운 후보입니다. Wider: 같은 형태의 패턴이 디코딩이 먼저 이루어지는, 구분자로 구조화된 문자열 비교라면 WebKit 어디에서든 나타날 수 있습니다. cookie path scoping,SecurityOrigin/OriginAccessEntry의 path 처리, service-worker scope matching, WebExtension match-pattern 평가, file-URL sandbox path allowlist 등이 후보입니다. 코드 검색에서 찾을 패턴은 디코딩 호출 하나의 결과가 곧바로 prefix 검사나 equality 검사로 넘어가는 지점입니다. Widest: 이는 decode-before-parse / parser-differential이라는 전형적인 취약점 클래스이며, 이 불변식은 구조화된 식별자를 비교하는 어떤 코드베이스에도 그대로 적용됩니다. HTTP 프록시와 origin 서버가%2F해석에서 불일치하는 경우, S3/GCS의 path-prefix IAM 정책, reverse-proxy의locationprefix 규칙, JVM과 Go의 path-prefix 인가 필터 등이 해당됩니다. 가장 넓은 층위에서의 판별 기준은 이 문자열 안의 어떤 인코딩된 바이트가 디코딩되면 그 문자열의 구조를 정의하는 구분자가 될 수 있는가입니다. 그렇다면, 그리고 디코딩이 분리보다 먼저 일어난다면, 해당 지점은 취약합니다. -
보안 비교의 두 operand 사이에 존재하는 비대칭적 normalization — 한쪽은 파싱 시점에, 다른 한쪽은 검사 시점에 정규화되어, 둘 중 하나만 바뀌어도 쌍이 어긋나 버리는 패턴입니다. Narrow: CSP source expression의 모든 컴포넌트에 대해,
ContentSecurityPolicySourceList::parsePath/parseHost/parseScheme가 저장하는 directive 쪽 값과URL에서 가져오는 URL 쪽 값이 — 대소문자 변환, percent-decoding, IDNA, trailing 구분자 처리를 포함해 — 정확히 동일한 normalization을 정확히 한 번씩만 거치는지 확인해야 합니다. Wider: 같은 감사 방식이, 정책이 한 번 컴파일된 뒤 실제 입력에 대해 여러 번 평가되는 모든 곳에 적용됩니다.OriginAccessEntry, CORS allowlist entry, WebExtension host permission,SecurityOrigin::isSameOriginAs와 그 직렬화된 문자열 비교 등이 해당됩니다. 검색 결과에서 주목할 패턴은 인자를 저장하기 전에 변형하는 constructor나 parser입니다. Widest: 재사용 가능한 불변식은 인가(authorization) 비교의 두 operand를 한 곳에서, 한 시점에, 동일한 횟수만큼 정규화한다는 것이며, 이는 평가 이전에 미리 컴파일되는 모든 allowlist 시스템 — firewall rule compiler, IAM 정책 엔진, WAF signature matcher — 에 그대로 적용됩니다. 판별 기준은 다음과 같습니다. ingest 시점에 변형되어 저장되는 정책 필드와, query 시점에 변형되는 요청 필드를 놓고 양쪽의 변형 횟수를 세어보십시오. 그 횟수가 서로 다르다면 그것이 바로 버그입니다. -
WebCore의 CSP matching에 남아있는 spec-divergence 영역, 이번 commit이 §6.7.2.12를 점검한 방식을 그대로 적용할 수 있습니다. 각 helper를 해당 spec section과 diff하는 방식입니다. Narrow: 먼저
schemeMatches()(§6.7.2.6, 주석에 이미 표시되어 있는 non-spec self-source upgrade/side-grade 허용 로직 포함)부터 살펴봅니다. 이어서hostMatches()/wildcardMatches()(§6.7.2.7, wildcard label-boundary 처리와 IDNA/대문자 host 처리),portMatches()(§6.7.2.8, scheme upgrade 시 default-port 동등성 처리)를 점검합니다. 마지막으로ContentSecurityPolicySourceList::isProtocolAllowedByStar()도 대상에 포함되는데, 이 함수는 소스 코드 자체에 spec보다 의도적으로 더 넓게 허용하도록 주석이 달려 있습니다. Wider: 같은 종류의 spec-transcription-drift는 spec 알고리즘을 hand-optimized shortcut으로 옮겨 작성한 WebKit의 다른 allowlist evaluator에도 동일하게 적용됩니다. Mixed-content 검사, referrer-policy downgrade 규칙,Sec-Fetch및 sandbox-flag 전파 로직이 여기에 해당합니다. Widest: 원칙은 다음과 같습니다. spec 알고리즘을 대체하는 hand-optimized fast path는 일반적인 입력뿐 아니라 알고리즘 자체의 edge case 입력에 대해서도 동등성이 입증되어야 합니다. 이 원칙은 URL parsing, JWT validation, cookie-prefix 규칙, TLS name matching 등 명문화된 표준을 구현하는 모든 코드에 적용됩니다. 재사용 가능한 기법은 spec의 step list를 checklist로 만들고, 각 step마다 하나씩 adversarial input을 구성하는 것입니다. 이 패턴을 발견하는 단서는, 구현이 spec의 step list보다 짧으면서도 어떤 step을 왜 안전하게 축약했는지 설명하는 주석이 없는 경우입니다.