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

빌드노트

스크립트는 실행되는데 fetch만 CSP에 막힌다

JS 실행 로그가 있어도 fetch 연결은 별도로 차단될 수 있다. 두 loopback origin에서 connect-src의 none·self·명시 origin과 default-src 대체 적용을 비교하고, 페이지의 CSP에서 필요한 API origin만 허용하는 판단을 설명한다.

JS 파일은 정상으로 내려오고 실행 로그도 찍히는데, API를 부르는 fetch()만 실패할 수 있다. 콘솔이 connect-src 위반을 가리킨다면 스크립트 실행 허용과 연결 대상 허용을 따로 확인해야 한다. script-src에 주소를 넣어도 그 스크립트가 호출하는 API까지 허용되지는 않는다. 페이지에 적용된 CSP의 connect-src에 필요한 API origin을 지정한다.

MDN의 connect-src 설명 (새 탭에서 열림)은 fetch()를 이 지시어의 대상으로 명시한다. 실행 코드를 고치기 전에 콘솔에서 차단된 URL과 지시어를 읽으면, 이미 실행된 JS에 실행 권한을 더 주는 수정을 피할 수 있다.

같은 JS를 실행하고 연결 정책만 바꿨다

운영 서비스 대신 loopback에 합성 페이지와 API를 만들었다. 페이지는 http://127.0.0.1:37250, 별도 API는 http://127.0.0.1:37251이었다. 호스트가 같아도 포트가 다르므로 다른 origin이다. origin은 스킴·호스트·포트의 조합이다. MDN의 origin 정의 (새 탭에서 열림)

페이지는 자신의 /runner.js를 외부 script 요소로 읽었다. 이 파일은 화면과 콘솔에 script-ran을 표시한 뒤, 페이지 서버와 별도 API 서버의 /api에 차례로 GET을 보냈다. 두 서버 모두 요청을 받으면 {"value":"synthetic"}를 반환했다. 조건마다 새 브라우저 컨텍스트를 사용했고 JS 파일의 바이트는 모두 같았다.

다른 origin의 응답을 읽을 수 있도록 API의 CORS 설정은 모든 조건에서 고정했다. Access-Control-Allow-Origin 값은 페이지 origin인 http://127.0.0.1:37250이었다. 요청은 인증 정보 없이 credentials: 'omit'으로 보내고 별도 요청 헤더를 붙이지 않았다. 실제 수신 기록에도 OPTIONS는 없었다. CORS 허용 여부를 함께 바꾸면 CSP 때문인지를 비교하기 어려워진다.

문서 응답에 default-src 'none'; script-src 'self'를 공통으로 두고 connect-src만 바꾼 네 조건의 결과다. 성공은 fetch가 200과 JSON을 읽었다는 뜻이고, 수신 수는 각 서버가 실제 받은 /api GET의 수다.

connect-src 값                       JS 표시      페이지 fetch / 수신  별도 API fetch / 수신
'none'                               script-ran   실패 / 0회           실패 / 0회
'self'                               script-ran   성공 / 1회           실패 / 0회
http://127.0.0.1:37251                script-ran   실패 / 0회           성공 / 1회
'self' http://127.0.0.1:37251         script-ran   성공 / 1회           성공 / 1회

'none'에서는 같은 origin으로 보내는 fetch도 막혔다. 반대로 별도 API origin만 적었을 때는 그 API가 성공하고 페이지 서버로의 fetch가 실패했다. 명시한 연결 목록에 같은 origin이 자동으로 추가되지는 않았다. 둘 다 필요할 때만 'self'와 API origin을 함께 적는다.

'self' 조건에서 JS 실행 로그 뒤에 남은 브라우저 오류는 다음과 같다.

Connecting to 'http://127.0.0.1:37251/api?case=self' violates the following Content Security Policy directive: "connect-src 'self'". The action has been blocked.

이 요청은 API 서버에 도착하지 않았고, fetch의 catch에서는 TypeError: Failed to fetch가 나왔다. 차단된 요청의 위반 이벤트도 effectiveDirective=connect-src, disposition=enforce였다. 단순히 fetch가 실패했다는 반환값보다 CSP 오류의 대상 URL과 서버 수신 0회가 함께 나온 것이 원인을 좁히는 근거다. W3C CSP Level 3의 connect-src 절 (새 탭에서 열림)은 연결 대상이 허용 목록과 맞지 않을 때 요청을 차단하는 절차를 정의한다.

connect-src가 없는데도 막힌 이유

