본문 바로가기
빌드노트 검색

빌드노트

스크립트는 실행되는데 Web Worker 생성은 CSP에 막힌다

문서 스크립트가 실행돼도 Worker는 다른 CSP 목록에 막힐 수 있다. worker-src부터 default-src까지의 fallback을 실제 메시지·위반 이벤트·서버 요청으로 비교하고, 필요한 Worker 출처만 허용하는 수정과 same-origin 실패를 구별한다.

페이지의 JavaScript는 실행되는데 new Worker(...) 뒤로 메시지가 오지 않고 Console에 CSP 오류가 뜬다면, script-src에 주소가 있는지만 확인해서는 부족하다. Worker 파일을 불러올 때는 worker-src가 우선하고, 그것이 없으면 child-src, script-src, default-src 순서로 정책을 찾는다. 같은 출처의 JS여도 문서 스크립트 실행과 Worker 시작의 결과가 달라질 수 있다.

여기서 fallback은 앞 지시문이 없을 때 뒤 지시문의 목록을 사용하는 규칙이다. 앞 목록이 요청을 거절했다고 다음 허용 목록을 찾아가는 방식이 아니다. MDN의 worker-src 설명 (새 탭에서 열림)은 이 순서와 적용 대상을 설명한다. 이 글은 일반 HTTP URL로 시작하는 기본 classic 전용 Worker를 다룬다.

Worker를 만든 문서의 응답부터 확인한다

개발자 도구의 Network를 열고 문제 페이지를 다시 불러온다. Worker를 생성한 HTML 문서의 Response Headers에서 Content-Security-Policy를 읽는다. iframe 안의 코드라면 해당 프레임 문서를 선택한다. Worker 파일 응답만 보면 생성 요청을 막은 문서 정책을 놓칠 수 있다.

그다음 Console에서 실패한 Worker URL과 오류가 인용한 정책을 맞춘다. 예를 들어 다음 정책은 페이지와 같은 출처의 외부 JS 파일을 허용하지만, 명시한 worker-src가 없으므로 Worker에는 child-src 'none'을 적용한다. 별도 loopback 실험에서 실제로 차단된 문서의 헤더 값이다.

Content-Security-Policy: default-src 'none'; script-src 'self'; child-src 'none'

이 조건의 문서 스크립트는 document-script 1을 출력했다. 하지만 Worker의 메시지는 오지 않았고, Worker 서버의 해당 GET 수는 0회였다. Console은 child-src 'none'을 인용하면서 worker-src가 명시되지 않아 child-src를 대신 사용했다는 안내를 남겼다. script-src 'self'를 다시 추가해도 이미 선택된 child-src의 거절이 바뀌지는 않는다.

페이지와 같은 출처에서 제공하는 Worker가 실제로 필요하다면, 그 문서 응답을 만드는 서버나 프록시의 기존 CSP에 worker-src 'self'를 명시한다. 다음은 앞 정책에 이 지시문만 추가해 같은 Worker에서 메시지를 받은 값이다. 이는 실험용 문서의 헤더 전체이며, 자기 앱에서는 기존 정책을 이 한 줄로 교체하지 말고 Worker 지시문에 필요한 허용값을 반영한다.

Content-Security-Policy: default-src 'none'; script-src 'self'; child-src 'none'; worker-src 'self'

이때 child-src 'none'은 남아 있었고 Worker가 숫자 7을 돌려줬다. Worker 전용 지시문을 명시했으므로 그 요청에는 child-src까지 내려가지 않은 것이다. 'self'는 문서와 스킴·호스트·포트가 같은 출처다. 실제 생성 URL이 무엇인지 확인하고 그 파일을 제공할 범위만 허용한다. 오류를 없애려고 *, blob:, data:를 한꺼번에 추가할 이유는 없다.

worker-src가 없어도 Worker 제한은 남을 수 있다

별도 실험은 Windows 11, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15에서 실행했다. 매 조건에 새 브라우저 컨텍스트를 사용했고, 같은 loopback 서버가 문서·문서 스크립트·Worker 파일을 제공했다. 외부 서비스나 실제 앱의 정책은 사용하지 않았다.

Worker는 숫자를 받으면 고정 숫자 7만 응답했다. 비교한 Worker 파일의 내용은 다음 한 줄이다. 기존 앱에서 작은 대조 파일을 준비할 때도 계산이나 외부 요청 없이 메시지 왕복만 확인할 수 있다.

self.onmessage = () => self.postMessage(7);

문서 스크립트는 Worker를 만든 뒤 메시지 수신 처리기를 연결하고 숫자 1을 보냈다. 수신하면 Worker를 종료했다. 모든 조건에서 같은 문서 스크립트가 실행 표식 1을 남겼고, 다음과 같이 Worker 결과가 갈렸다.

  • 명시한 worker-src: worker-src 'none'이면 차단됐다. worker-src 'self'이면 child-src 'none'이 함께 있어도 7을 받았다.
  • child-src로 fallback: worker-src 없이 child-src 'none'; script-src 'self'이면 차단됐다. 반대로 child-src 'self'; script-src 'none'이면 7을 받았다.
  • script-src로 fallback: worker-src와 child-src를 모두 빼자 script-src 'self'에서 7을 받았다. script-src 'none'에서는 default-src 'self'가 있어도 차단됐다.
  • default-src로 fallback: 앞의 세 지시문을 모두 빼자 default-src 'self'에서는 7을 받았고, default-src 'none'에서는 차단됐다. CSP 자체가 없는 같은 출처 대조군에서도 7을 받았다.

