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

빌드노트

CSP를 켠 뒤 초기화 스크립트만 실행되지 않는다

CSP 적용 뒤 inline 초기화만 멈춘다면 같은 HTML 응답의 정책과 script nonce를 비교한다. 실제 브라우저에서 누락·불일치·일치, 응답별 값 변화와 script-src-elem·Report-Only 차이를 확인하고 getAttribute의 빈 문자열을 누락으로 오인하지 않는 방법을 설명한다.

CSP를 적용한 뒤 HTML의 제목과 본문은 보이는데 초기화 스크립트가 채우는 부분만 비어 있다면, 그 HTML 응답의 정책과 초기화 <script>의 nonce를 함께 확인한다. nonce로 inline script를 허용하는 정책에서는 헤더에 적힌 값과 해당 요소의 값이 맞아야 한다. 서버가 HTML을 200으로 보냈다는 사실만으로 스크립트 실행까지 성공한 것은 아니다.

아래는 실제 서비스 장애 기록이 아니라, 전용 로컬 서버에서 같은 초기화 코드를 비교한 실험이다. 운영 정책은 변경하지 않았다.

nonce가 없거나 다르면 HTML만 남았다

페이지에는 pending이라고 적힌 문단과 이를 ready로 바꾸는 inline script를 넣었다. 먼저 로드하는 관찰용 외부 스크립트는 위반 이벤트를 수집한다. 정책의 script-src에는 'self'와 응답별 nonce를 넣었다. 이 조건에서 같은 출처의 관찰용 파일은 실행됐지만, 'self'만으로 inline 코드까지 허용되지는 않았다.

비교한 초기화 코드는 다음과 같다. nonce는 일치 조건에서 실제 받은 값이다. 재사용할 설정값은 아니다.

<p id="marker">pending</p>
<script id="init" nonce="rrQg95mh6Z1gM7E6F2G5bQ==">
document.querySelector('#marker').textContent = 'ready';
</script>

nonce 속성을 빼거나, 헤더와 다른 난수를 넣거나, 같은 값을 넣었다. 세 조건 모두 HTML 응답은 200이었고 정적 문구 HTML loaded도 보였다. 다음은 DOM의 문단 내용과 securitypolicyviolation 이벤트에서 필요한 값만 추린 결과다.

조건       문단 내용   effectiveDirective  blockedURI  disposition
nonce 없음 pending     script-src-elem     inline      enforce
값 불일치  pending     script-src-elem     inline      enforce
값 일치    ready       위반 이벤트 없음

누락과 불일치는 각각 위반 이벤트가 한 번 발생했다. 콘솔에도 inline script 실행이 CSP를 위반해 차단됐다는 오류가 남았다. 일치 조건은 초기화 문구가 바뀌고 위반 이벤트는 없었다. 브라우저가 받은 응답 헤더와 원문 HTML은 서버가 보낸 기록 및 브라우저 네트워크 기록과 대조했다.

CSP Level 3의 요소 일치 절차 (새 탭에서 열림)는 nonce 값이 정책의 nonce 값과 같은지 검사한다. 이 실험의 정책에는 별도의 script hash 허용이 없었다. 관찰 결과는 nonce 누락·불일치에 따른 차단과 일치한다.

콘솔을 읽은 뒤 같은 문서 응답을 고른다

자기 앱에서는 개발자 도구를 열고 Network 기록을 켠 상태로 문제 페이지를 새로 연다. 먼저 Console에서 inline script 차단 오류의 위치와 적용 지시어를 읽는다. 이 실험의 위반 이벤트는 script-src-elem을 가리켰지만, 세 조건의 응답 헤더에는 script-src만 있었다.

이는 헤더가 누락됐다는 뜻이 아니다. <script> 요소에는 script-src-elem이 우선 적용되고, 없으면 script-src, 그것도 없으면 default-src를 따른다. 따라서 콘솔의 이름을 헤더에서 찾는 것에 그치지 말고 실제로 적용된 정책까지 읽어야 한다. script-src-elem의 적용 범위와 대체 지시어 (새 탭에서 열림)

  1. Network에서 현재 페이지의 HTML 문서 요청을 고른다. JS 파일 응답이 아니라 그 문서를 보호하는 정책을 확인하려는 것이다.
  2. Headers의 Response Headers에서 Content-Security-Policy를 읽고, 같은 요청의 Response에서 초기화 <script>를 찾는다. 헤더의 'nonce-값'과 HTML의 nonce="값"을 비교한다. HTML 속성에는 nonce- 접두어를 붙이지 않는다. Chrome의 헤더·응답 본문 확인 방법 (새 탭에서 열림)
  3. 값을 확인하려고 페이지를 다시 요청했다면 그 새 응답끼리 비교한다. 이전 요청의 헤더와 새 요청의 HTML을 섞지 않는다.

일치 조건의 같은 응답에서 필요한 부분만 발췌하면 다음과 같다.

응답 헤더의 script-src:
script-src 'self' 'nonce-rrQg95mh6Z1gM7E6F2G5bQ=='

응답 HTML의 초기화 요소:
<script id="init" nonce="rrQg95mh6Z1gM7E6F2G5bQ==">

Elements 화면이나 getAttribute()만 보고 nonce가 빠졌다고 결론 내리지도 않는다. 문제 페이지의 Console에서 아래 입력으로 해당 요소의 .nonce를 읽을 수 있다. #init은 자신의 초기화 스크립트를 가리키는 선택자로 바꾼다.