다음 조건에서는 문서의 CSP를 아래처럼 보냈다. connect-src는 아예 없다.

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

JS는 여전히 script-ran을 표시했지만 두 fetch는 모두 실패했고 서버 수신도 각각 0회였다. 콘솔은 이번에는 default-src 'none'을 가리키면서 connect-src가 명시되지 않아 대신 사용됐다고 설명했다. 위반 이벤트의 effectiveDirective는 계속 connect-src였다.

connect-src가 없으면 연결 제한이 사라지는 것이 아니라 default-src를 대신 적용한다. 추가로 default-src 'self'; script-src 'self'도 실행하자 페이지 서버의 fetch만 성공했고 별도 API는 막혔다. MDN connect-src의 대체 적용 규칙 (새 탭에서 열림)

그렇다고 API를 열기 위해 default-src 전체를 넓힐 필요는 없다. 앞의 API 허용 조건은 default-src 'none'을 유지한 채 성공했다. 같은 정책 안에 connect-src를 명시하면 연결 요청은 그 목록으로 판단한다. default-src의 목록을 여기에 합치거나 두 목록을 모두 통과해야 하는 구조가 아니다. MDN의 default-src 대체 적용 설명 (새 탭에서 열림)

API 응답보다 페이지의 정책을 먼저 고친다

자기 서비스에서 같은 오류를 확인할 때는 개발자 도구의 Console과 Network를 연 상태로 문제 페이지를 새로 연다. 요청을 보내는 페이지의 HTML 문서 응답을 선택하고 Response Headers의 Content-Security-Policy를 읽는다. JS 파일이나 API 응답의 헤더를 페이지에 적용된 CSP와 혼동하지 않는다.

  1. Console에서 실패한 fetch URL과 connect-src 위반을 확인한다. JS 실행 자체가 막혔다면 이 비교의 전제가 다르다.
  2. 페이지 정책에 connect-src가 있으면 그 목록을 읽고, 없으면 default-src를 읽는다. API 주소의 스킴·호스트·포트를 페이지 origin과 비교한다. 이번 실험에서 'self'는 포트가 다른 API를 포함하지 않았다.
  3. 개발 환경에서 문서 응답을 만드는 서버나 프록시의 기존 connect-src 값을 수정한다. 호출이 필요한 API origin만 넣고, 같은 origin의 fetch도 필요하면 'self'를 남긴다. 기존 값이 'none'이라면 필요한 허용 목록으로 교체한다.
  4. 새 문서 응답에 변경한 정책이 실제로 들어왔는지 확인하고 같은 동작을 반복한다. fetch의 상태·본문과 API 서버 수신을 함께 확인한다.

이번 실험에서 페이지 서버와 별도 API를 모두 사용한 조건의 전체 CSP 헤더는 다음과 같다.

Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self' http://127.0.0.1:37251

이는 로컬 비교용 정책이다. 자기 서비스에서는 API origin을 실제 필요한 주소로 바꾸고 기존 CSP의 다른 지시어를 유지한다. 이 헤더를 기존 헤더 뒤에 하나 더 붙여 해결하려고 해서는 안 된다. 여러 강제 CSP는 각각 적용되므로, 다른 정책이 계속 차단하면 새 허용 정책만으로 연결을 열 수 없다. 이는 W3C의 다중 정책 설명 (새 탭에서 열림)에 따른 추가 확인 사항이며, 이번 실험은 단일 CSP 헤더만 사용했다.

CSP 허용 뒤 콘솔 오류가 CORS로 바뀐다면 API 응답의 CORS 허용을 따로 확인한다. connect-src가 연결을 허용해도 다른 origin의 응답을 JS가 읽을 수 있는지는 별도 조건이다. MDN CORS 설명 (새 탭에서 열림)은 이 역할을 응답을 읽을 수 있는 origin의 허용으로 설명한다. 이번에는 CORS 응답을 동일하게 유지했으므로 CORS 거절 조건까지 비교한 것은 아니다. TypeError: Failed to fetch만으로 두 원인을 구별하지 않는다.

직접 실행한 환경은 Windows 11, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15였다. 새 컨텍스트의 loopback HTTP 페이지와 비인증 GET만 비교했고 서버는 실험 뒤 종료했다. 다른 브라우저, HTTPS 운영 환경, 리다이렉트, Service Worker, WebSocket, 인증·preflight 조합은 검증하지 않았다. 스크립트가 실행됐다는 사실과 그 안의 fetch가 허용됐다는 사실을 나눠 확인하는 것이 이 실험의 범위다.

← 목록으로 돌아가기