script-src 'none'이거나 script-src가 없는 비교에서는 script-src-elem 'self'로 문서의 외부 script 요소를 허용했다. 그래서 문서 스크립트가 실행되지 않아 Worker 시도 자체가 빠지는 상황을 피할 수 있었다. script-src-elem은 Worker의 fallback 목록에 들어가지 않는다. 문서의 script 요소에 대한 허용을 Worker 허용으로 읽으면 안 되는 지점이다. CSP Level 3의 script-src-elem 적용 범위 (새 탭에서 열림)는 이를 명시하며, fallback 목록과 선택 절차 (새 탭에서 열림)에도 같은 순서가 나온다. 확인한 CSP 문서는 2026년 9월 16일자 작업 초안이다.

같은 출처의 9개 조건 중 허용된 5개는 각각 Worker GET 1회와 메시지 7을 남겼다. 차단된 4개는 각각 Worker GET 0회, 메시지 없음, 강제 CSP 위반 이벤트를 남겼다. 이 비교가 뒷받침하는 판단은 허용 목록을 합치는 것이 아니라, 명시 여부에 따라 Worker 요청에 사용할 목록을 고른다는 것이다.

위반 이벤트의 이름과 실제 헤더를 함께 읽는다

자기 개발용 페이지에서 차단 대상 URL을 확인하려면 다음 처리기를 Worker 생성 코드보다 먼저 등록한다. 이미 페이지 로딩 때 Worker를 만들었다면 허용된 기존 JS 파일의 생성 호출 앞에 넣고 다시 불러온다. Console에서 뒤늦게 등록하면 앞서 발생한 이벤트는 잡히지 않는다.

document.addEventListener('securitypolicyviolation', (event) => {
  console.log(event.effectiveDirective, event.disposition, event.blockedURI);
});

이 처리기는 CSP 위반 이벤트의 effectiveDirective (새 탭에서 열림), 정책의 강제·보고 모드 (새 탭에서 열림), 차단 URL을 출력한다. 실험에서 child-src로 막힌 경우에도 effectiveDirective는 worker-src, disposition은 enforce였다. script-src와 default-src로 막힌 경우도 같았다. 따라서 이벤트에 worker-src가 찍혔다는 사실만으로 헤더에 그 지시문이 명시됐다고 단정하면 안 된다. Console이 인용한 정책과 실제 응답의 fallback 순서를 함께 확인한다.

또 이번 Chromium에서는 CSP로 막힌 네 경우 모두 new Worker(...)가 객체를 반환한 뒤 Worker의 error 이벤트가 발생했다. 생성자 호출에서 예외가 없었다는 것만으로 시작에 성공했다고 판단할 수 없다. 수정 후에는 새 문서 응답에 의도한 정책이 내려왔는지, 같은 URL의 강제 위반이 사라졌는지, 기대한 message를 받았는지까지 본다.

정책이 Content-Security-Policy-Report-Only로만 전달된다면 보고 자체와 차단을 구분해야 한다. 강제 CSP가 여러 개거나 HTML의 CSP meta가 있다면 그 정책도 함께 확인한다. 다른 강제 정책이 계속 거절하는 상태에서는 허용 헤더를 하나 더 보내도 해제되지 않는다. 이는 CSP 규격의 다중 정책 규칙 (새 탭에서 열림)에 따른 추가 확인 사항이며, 이번 비교는 문서당 강제 헤더 하나를 사용했다.

다른 포트의 실패를 CSP 차단으로 세지 않는다

Worker URL이 페이지와 다른 출처라면 CSP만 고쳐서는 해결되지 않을 수 있다. MDN의 Worker 생성자 설명 (새 탭에서 열림)은 일반 URL의 Worker 스크립트에 same-origin 조건이 있고, 이를 어기면 SecurityError가 날 수 있다고 설명한다.

이를 구별하려고 CSP가 전혀 없는 문서에서 포트만 다른 두 번째 loopback 서버의 Worker를 생성했다. 이 대조군은 SecurityError로 실패했고, Worker GET과 CSP 위반 이벤트는 모두 0회였다. 같은 문서에서 같은 출처 Worker는 7을 받았다. 따라서 다른 포트의 실패는 CSP 차단 수에 포함하지 않았다. 메시지가 오지 않거나 서버 요청이 없다는 사실만으로 CSP를 원인으로 확정하지 않는다.

선택된 Worker 허용 목록이 URL을 허용하는데도 실패하면 Console의 예외와 Worker 응답의 상태·JavaScript MIME·문법 오류를 확인한다. 이번 실험은 동일한 정상 JavaScript 응답을 고정해 정책 선택만 비교했다. 다른 브라우저 엔진, 모듈 Worker의 import, SharedWorker·Service Worker, 리다이렉트, blob:·data:, Worker 내부의 추가 요청 정책은 검증하지 않았다. 생성 URL을 허용한 결과를 Worker 내부 동작 전체의 허용으로 확대하지 않는다.

← 전체 글 보기