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

빌드노트

CSP 허용 목록을 넓혀도 다른 헤더의 차단은 남는다

프록시와 앱이 CSP를 각각 보내면 뒤의 허용 목록을 넓혀도 다른 정책의 차단은 남는다. 격리 브라우저의 9개 loopback 비교에서 헤더 순서·각 단독 정책·Report-Only와 요청 및 실행 표식을 확인하고, 실제 모든 강제 정책의 생성 위치를 찾는 방법을 설명한다.

프록시와 앱이 Content-Security-Policy를 각각 보내는 환경에서는 앱의 허용 목록을 넓혀도 JS가 계속 차단될 수 있다. 브라우저는 마지막 CSP 헤더로 앞의 정책을 덮어쓰지 않는다. 강제로 적용되는 정책을 모두 통과해야 하므로, 앱이 허용한 주소를 프록시의 정책이 여전히 막고 있다면 실행되지 않는다. MDN의 다중 CSP 설명 (새 탭에서 열림)

먼저 수정한 설정 파일보다 브라우저가 받은 HTML 문서의 전체 응답 헤더를 확인한다. 허용 헤더를 하나 더 붙이거나 헤더 순서를 바꾸는 것으로 이 문제를 풀 수 있는지, 별도 loopback 실험으로 비교했다. 운영 프록시에서 발생한 장애 기록은 아니다.

허용한 주소가 다른 정책에도 들어 있는가

개발자 도구의 Network를 연 뒤 Disable cache를 켜고 문제 페이지를 새로 불러온다. 실패한 JS를 포함하는 HTML 문서 요청을 선택한다. 이 문서의 Response Headers에서 Content-Security-Policy 값을 빠짐없이 읽는다. JS 파일 응답의 헤더만 확인하면 문서에 적용된 제한을 놓칠 수 있다.

다음은 실험에서 허용 목록을 넓힌 뒤에도 b.js가 차단된 문서 응답의 CSP 두 줄이다. 어느 정책에 b.js의 출처가 빠져 있는지 확인하면 된다.

Content-Security-Policy: script-src 'self' http://127.0.0.1:48798; report-uri /csp-report
Content-Security-Policy: script-src 'self' http://127.0.0.1:48798 http://127.0.0.1:48799; report-uri /csp-report

HTML은 http://127.0.0.1:48797에서 열었고, b.js는 http://127.0.0.1:48799에서 제공했다. 첫 정책에는 그 주소가 없다. 두 번째 정책에 주소를 추가했어도 첫 정책의 허용 범위는 그대로다. 이때 b.js 서버가 받은 GET은 0회였고 실행 표식도 남지 않았다. 같은 문서 출처의 common.js와 48798의 a.js는 각각 한 번 요청되고 실행됐다.

이 사례를 자기 환경에서 확인할 때는 다음을 구분한다.

  • 이름이 같은 강제 CSP 헤더가 여러 줄이면 각 값을 모두 확인한다. 도구가 쉼표로 이어진 한 값으로 보여 주더라도 여러 정책이 들어 있을 수 있다. CSP의 정책 목록은 쉼표로 구분되며 각 정책을 적용한다. CSP Level 3의 정책 목록과 헤더 처리 (새 탭에서 열림)
  • Content-Security-Policy-Report-Only는 강제 CSP와 따로 읽는다. 콘솔에 위반 메시지가 있다는 사실만으로 실제 차단됐다고 판단하지 않는다.
  • Console에서 실패한 URL과 함께 인용된 정책 값을 전체 헤더와 대조한다. 스크립트 요소라면 각 정책 안에서 script-src-elem, 없으면 script-src, 그것도 없으면 default-src를 확인한다. 여기서는 모든 정책에 script-src만 명시했다. MDN의 지시문 fallback 설명 (새 탭에서 열림)

응답에 강제 헤더가 하나뿐인데 설명되지 않는 제한이 남으면 HTML의 <meta http-equiv="Content-Security-Policy">도 확인한다. 문서가 정한 meta 정책도 다른 활성 정책과 함께 적용된다. 다만 파싱되기 전 자원까지 소급해서 제한하지는 않는다. 이 meta 동작은 문서로 확인한 사항이며 아래 실험에는 넣지 않았다. CSP Level 3의 meta 적용 조건 (새 탭에서 열림)

두 정책 중 하나를 고르는 동작이 아니었다

합성 HTML과 JS만 제공하는 loopback 서버 세 개를 만들었다. 문서와 같은 출처의 common.js, 다른 포트의 a.js, b.js를 일반 외부 script 요소로 읽었다. 각 파일은 실행되면 자기 이름을 콘솔과 화면에 남긴다. 페이지마다 새 브라우저 컨텍스트를 사용했고, 실제 응답 헤더·서버의 GET 수·실행 표식·CSP 위반 이벤트를 함께 수집했다.

비교에 쓴 정책 A의 허용 목록은 'self'와 48798이며, B의 목록은 'self'와 48799다. 넓힌 정책 W는 'self'와 두 포트를 모두 허용한다. 포트 표기는 위 헤더의 HTTP 출처를 줄여 쓴 것이며 모든 주소의 호스트는 127.0.0.1이다. 보고를 받는 report-uri /csp-report도 붙였지만, 허용 여부를 바꾼 부분은 script-src다.