document.querySelector('#init')?.nonce

위 일치 페이지에서는 "rrQg95mh6Z1gM7E6F2G5bQ=="가 나왔다. 반면 같은 요소에 document.querySelector('#init')?.getAttribute('nonce')를 실행한 결과는 ""였다. nonce 속성 숨김 설명 (새 탭에서 열림)처럼 브라우저가 내용 속성값을 숨기기 때문이다. 요소가 없으면 위 선택자의 결과는 undefined다. 먼저 올바른 문서·프레임과 요소를 선택했는지 확인한다.

한 응답에서 한 번 만들고 헤더와 템플릿에 함께 넣는다

누락이나 불일치라면 응답을 만드는 코드에서 nonce의 생성 위치를 찾는다. 헤더 필터와 템플릿이 각각 새 난수를 만들면 같은 응답 안에서도 값이 달라진다. 요청을 처리하면서 한 번 만든 값을, 허용할 초기화 요소와 그 응답의 정책에 함께 전달한다. 모든 문자열을 만든 다음 HTML 전체의 script 태그에 nonce를 일괄 주입하는 방식은 피한다. 신뢰할 초기화 코드의 템플릿 위치에 명시한다.

실험 서버의 생성 부분은 다음과 같다. Python 3 표준 라이브러리만 쓰는 코드이며, 요청 처리 함수 안에서 호출했다.

import base64
import secrets

nonce = base64.b64encode(secrets.token_bytes(16)).decode('ascii')

여기서 만든 nonce를 정책의 'nonce-…'와 HTML 속성값에 함께 사용했다. Python secrets 문서 (새 탭에서 열림)는 이 API가 보안용 난수 바이트를 생성한다고 설명한다. 샘플 문자열이나 시작할 때 한 번 만든 전역 값을 계속 쓰는 방식으로 바꾸지는 않는다.

CSP 명세의 nonce 재사용 절 (새 탭에서 열림)은 정책을 보낼 때마다 고유한 값을 만들도록 요구하며, 인코딩 전 최소 128비트와 암호학적으로 안전한 난수 생성기를 권고한다. 위 코드는 16바이트, 즉 128비트 난수를 Base64로 인코딩한다.

같은 일치 페이지를 연속 두 번 요청했을 때 실제 값은 다음처럼 바뀌었다. 각 응답 안에서는 헤더와 HTML이 일치했고, 두 페이지 모두 ready가 됐다.

첫 응답: rrQg95mh6Z1gM7E6F2G5bQ==
다음 응답: 8kfAH2KbXZ7/wK8xE/ZX2w==

이 표본은 이번 두 응답이 달랐다는 확인이다. 모든 미래 응답의 고유성을 증명한 것은 아니다. 캐시가 있는 서비스에서는 캐시된 HTML과 새 헤더를 섞는지도 확인할 대상이다. 이번 실험은 Cache-Control: no-store로 실행했으며 CDN이나 프록시의 캐시 동작은 측정하지 않았다.

값이 맞는데도 막히면 적용 정책을 더 읽는다

추가 조건에서는 script-src의 nonce와 HTML을 맞춘 채 script-src-elem 'self'를 따로 넣었다. 결과는 pending과 script-src-elem / inline / enforce였다. <script>에 우선 적용되는 지시어에는 nonce가 없었기 때문이다. 이런 정책이라면 script-src에만 값을 추가해서는 해결되지 않는다. 허용할 초기화 요소가 실제로 적용받는 지시어에 같은 nonce가 전달되는지 확인한다.

반대로 nonce 없는 페이지에서 헤더를 Content-Security-Policy-Report-Only로만 보낸 조건은 위반 이벤트가 있어도 ready였다. 이벤트의 disposition은 report였다. 보고용 정책만으로는 실행을 차단하지 않는다. 콘솔에 위반이 보인다는 이유만으로 빈 화면의 원인이라고 단정하지 말고 강제 정책과 구별한다. CSP의 Report-Only 헤더 정의 (새 탭에서 열림)

값이 맞고 적용 지시어도 맞는다면 다른 강제 CSP 헤더나 HTML의 CSP meta 정책이 함께 있는지 살핀다. 여러 정책은 각각 적용되므로 허용 정책 하나를 더 붙여 기존 차단을 풀 수는 없다. 이는 명세의 다중 정책 설명 (새 탭에서 열림)에 따른 다음 조사 항목이며, 이번 브라우저 비교에서 다중 정책까지 재현한 것은 아니다. nonce 일치 뒤 CSP 차단이 사라졌는데도 화면이 비어 있다면, 이어 발생하는 자바스크립트 예외와 초기화 함수의 호출 여부를 확인한다.

직접 확인한 환경은 Windows 11, Python 3.12.10, Playwright 1.59.0, Chromium 147.0.7727.15다. 새 브라우저에서 loopback HTTP 문서의 일반 inline <script>를 비교했다. 다른 브라우저 엔진, 운영 템플릿·프록시, onclick 같은 이벤트 속성, 동적 스크립트 로더는 검증하지 않았다. nonce 허용을 이벤트 속성에 그대로 적용하는 설명으로 넓히지 않는다. 실행이 막힌 요소와 같은 응답의 정책을 맞춰 보는 것이 이 비교의 판단 범위다.

← 전체 글 보기