← All reports

CSP strict-dynamic does not block parser-inserted external module scripts

strict-dynamic checks scripts twice — external modules were only checked once.

Component: WebCore Content Security Policy enforcement | 97937c9

Source/WebCore/dom/ScriptElement.cpp

// requestModuleScript (external module path) — before fix, this call was absent:
+ if (!contentSecurityPolicy->allowScriptForStrictDynamic(*this))
+ return;

Source/WebCore/page/csp/ContentSecurityPolicy.cpp

-bool ContentSecurityPolicy::allowNonParserInsertedScripts(...) const
+bool ContentSecurityPolicy::allowScriptForStrictDynamic(...) const

strict-dynamic은 CSP가 스크립트를 평가하는 방식 자체를 바꿉니다. URL allowlist를 확인하는 대신, 유효한 nonce를 가진 스크립트를 신뢰하고 그 스크립트가 동적으로 생성하는 스크립트까지 transitive하게 신뢰하는 방식입니다. 이때 parser-inserted 스크립트, 즉 DOM API가 아니라 HTML markup에 직접 작성된 스크립트는 이 transitive trust에서 명시적으로 제외되며 자체 nonce를 가져야 합니다. WebKit은 이를 두 단계 검사로 구현합니다. allowScriptForStrictDynamic이 request 시점에 실행되어 parser-inserted 스크립트를 걸러내고, allowScriptFromSource는 fetch 시점에 실행되지만 strict-dynamic이 활성화된 경우 첫 번째 단계가 이미 실행되었다는 전제 하에 무조건 true를 반환합니다. 문제는 external module 경로인 requestModuleScript가 첫 번째 단계를 전혀 호출하지 않았다는 점입니다. 그 결과 fetch 시점 단계가 모든 parser-inserted module script를 조용히 통과시키게 됩니다. 이번 fix는 누락된 호출을 추가하고, 함수 이름도 allowNonParserInsertedScripts에서 더 명확한 이름으로 변경했습니다.

strict-dynamic two-stage validation:

                       Stage 1                     Stage 2
                  allowScriptForStrictDynamic   allowScriptFromSource
                  (checks parserInserted flag)  (passes if strict-dynamic)

Classic scripts:      CALLED ──────────────────► CALLED   ✓
Inline modules:       CALLED ──────────────────► CALLED   ✓
External modules      MISSING ─────────────────► CALLED   ✗  (BUG: stage 1 skipped)
  (before fix):
External modules      CALLED ──────────────────► CALLED   ✓  (fixed)
  (after fix):

script-src 'nonce-X' 'strict-dynamic'으로 강화한 페이지에서도, 유효한 nonce가 없는 parser-inserted external module script는 차단되지 않았습니다. strict-dynamic이 존재하는 이유 자체가 무력화된 셈입니다. CSP를 의미 있는 XSS mitigation 경계로 삼고 있던 모든 사이트가 이 문제의 영향을 받습니다.

이 사례는 스크립트 로딩 경로 전반에 걸쳐 CSP gate 호출이 누락된 유형이 존재할 수 있음을 드러냅니다. Fetch 시점에 allowScriptFromSource를 호출하면서도 request 시점의 allowScriptForStrictDynamic을 건너뛰는 지점을 찾아볼 필요가 있습니다. 유력한 후보로는 import() 동적 module 표현식, worklet의 addModule(), service worker의 importScripts(), 그리고 speculative preload 경로가 있습니다. 이 두 단계 계약은 암묵적인 invariant에 가깝습니다. strict-dynamic 하에서 allowScriptFromSource에 도달하는 모든 경로는 그 이전에 allowScriptForStrictDynamic을 통과했어야 한다는 전제인데, 이는 구조적으로 강제되지 않고 관례로만 지켜지고 있어 새로운 스크립트 로딩 경로가 추가될 때마다 깨지기 쉽습니다. 코드 리뷰 관점에서는, 새로 추가된 request*Script 함수가 request 시점 gate 없이 fetch 시점 CSP 호출에 도달하는 패턴이 단서가 됩니다. 이런 비대칭은 파일을 넘나들며 추적하지 않아도 눈에 띌 정도로 드러납니다.