다음은 실제 결과를 묶은 것이다. GET 수는 각 조건의 a.js, b.js를 서버가 받은 횟수다. common.js는 모든 조건에서 한 번 요청되고 실행됐다.

문서에 보낸 정책                       a.js GET  b.js GET  실행된 파일
A만                                    1         0         common,a
B만                                    0         1         common,b
A 다음 B                               0         0         common
B 다음 A                               0         0         common
A 다음 W                               1         0         common,a
W만                                    1         1         common,a,b
W 다음 W                               1         1         common,a,b
강제 W + Report-Only A                  1         1         common,a,b
강제 A + Report-Only W                  1         0         common,a

A와 B를 함께 보내면 두 정책의 공통 허용 범위인 문서 자체 출처의 파일만 실행됐다. 순서를 뒤집어도 결과는 같았다. A만으로는 a.js가, B만으로는 b.js가 실행되므로 어느 한 정책 전체를 골라 적용한다고 설명할 수도 없다. 이 출처 목록 비교에서 최종 허용 범위는 각 강제 정책이 허용한 범위의 교집합이었다. CSP Level 3의 다중 정책 예시 (새 탭에서 열림)

사용자 증상에 해당하는 것은 A 다음 W다. 뒤의 정책을 넓혔지만 A가 남아 있어 b.js는 여전히 막혔다. 반면 두 강제 정책을 모두 W로 맞춘 조건에서는 세 파일이 실행됐다. 여러 정책을 유지하면서도 필요한 출처를 허용할 수 있음을 확인한 비교다.

Report-Only 대조에서는 경고와 차단이 갈렸다. 강제 W에 Report-Only A를 붙이자 b.js가 실행되는 동시에 위반 이벤트와 로컬 보고에 disposition: "report"가 남았다. 강제 A에 Report-Only W를 붙인 조건에서는 b.js가 차단됐고 disposition: "enforce"가 남았다. Report-Only 정책은 위반을 관찰하며, 기존 강제 정책의 차단을 풀어 주지 않는다. MDN의 Report-Only 설명 (새 탭에서 열림)

위반 이벤트의 originalPolicy도 차단 주체를 좁히는 데 도움이 됐다. A와 B를 함께 보낸 조건에서 a.js 위반에는 B의 문자열이, b.js 위반에는 A의 문자열이 기록됐다. 이 값은 위반한 정책을 가리킬 뿐 프록시나 앱의 이름까지 알려 주지는 않는다. 실제 설정의 어느 값과 일치하는지 연결해야 한다. MDN의 SecurityPolicyViolationEvent 속성 (새 탭에서 열림)

헤더를 만드는 위치와 수정할 책임을 맞춘다

프록시의 헤더 추가 규칙, 앱의 보안 필터·미들웨어, 별도 응답 헤더 설정에서 관찰한 정책 문자열을 찾는다. 원본 앱 응답의 정책과 최종 응답에 실린 정책이 같다고 가정하지 않는다. 설정에 적힌 허용 목록과 브라우저가 받은 값이 다르면 먼저 어느 계층에서 값이 추가되거나 바뀌는지 확인한다.

정책을 만드는 책임을 한곳으로 모을지, 여러 계층이 각각 제한할지는 구성에 따라 정한다. 여러 강제 정책을 유지한다면 그 자원을 제한하는 모든 정책에서 필요한 허용 조건을 충족해야 한다. 한쪽에 허용 헤더를 추가하는 대신, 실제로 차단한 정책의 기존 지시문을 담당하는 위치에서 수정한다. 다른 지시문이 보호하는 범위를 보존하고, 이 실험의 짧은 헤더를 서비스의 전체 CSP로 복사하지 않는다.

수정 후에는 새 문서 응답에 의도한 정책들이 실렸는지 다시 확인한다. 이어 같은 JS의 네트워크 결과와 실행 여부를 함께 본다. 여기서는 두 강제 정책을 모두 수정했을 때 GET과 실행 표식이 함께 나타났다. 자기 페이지에서 파일이 200인데 실행 표식이 없다면 CSP 오류뿐 아니라 문법 오류와 실행 중 예외도 확인해야 한다.

직접 비교한 환경은 2026년 10월 22일 KST의 Windows 11, Python 3.12.10, Playwright 1.59.0, headless Chromium 147.0.7727.15다. 참조한 CSP Level 3 문서는 2026년 9월 16일자 W3C 작업 초안이다. CSP 우회 없이 새 브라우저에서 일반 외부 script 요소와 출처 목록을 비교했으며 서버와 브라우저는 종료했다. 실제 프록시의 헤더 생성·병합 방식, HTTP/2 전달, 다른 브라우저 엔진, nonce·hash·strict-dynamic, 모듈의 후속 로딩은 검증하지 않았다. 여러 정책을 동시에 적용한다는 규칙과 별개로, 이런 구성의 허용 조건은 각각 다시 확인해야 한다.

← 전체 글 보기