웹 보안, 신뢰 경계부터 CSP까지

CSP를 적용하면서 헤더 설정과 애플리케이션 코드의 문제를 함께 검토했습니다. 허용할 도메인을 정하는 것만으로는 판단이 끝나지 않았습니다. HTML을 넣는 코드가 왜 필요한지, 그 HTML을 누가 만들 수 있는지부터 확인해야 했습니다.
브라우저가 주입된 스크립트를 차단하면 당장의 실행은 막을 수 있습니다. 하지만 화면에 임의의 HTML을 넣을 수 있는 경로는 그대로 남아 있을 수 있습니다.
코드에서 고칠 문제와 CSP에 맡길 방어는 어떻게 구분할까요?
이 글에서 다루는 내용
프론트엔드 코드를 검토하고 CSP를 도입할 때 필요한 판단 기준을 정리합니다. 브라우저의 출처 구분에서 시작해 입력이 실행 가능한 내용으로 바뀌는 과정을 살펴보고, 그 경로를 코드와 브라우저 정책으로 각각 제한합니다.
CSP에서는 지시어를 읽는 방법과 nonce·hash를 선택하는 기준, Report-Only에서 실제 차단으로 전환하는 절차를 다룹니다. 마지막에는 CSP만으로 해결되지 않는 CSRF와 창 간 통신을 구분하고 코드 리뷰에 사용할 질문을 남깁니다.
- 브라우저가 구분하는 출처와 권한
- 입력이 코드가 되는 순간: XSS
- 값을 넣는 위치에 맞는 처리
- CSP 정책을 읽는 방법
- 실행할 스크립트를 고르는 nonce와 hash
- Report-Only에서 실제 차단까지
- CSP와 별도로 확인할 신뢰 경계
- 코드 리뷰에서 확인할 순서
브라우저가 구분하는 출처와 권한
출처를 나누는 세 요소
웹에서 출처(origin)는 스킴(scheme), 호스트(host), 포트(port)의 조합입니다. https://app.example.com을 기준으로 보면 경로가 다른 /settings는 같은 출처이고, 호스트가 다른 https://api.example.com은 다른 출처입니다. HTTP로 바뀌거나 포트가 8443으로 바뀌어도 출처가 달라집니다.
브라우저의 동일 출처 정책(Same-Origin Policy, SOP)은 서로 다른 출처 사이의 데이터 읽기와 DOM 접근을 제한합니다. 사용자가 메일에 로그인한 상태로 다른 사이트를 열어도 그 사이트의 JavaScript가 메일함을 마음대로 읽을 수 없는 이유입니다.
그렇다고 다른 출처와의 모든 상호작용이 금지되는 것은 아닙니다. 외부 이미지를 표시하거나 폼을 제출하는 일은 가능한 경우가 많습니다. 요청을 보낼 수 있는지와 응답을 읽을 수 있는지를 나누어 봐야 합니다.
CORS 오류가 있어도 처리될 수 있는 요청
교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 서버가 특정 출처의 JavaScript에 응답을 공유하도록 허용하는 프로토콜입니다. 예를 들어 API 서버가 다음 헤더를 보내면 지정한 웹앱에 쿠키를 포함한 요청의 응답을 공유할 수 있습니다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
교차 출처 fetch()에서 쿠키를 보내려면 클라이언트도 credentials: "include"를 지정해야 합니다. 위 응답 헤더가 쿠키 전송을 자동으로 켜 주지는 않으며, SameSite와 브라우저의 쿠키 제한도 그대로 적용됩니다.
여러 출처를 허용하는 서버라면 요청의 Origin을 허용 목록과 비교한 뒤 응답에 넣습니다. 임의의 Origin을 그대로 반사하면 공격자 출처에도 응답을 공유하게 됩니다. 출처에 따라 헤더를 바꿀 때는 캐시가 다른 출처의 응답을 재사용하지 않도록 Vary: Origin도 필요합니다.
CORS의 동작은 요청 형태에 따라 나뉩니다.
| 요청 형태 | 브라우저의 처리 | 서버에서 확인할 점 |
|---|---|---|
| CORS의 단순 요청 조건을 만족하는 요청 | 본 요청을 보내고 응답 공유 여부를 검사 | 응답을 못 읽어도 서버 처리는 끝났을 수 있음 |
| 허용 목록 밖의 메서드·헤더 등을 사용하는 요청 | 사전 요청인 preflight OPTIONS로 허가를 확인한 뒤 본 요청 전송 | preflight가 실패하면 본 요청은 보내지 않음 |
따라서 개발자 도구의 CORS 오류만 보고 “서버에 요청이 도착하지 않았다”고 판단하면 안 됩니다. 서버가 요청 자체를 거부해야 하는 조건은 인증·인가와 CSRF 방어로 검사해야 합니다. 요청 조건의 정확한 범위는 MDN의 CORS 문서에 정리되어 있습니다.
외부 스크립트는 누구의 권한으로 실행될까요?
다음 코드는 다른 출처에서 JavaScript를 가져옵니다.
<script src="https://cdn.example.net/analytics.js"></script>
이 스크립트는 불러온 페이지의 실행 환경에서 동작합니다. CDN에서 왔다는 이유로 페이지의 DOM이나 저장소에 대한 접근이 별도로 격리되지는 않습니다. 분석 도구나 태그 매니저를 추가하는 일은 그 코드에 페이지 안에서 행동할 권한을 주는 일이기도 합니다.
여기서 신뢰 경계(trust boundary)를 생각할 수 있습니다. 서비스가 직접 관리하는 코드와 외부에서 바뀔 수 있는 코드 사이에는 검토해야 할 경계가 있습니다. 사용자 입력을 HTML로 해석할 때도 비슷한 문제가 생깁니다. 누가 내용을 정할 수 있는지와 그 내용이 어떤 권한으로 사용되는지를 함께 확인해야 합니다.
Recap
브라우저는 출처를 기준으로 데이터 읽기와 DOM 접근을 제한합니다. CORS는 응답을 공유하는 조건을 정하므로 요청 처리 여부와 구분해야 합니다. 외부에서 가져온 스크립트도 페이지의 권한으로 실행되기 때문에 실행을 허용할 코드를 고르는 일이 필요합니다.
입력이 코드가 되는 순간: XSS
댓글을 화면에 보여주는 두 코드를 비교해 보겠습니다.
function HtmlComment({ body }: { body: string }) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
function TextComment({ body }: { body: string }) {
return <div>{body}</div>;
}
body에 <img src="invalid:" onerror="alert(1)">가 들어왔다고 가정합니다. 별도의 차단 정책이 없다면 첫 번째 코드는 이미지 요소를 만들고 이미지 로딩 실패 시 이벤트 핸들러를 실행할 수 있습니다. 두 번째 코드는 문자열을 텍스트로 표시합니다.
같은 입력이지만 브라우저에 해석을 맡기는 방식이 다릅니다. 교차 사이트 스크립팅(Cross-Site Scripting, XSS)은 공격자가 넣은 내용이 페이지에서 실행되어 그 페이지의 권한으로 행동하는 취약점입니다. <script> 태그 외에도 이벤트 핸들러나 javascript: URL 등이 실행 경로가 될 수 있습니다.
XSS 코드가 실행되면 화면 내용을 읽거나 바꾸고 사용자 권한으로 API를 호출할 수 있습니다. 세션 쿠키에 HttpOnly가 있어도 쿠키를 직접 읽는 동작만 막습니다. 브라우저가 쿠키를 붙여 보내는 인증된 요청까지 막아주지는 않습니다.
입력부터 사용 지점까지
검토할 때는 두 지점을 찾으면 됩니다. 소스(source)는 값이 들어오는 곳이고, 싱크(sink)는 그 값으로 민감한 동작을 수행하는 곳입니다. URL 파라미터가 소스라면 innerHTML은 문자열을 HTML로 해석하는 싱크입니다.
API 응답이나 DB에 저장된 값도 원래 내용을 사용자가 정할 수 있다면 검토 대상입니다. 서버를 한 번 거쳤다는 사실만으로 신뢰할 수 있는 값이 되지는 않습니다.
반대로 innerHTML이 검색됐다고 곧바로 XSS로 판정할 수도 없습니다. 고정된 마크업만 넣는지, 공격자가 내용을 바꿀 수 있는지, 중간에 어떤 처리를 하는지 따라가야 합니다. DOMParser처럼 파싱한 결과를 나중에 활성 문서에 붙이는 API는 그 다음 사용 지점까지 확인합니다.
저장형 XSS는 저장된 입력이 다른 사용자에게 전달되는 경우이고, 반사형 XSS는 요청의 입력이 응답에 실리는 경우입니다. DOM 기반 XSS는 클라이언트 코드가 입력을 위험한 DOM API에 전달하는 경로를 가리킵니다. 서버에 저장된 댓글이 innerHTML로 들어갈 수 있으므로 이 분류들은 서로 겹칩니다.
HTML이 필요한 경우의 처리
댓글에 서식이 필요 없다면 일반 텍스트 렌더링을 사용합니다. 서식 있는 HTML이 제품 요구사항이라면 허용할 태그·속성·URL을 정하고, 검토된 라이브러리로 위험한 내용을 제거하는 정제(sanitization)를 거칩니다.
아래는 브라우저에서 DOMPurify를 사용하는 예시입니다.
import DOMPurify from "dompurify";
function RichComment({ html }: { html: string }) {
const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li"],
ALLOWED_ATTR: [],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}
이 댓글에는 텍스트 서식과 목록만 허용합니다. data-*와 aria-* 속성은 별도의 기본 허용 옵션이 있어 함께 비활성화합니다. 링크나 이미지가 필요해지면 태그뿐 아니라 URL 정책도 함께 검토합니다. sanitize()를 호출했다는 사실보다 서비스가 허용하려는 내용과 실제 출력이 일치하는지가 판단 기준입니다.
마크다운에서 원문 HTML을 허용하는 경우에도 순서를 확인해야 합니다. rehype-raw로 HTML을 파싱한 뒤 rehype-sanitize로 정제하고 렌더링합니다. 정제 이후에 검증하지 않은 문자열을 다시 이어 붙이거나 다른 플러그인이 실행 가능한 속성을 추가하면 앞의 방어가 무효화될 수 있습니다. DOMPurify와 rehype-sanitize는 이런 후처리와 플러그인 순서의 위험을 설명합니다.
Recap
XSS를 찾으려면 공격자가 정할 수 있는 값이 실행 가능한 내용으로 해석되는 경로를 확인해야 합니다. 텍스트는 텍스트로 표시하고 HTML이 필요할 때는 허용 범위를 정해 정제합니다. 위험한 API의 이름만 보거나 정제 함수의 존재만 확인해서는 실제 경로의 안전성을 판단하기 어렵습니다.
값을 넣는 위치에 맞는 처리
HTML에 안전하게 표시한 문자열이 URL이나 JavaScript 소스에서도 안전한 것은 아닙니다. 이스케이프(escaping)는 문자를 해당 문맥에서 데이터로 처리하도록 바꾸는 일입니다. URL의 목적지가 허용되는지 같은 의미상의 검증은 별도로 남습니다.
OWASP의 XSS 예방 가이드도 값을 넣는 문맥에 따라 출력 인코딩과 안전한 API를 구분합니다.
| 값을 사용하는 곳 | 먼저 선택할 방법 | 남는 검토 |
|---|---|---|
| 화면의 일반 텍스트 | JSX의 문자열 렌더링, textContent | HTML 파서로 우회하는 경로가 있는지 |
| 서식 있는 HTML | 허용 정책을 적용한 sanitizer | 정제 이후 내용이 다시 변하는지 |
| 링크·리다이렉트 URL | URL로 파싱한 뒤 허용 조건 검사 | 스킴·출처·경로 중 무엇을 허용할지 |
| 실행할 JavaScript | 입력을 소스 문자열에 이어 붙이지 않고 데이터로 전달 | eval()·new Function() 같은 실행 경로가 있는지 |
| 서버가 HTML에 넣는 JSON | JSON 직렬화 후 HTML 파서에서 문제가 되는 문자 치환 | </script>로 요소를 닫을 수 있는지 |
URL 인코딩과 목적지 검증
encodeURIComponent()는 문자열을 URL 구성 요소에 넣기 위한 인코딩입니다. 이동할 주소를 신뢰할 수 있는지 판정하지는 않습니다. 로그인 이후의 next 경로라면 브라우저와 같은 URL 파서로 해석한 결과를 검사할 수 있습니다.
function safeNext(raw: string | null, origin: string): string {
if (!raw) return "/";
try {
const url = new URL(raw, origin);
if (url.protocol !== "https:" && url.protocol !== "http:") return "/";
if (url.origin !== origin) return "/";
// 검증한 출처를 유지한 절대 URL을 반환한다.
return url.href;
} catch {
return "/";
}
}
const next = new URLSearchParams(location.search).get("next");
location.assign(safeNext(next, location.origin));
이 함수는 HTTP·HTTPS 페이지에서 현재 출처 안으로 이동하는 경우를 허용합니다. 같은 출처의 절대 URL도 허용하며 외부 출처와 javascript:·blob: URL은 거부합니다.
검증 후 경로만 떼어 반환하지 않는 데에도 이유가 있습니다. https://app.example.com//evil.example은 같은 출처의 URL이지만, pathname인 //evil.example만 location.assign()에 전달하면 외부 호스트로 이동합니다. 검증한 URL과 실제 탐색할 URL이 같아야 합니다. 파싱한 URL 전체를 직렬화하는 href를 사용하면 이 재해석을 피할 수 있습니다. URL의 파싱·직렬화 동작은 WHATWG URL 표준에 정의되어 있습니다.
서비스에서 몇 개 화면으로만 복귀해야 한다면 경로 허용 목록까지 좁힐 수 있습니다. 같은 출처에 외부로 다시 보내는 리다이렉트 기능이 있다면 그 최종 목적지도 따로 검토해야 합니다.
JSON-LD에서 JSON.stringify()만으로 부족한 이유
서버가 다음 JSON을 <script type="application/ld+json"> 안에 넣는다고 가정합니다.
const data = {
name: '</script><img src="invalid:" onerror="alert(1)">',
};
JSON.stringify(data)는 유효한 JSON을 만듭니다. 하지만 브라우저는 먼저 응답을 HTML로 파싱합니다. 문자열 안의 </script>를 만나면 스크립트 요소를 닫고 그 뒤를 HTML로 해석합니다. application/ld+json 자체가 JavaScript로 실행되지 않아도 HTML 요소 밖으로 빠져나갈 수 있는 것입니다.
이 경우에는 직렬화한 결과에서 <를 \u003c로 치환하면 닫는 태그가 만들어지지 않습니다. JSON을 파싱하면 원래 값은 복원됩니다. Next.js의 JSON-LD 가이드도 같은 처리를 제시합니다.
function serializeJsonForHtml(data) {
// JSON으로 표현 가능한 데이터를 입력받는다.
return JSON.stringify(data).replace(/</g, "\\u003c");
}
const serialized = serializeJsonForHtml(data);
console.assert(!serialized.includes("<"));
console.assert(JSON.parse(serialized).name === data.name);
입력의 <를 먼저 문자 그대로의 \u003c로 바꾸고 직렬화하면 백슬래시가 다시 이스케이프되어 원래 값과 달라질 수 있습니다. 그래서 직렬화 이후에 치환합니다. 이 함수는 JSON을 HTML의 스크립트 요소에 넣기 위한 것으로, 일반 HTML이나 URL을 정제하는 함수로 재사용하지 않습니다.
Trusted Types로 누락된 정제 경로 찾기
코드가 늘어나면 어떤 렌더 경로에서는 정제를 빠뜨릴 수 있습니다. Trusted Types는 지원 브라우저에서 특정 DOM 주입 API가 일반 문자열을 거부하도록 강제하는 기능입니다. CSP의 require-trusted-types-for 'script'를 사용하면 innerHTML 같은 싱크에 값을 넣을 때 적절한 Trusted Type이 필요합니다.
여기서 script는 <script> 요소만 가리키지 않습니다. HTML 주입을 포함해 스크립트 실행으로 이어질 수 있는 DOM 싱크를 대상으로 합니다.
| 장치 | 하는 일 |
|---|---|
require-trusted-types-for'script' | 대상 싱크에 일반 문자열을 넣는 동작 제한 |
trusted-types | 애플리케이션이 생성할 수 있는 정책 이름 제한 |
정책의 createHTML() 등 | 입력을 검증·정제해 타입을 생성하는 처리 정의 |
Trusted Types가 HTML을 직접 정제해 주지는 않습니다. 정책 함수가 입력을 그대로 반환하면 위험한 내용을 통과시킬 수 있습니다. DOMPurify를 정책 안에서 쓴다면 반환 타입뿐 아니라 라이브러리가 내부적으로 만드는 정책 이름도 CSP와 맞춰야 합니다. 구체적인 설정은 DOMPurify의 Trusted Types 안내를 기준으로 확인합니다.
도입 시에는 사용하는 프레임워크·라이브러리의 DOM 쓰기 경로를 함께 점검합니다. 지원하지 않는 브라우저에서도 텍스트 렌더링과 정제는 계속 수행해야 합니다. 지원 범위와 강제 방식은 Trusted Types API 문서에서 확인할 수 있습니다.
Recap
출력 위치가 달라지면 필요한 처리도 달라집니다. URL은 목적지를 검증하고, HTML 안에 넣는 JSON은 직렬화 후 <를 치환합니다. Trusted Types는 정해진 처리 경로를 거치도록 강제하지만 그 처리 자체의 안전성은 애플리케이션이 책임집니다.
CSP 정책을 읽는 방법
콘텐츠 보안 정책(Content Security Policy, CSP)은 문서가 불러올 리소스와 스크립트 실행 등을 브라우저가 제한하도록 선언하는 정책입니다. 일반적으로 HTML 응답의 Content-Security-Policy 헤더로 전달합니다.
XSS 방어에서 CSP가 유용한 이유는 입력 처리에 실수가 남아도 스크립트 실행을 한 번 더 제한할 수 있기 때문입니다. 앞의 댓글 예제에서 이벤트 핸들러 실행을 차단하더라도 공격자가 넣은 이미지나 문구까지 사라지는 것은 아닙니다. CSP를 적용한 뒤에도 HTML 주입 경로는 수정해야 합니다.
지시어와 허용 값
다음은 문법을 읽기 위한 예시입니다.
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' https://images.example.net; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
세미콜론으로 구분한 각 항목이 지시어(directive)입니다. img-src는 이미지 로드를 제어하고 그 뒤의 값은 허용할 대상을 정합니다. 'self'는 문서와 같은 출처, 'none'은 해당 지시어의 대상을 모두 차단한다는 뜻입니다. 'none'은 다른 허용 값과 섞지 않고 단독으로 씁니다.
| 지시어 | 제한하는 대상 | 예시 정책에서의 결과 |
|---|---|---|
default-src | 별도 설정이 없는 리소스 로드의 기본값 | 같은 출처 허용 |
script-src | 스크립트 로드와 실행 | 같은 출처의 외부 스크립트 허용, 인라인 실행 제한 |
img-src | 이미지 로드 | 같은 출처와 지정한 이미지 서버 허용 |
object-src | <object>, <embed> 로드 | 차단 |
base-uri | 문서의 <base> URL | <base>로 기준 URL 변경 차단 |
frame-ancestors | 이 문서를 프레임으로 포함할 수 있는 상위 문서 | 다른 문서의 프레임 안에 표시되는 것 차단 |
form-action | 이 문서의 폼 제출 대상 | 같은 출처 허용 |
API 연결은 connect-src, 스타일시트는 style-src, 글꼴은 font-src로 지정합니다. frame-src는 내가 불러올 프레임을 정하므로 나를 포함할 상위 문서를 정하는 frame-ancestors와 방향이 다릅니다.
default-src가 대신 정해 주지 않는 것
default-src는 생략한 리소스 로드 지시어의 기본값입니다. 다른 지시어에 값을 덧붙이는 방식으로 동작하지는 않습니다.
Content-Security-Policy: default-src 'self'; img-src https://images.example.net
이 정책에서 이미지는 지정한 이미지 서버에서만 불러올 수 있습니다. 같은 출처의 이미지도 필요하면 img-src에 'self'를 직접 넣어야 합니다.
또한 base-uri, form-action, frame-ancestors는 default-src를 물려받지 않습니다. default-src 'none'만으로 폼 제출과 프레임 포함까지 막았다고 해석하면 안 됩니다. 리소스별 대체 규칙은 MDN의 default-src 문서를 참고할 수 있습니다.
허용 범위를 넓힐 때의 의미
script-src 'self'는 같은 출처의 스크립트를 허용합니다. 그 출처에서 공격자가 JavaScript 파일을 제공할 수 있다면 해당 파일도 허용될 수 있습니다. 사용자 업로드 파일이나 코드를 반환하는 엔드포인트를 함께 확인해야 하는 이유입니다.
https:는 HTTPS를 사용하는 모든 출처를 허용하고, *.example.com은 여러 서브도메인을 허용합니다. 차단 오류를 없애려고 범위를 넓히면 실행을 허용하는 코드도 늘어납니다.
특히 스크립트 정책에서 다음 두 값은 용도가 다릅니다.
'unsafe-inline'은 인라인 스크립트와 이벤트 핸들러 등을 폭넓게 허용합니다. nonce·hash를 지원하는 브라우저는 같은 지시어에 nonce나 hash가 있으면'unsafe-inline'을 무시합니다. 이 값이 실제로 적용되는 정책에서는 인라인 주입 방어가 약해집니다.'unsafe-eval'은eval(),new Function()처럼 문자열을 코드로 실행하는 경로를 허용합니다. 인라인 스크립트 문제를 해결하기 위해 추가할 값이 아닙니다.
스타일의 'unsafe-inline'도 스크립트와 구분해 검토합니다. 런타임에 스타일을 삽입하는 라이브러리가 있다면 nonce 지원이나 빌드 시 CSS 추출 가능 여부를 확인합니다. 스타일 때문에 생긴 오류를 해결하려고 script-src까지 넓힐 이유는 없습니다.
Recap
CSP는 지시어마다 제한할 동작과 허용 범위를 정합니다. default-src는 생략된 리소스 정책의 기본값이며 모든 지시어에 적용되지는 않습니다. 정책을 읽을 때는 헤더의 존재보다 실제로 허용되는 출처와 실행 방식을 확인해야 합니다.
실행할 스크립트를 고르는 nonce와 hash
출처만으로 스크립트를 허용하는 대신 서버가 선택한 요소나 정해진 내용에 실행을 허용할 수 있습니다. nonce는 HTML 응답을 만들 때 지정하고, hash는 실행할 스크립트의 내용으로 계산합니다.
응답마다 새로 만드는 nonce
nonce는 공격자가 예측하기 어려운 난수입니다. 서버가 CSP 헤더의 'nonce-값'과 실행할 <script> 요소의 nonce="값"에 같은 값을 넣으면 브라우저가 이를 대조합니다. 인라인 스크립트와 src가 있는 스크립트 요소 모두에 사용할 수 있습니다.
다음 코드를 server.mjs로 저장하고 node server.mjs로 실행하면 http://localhost:8080에서 동작을 확인할 수 있습니다.
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
createServer((_req, res) => {
const nonce = randomBytes(16).toString("base64");
const policy = [
"default-src 'none'",
`script-src 'nonce-${nonce}'`,
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"form-action 'none'",
].join("; ");
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": policy,
// 예제에서는 HTML과 nonce를 캐시로 재사용하지 않는다.
"Cache-Control": "no-store",
});
res.end(`<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>CSP 예제</title></head>
<body>
<p id="status">실행 전</p>
<script nonce="${nonce}">
document.getElementById("status").textContent = "허용된 스크립트 실행";
</script>
<script>
document.getElementById("status").textContent = "허용하지 않은 스크립트 실행";
</script>
</body>
</html>`);
}).listen(8080, "127.0.0.1");
페이지에는 “허용된 스크립트 실행”이 표시되고 두 번째 스크립트는 CSP 위반으로 차단됩니다. 개발자 도구의 콘솔에서 차단 메시지를 확인할 수 있습니다.
CSP 규격은 정책을 전송할 때마다 새 nonce를 생성하도록 요구하며, 최소 128비트의 암호학적으로 안전한 난수를 권고합니다. 예제의 randomBytes(16)이 그 크기입니다. 고정값을 설정 파일에 넣거나 빌드마다 한 번만 생성해 여러 응답에서 재사용하는 방식은 피합니다.
헤더와 HTML을 만드는 곳도 맞아야 합니다. CDN이 헤더의 nonce만 바꾸고 HTML에는 이전 값이 남으면 정상 스크립트가 차단됩니다. 반대로 헤더와 HTML을 통째로 캐시해 같은 nonce를 계속 보내면 응답마다 새 값을 만드는 조건이 깨집니다. 전체 HTML을 캐시해야 한다면 아래의 hash 방식도 검토할 수 있습니다.
nonce는 서버가 실행을 허용한 요소에만 붙입니다. 완성된 HTML의 모든 <script>에 일괄 부착하면 공격자가 주입한 요소까지 허용할 수 있습니다. 허용한 스크립트 내부에 사용자 입력을 JavaScript 소스로 이어 붙이는 것도 여전히 위험합니다.
내용이 고정된 스크립트의 hash
정적 HTML의 인라인 스크립트는 빌드 시 내용의 SHA-256·384·512 해시를 계산해 정책에 넣을 수 있습니다. 예를 들어 다음 한 줄을 허용한다고 가정합니다.
<script>document.documentElement.dataset.ready = "true";</script>
해시는 <script> 태그를 제외한 정확한 내용으로 계산합니다.
import { createHash } from "node:crypto";
const source = 'document.documentElement.dataset.ready = "true";';
const hash = createHash("sha256").update(source, "utf8").digest("base64");
console.log(`script-src 'sha256-${hash}'`);
출력된 지시어를 CSP에 넣으면 브라우저가 인라인 스크립트의 내용을 대조합니다. 공백·개행도 해시에 들어가므로 압축이나 HTML 변환이 끝난 최종 내용으로 계산해야 합니다. 스크립트와 정책을 같은 배포 단위로 갱신하지 않으면 새 버전의 코드가 차단됩니다.
hash 방식은 정적 HTML을 캐시하기에 적합하지만 동적 데이터 때문에 스크립트 내용이 매번 바뀌는 페이지에는 관리 비용이 생깁니다. 외부 스크립트에 hash를 적용할 때는 integrity 속성과 관련 조건도 필요합니다. 인라인 예제를 그대로 옮기기 전에 script-src의 외부 스크립트 해시 규칙을 확인합니다.
동적으로 불러오는 스크립트와 strict-dynamic
번들 로더나 태그 매니저는 실행 중에 스크립트 요소를 추가할 수 있습니다. 'strict-dynamic'을 사용하면 nonce·hash로 허용한 스크립트가 document.createElement("script") 등으로 추가하는 스크립트에도 신뢰를 전파할 수 있습니다.
script-src 'nonce-응답마다-생성한-값' 'strict-dynamic'
위 값은 문법 설명용입니다. 실제 정책에는 앞의 예제처럼 생성한 nonce를 넣습니다.
이때 지원 브라우저는 해당 스크립트 정책의 호스트·스킴 허용 목록과 'self', 'unsafe-inline'을 무시합니다. 따라서 허용 목록을 벗어난 스크립트라도 신뢰한 로더가 동적으로 불러오면 실행될 수 있습니다. HTML 파서가 직접 만드는 스크립트 요소에는 이 동적 로드 규칙이 그대로 적용되지 않습니다.
검토 대상은 로더가 URL을 고르는 코드입니다. URL 파라미터나 메시지로 받은 주소를 그대로 로드하면 공격자가 다음 스크립트를 선택할 수 있습니다. 'strict-dynamic'을 쓸 때는 이 입력 경로와 태그 매니저의 변경 권한까지 확인해야 합니다. web.dev의 strict CSP 가이드가 nonce·hash와 동적 로드를 함께 설명합니다.
Recap
nonce는 응답마다 서버가 선택한 스크립트 요소를 허용하고, hash는 내용이 일치하는 스크립트를 허용합니다. HTML 생성·캐시 방식에 맞게 선택하고 정책과 스크립트를 함께 배포해야 합니다. strict-dynamic은 동적 로드를 허용하는 만큼 로더가 다음 스크립트를 선택하는 과정을 검토해야 합니다.
Report-Only에서 실제 차단까지
서비스에는 초기 화면 외에도 로그인 뒤에만 열리는 화면, 지연 로딩되는 기능, 외부 결제창처럼 특정 조건에서만 사용하는 리소스가 있습니다. CSP를 바로 강제하면 이런 기능이 배포 후에야 깨질 수 있습니다.
저도 CSP를 적용할 때 Report-Only 리포트를 분석하고 실제 사용 흐름의 문제를 먼저 수정한 뒤 차단으로 전환했습니다. 전환과 롤백은 설정 변경으로 처리할 수 있도록 구성했습니다.
차단 없이 후보 정책 관측하기
Content-Security-Policy-Report-Only는 위반을 관측하면서 해당 정책에 따른 차단은 하지 않습니다. 아래 예시는 보고 설정을 보여주기 위해 스크립트를 모두 차단하는 후보 정책을 사용합니다. 운영에 사용할 정책은 서비스의 리소스와 앞에서 선택한 nonce·hash 방식에 맞춰 구성합니다.
Reporting-Endpoints: csp="https://reports.example.com/csp"
Content-Security-Policy-Report-Only: script-src 'none'; object-src 'none'; base-uri 'none'; report-to csp; report-uri https://reports.example.com/csp-legacy
report-to에는 엔드포인트 이름을 쓰고 Reporting-Endpoints에서 실제 URL과 연결합니다. report-uri는 폐기 예정 지시어지만 report-to를 지원하지 않는 브라우저의 보고 경로로 함께 둘 수 있습니다. 두 방식의 보고 형식이 다르므로 수집기도 각각 처리해야 합니다. 위 주소는 예시이며 실제로 보고를 받는 서버가 필요합니다. 보고 지시어 문서에 형식과 호환 동작이 나와 있습니다.
이미 강제 중인 CSP와 더 엄격한 Report-Only 정책을 동시에 보낼 수도 있습니다. 이 경우 기존 CSP는 계속 차단하고 새 정책은 관측만 합니다. 보고가 수집되지 않는다고 위반이 없다고 판단하지 않도록 콘솔과 수집 서버 양쪽에서 전달 여부를 확인합니다.
위반 리포트를 처리하는 순서
위반에 나온 주소를 모두 허용 목록에 추가하면 정책이 계속 넓어집니다. 먼저 어떤 화면의 어떤 코드가 요청을 만들었는지 확인합니다.
| 관측한 위반 | 확인할 원인 | 수정 방향 |
|---|---|---|
| 정상 인라인 스크립트 차단 예상 | nonce 누락·불일치, 이전 빌드의 hash | HTML과 정책 생성 과정 수정 |
| 특정 기능의 API·이미지 요청 차단 예상 | 실제 필요한 출처가 해당 지시어에 없는지 | 해당 지시어에 필요한 출처만 추가 |
eval 계열 실행 위반 | 개발용 번들·의존성의 코드 생성 방식 | 운영 빌드와 라이브러리 설정 검토 |
| 사용하지 않는 분석·광고 요청 | 남아 있는 태그와 그 소유자 | 필요 없는 태그 제거 |
| 출처를 설명할 수 없는 위반 | 확장 프로그램, 오래 열린 탭, 주입 시도 등 | 재현 조건과 발생 경로를 확인한 뒤 분류 |
리포트 수를 0으로 만드는 것보다 정상 사용자 흐름이 필요한 정책 안에서 동작하는지 확인하는 편이 유용합니다. 익명·로그인 상태와 주요 화면을 나누어 실행하고 대상 브라우저·웹뷰에서도 확인합니다.
리포트에는 페이지 URL 등이 담깁니다. 'report-sample'로 코드 일부까지 수집한다면 민감한 입력이 함께 기록될 가능성도 검토해야 합니다. 필요한 진단 정보와 수집 범위를 정한 뒤 사용합니다.
실제 응답에서 확인할 정책
설정 파일에 적은 값과 브라우저가 받은 헤더가 일치하는지 확인합니다. 특히 앱 서버와 CDN이 모두 CSP를 붙이는 구성에서는 여러 강제 정책이 각각 적용됩니다. 두 허용 목록이 합쳐지는 것이 아니므로 나중에 넓은 정책을 추가해도 앞의 제한은 풀리지 않습니다.
<meta http-equiv="Content-Security-Policy">로도 일부 정책을 전달할 수 있지만 응답 헤더와 기능이 같지 않습니다. Report-Only를 사용할 수 없고 frame-ancestors 같은 지시어도 적용되지 않습니다. 이 글의 도입 절차는 응답 헤더를 기준으로 합니다. 복수 정책과 전달 방식의 제약은 CSP 규격에서 확인할 수 있습니다.
검증이 끝나면 후보 정책을 Content-Security-Policy로 전환해 실제 차단을 시작합니다. 보고 설정은 유지하고 화면 오류와 리소스 로드 실패를 함께 관측합니다. 기능이 깨졌을 때는 원인을 확인하면서 직전 정책으로 되돌릴 수 있어야 합니다.
Recap
Report-Only로 정상 기능에 미칠 영향을 먼저 확인합니다. 위반은 발생 경로를 추적해 코드 수정과 정책 수정 중 무엇이 필요한지 판단합니다. 실제 차단으로 전환한 뒤에도 최종 응답 헤더와 사용자 흐름을 확인하고 직전 정책으로 돌아갈 수 있는 경로를 유지합니다.
CSP와 별도로 확인할 신뢰 경계
스크립트 실행을 제한해도 모든 위조 요청과 잘못된 메시지가 사라지지는 않습니다. 요청을 보낸 사이트, 메시지를 보낸 창, 서버에서 행동할 사용자는 각각 다른 곳에서 확인합니다.
CSRF: 사용자 브라우저가 자동으로 보내는 쿠키
교차 사이트 요청 위조(Cross-Site Request Forgery, CSRF)는 공격자 사이트가 사용자의 브라우저를 이용해 대상 서비스에 원하지 않은 요청을 보내는 공격입니다. 세션 쿠키처럼 브라우저가 자동으로 붙이는 자격 증명이 있으면 서버에는 로그인된 사용자의 요청으로 보일 수 있습니다.
XSS는 우리 페이지에서 공격자의 코드가 실행되는 경우이고, CSRF는 공격자 페이지에서 시작된 요청만으로도 성립할 수 있습니다. 응답을 읽지 못해도 설정 변경이 끝났다면 피해는 발생합니다. 우리 페이지의 CSP나 CORS의 응답 읽기 제한만으로 해결할 수 없는 이유입니다.
상태를 바꾸는 요청은 서버에서 CSRF 토큰이나 요청 출처 검증 등으로 보호하고 GET으로 처리하지 않습니다. 사용자 정의 헤더를 요구해 preflight를 활용하는 설계도 가능하지만 그 헤더가 없는 요청을 서버가 거부하고 CORS 허용 출처도 엄격히 제한해야 합니다. 구체적인 조합은 OWASP의 CSRF 예방 가이드를 기준으로 정할 수 있습니다.
쿠키의 SameSite는 교차 사이트 요청에 쿠키를 붙일 범위를 제한합니다.
| 명시한 값 | 교차 사이트 요청의 전송 범위 |
|---|---|
Strict | 쿠키 전송 제한 |
Lax | 최상위 페이지 이동의 GET 같은 안전한 메서드에는 전송 가능 |
None | 교차 사이트 전송 허용. Secure 필요하며 다른 브라우저 쿠키 정책의 제한은 남음 |
여기서 사이트(site)는 출처와 다릅니다. HTTPS를 사용하는 app.example.com과 other.example.com은 서로 다른 출처이지만 같은 사이트일 수 있습니다. 따라서 신뢰하지 않는 서브도메인이 있다면 SameSite만으로 요청을 구별할 수 없습니다. 쿠키 속성별 조건은 MDN의 Set-Cookie 문서를 참고합니다.
postMessage: 메시지를 보낸 창 확인하기
외부 iframe과 통신할 때는 메시지 내용에 따라 화면이나 상태를 바꾸기 전에 송신자를 확인합니다. 다음은 특정 iframe에서 테마 변경 메시지를 받는 예시입니다.
const expectedOrigin = "https://widget.example.com";
const iframe = document.querySelector("#widget");
window.addEventListener("message", (event) => {
if (event.origin !== expectedOrigin) return;
if (!iframe || event.source !== iframe.contentWindow) return;
const data = event.data;
if (typeof data !== "object" || data === null) return;
if (data.type !== "SET_THEME") return;
if (data.theme !== "light" && data.theme !== "dark") return;
document.documentElement.dataset.theme = data.theme;
});
event.origin은 메시지를 보낼 때의 출처를, event.source는 기대한 창인지를 확인합니다. 이어서 메시지의 타입과 필드 값을 검사합니다. 로그인된 사용자만 할 수 있는 동작이라면 이 검사를 통과해도 서버의 인가 검사는 별도로 필요합니다.
송신할 때도 targetOrigin에 정확한 상대 출처를 지정합니다. 창 객체를 얻은 뒤 그 창이 다른 사이트로 이동할 수 있기 때문입니다. 세부 동작은 window.postMessage() 문서를 참고합니다.
BroadcastChannel과 워커 메시지는 연결 범위와 API가 다르므로 위 코드를 그대로 복사하지 않습니다. 각각의 통신 모델은 Web Worker와 Service Worker 글에서 다룹니다.
실행을 허용하기 어려운 코드의 격리
외부에서 만든 HTML이나 코드를 실행해야 하는 기능이라면 앱의 DOM에 직접 넣기보다 별도 출처의 iframe에서 실행하는 구조를 검토합니다. 필요한 sandbox 권한만 열고 부모와는 검증한 메시지로 통신합니다.
같은 출처의 iframe에 allow-scripts와 allow-same-origin을 함께 주면 내부 코드가 부모 DOM에 접근해 sandbox 속성을 제거할 수 있습니다. 격리하려는 코드의 출처와 허용 토큰을 함께 봐야 합니다. iframe의 sandbox 문서에 이 조합의 제약이 설명되어 있습니다.
Recap
CSRF는 자동으로 붙는 자격 증명을 이용하므로 요청을 받는 서버에서 방어해야 합니다. 창 간 메시지는 출처·창·데이터 형식을 확인하고, 사용자 권한도 필요한 곳에서 따로 검사합니다. 신뢰하기 어려운 코드의 실행이 필요하면 출처와 sandbox를 이용한 격리 구조를 검토합니다.
코드 리뷰에서 확인할 순서
보안 검토는 의심스러운 API를 찾은 뒤 실제 입력까지 거슬러 올라가는 방식으로 시작할 수 있습니다. 검색 결과마다 소스와 싱크, 중간 처리, 실행 권한을 적으면 다음에 확인할 내용이 구체적입니다.
예를 들어 “댓글에 XSS 위험이 있다”보다 “사용자가 저장한 댓글이 관리자 화면의 innerHTML에 정제 없이 전달된다”는 기록이 수정 위치와 피해 범위를 더 잘 보여줍니다. 같은 HTML API라도 코드에 고정된 안내 문구만 넣는 곳과는 우선순위가 다릅니다.
수정 후의 확인
본문의 댓글 예제라면 위험한 이벤트 핸들러가 실행되지 않는지와 허용한 서식이 유지되는지를 함께 확인합니다. JSON-LD는 결과에 <가 남지 않는지, 파싱하면 원래 데이터가 복원되는지 확인합니다. URL 검증은 같은 출처의 정상 경로와 외부 URL·파싱 실패를 함께 넣어 봅니다.
CSP의 XSS 실행 완화 효과도 입력 처리와 나누어 확인합니다. 허용한 스크립트는 실행되고 nonce가 없는 스크립트는 차단되는지, Report-Only에서는 보고만 발생하는지 확인합니다. 이렇게 구분하면 CSP가 막아준 결과를 보고 정제 코드까지 올바르다고 오인하는 일을 줄일 수 있습니다.
리뷰 질문
- 이 값을 사용자가 URL·폼·저장된 콘텐츠 등으로 바꿀 수 있습니까?
- 값이 텍스트, HTML, URL, JavaScript 중 무엇으로 해석됩니까?
- HTML 정제나 URL 검증 이후에 값이 다시 변하는 경로가 있습니까?
- CSP가 허용하는 스크립트는 무엇이며 nonce·hash가 실제 HTML과 일치합니까?
- 정상 화면의 동작과 차단할 실행 경로를 각각 확인했습니까?
- 쿠키가 자동으로 붙는 요청과 창 간 메시지의 발신자를 필요한 곳에서 검사합니까?
처음의 질문으로 돌아가면 해야 할 일이 나뉩니다. 댓글을 HTML로 해석할 필요가 없다면 렌더링 코드를 바꿉니다. HTML이 필요하면 허용 범위를 정해 정제합니다. CSP에서는 그 처리를 놓쳤을 때도 임의의 스크립트가 실행되지 않도록 정책을 구성합니다.
Recap
검토 기록에는 공격자가 정할 수 있는 입력과 최종 사용 지점, 중간 처리와 실행 권한을 남깁니다. 수정 후에는 차단할 입력과 유지할 기능을 함께 확인합니다. 코드의 입력 처리와 CSP의 차단 효과를 각각 검증하면 어떤 방어가 동작했고 무엇이 남았는지 설명할 수 있습니다.
References
브라우저의 출처와 요청
XSS와 출력 처리
- OWASP: Cross Site Scripting Prevention Cheat Sheet
- DOMPurify: 사용법과 Trusted Types 연동
- rehype-sanitize: 플러그인 순서와 보안 고려사항
- Next.js: JSON-LD
- WHATWG: URL의 파싱과 직렬화
- MDN: Trusted Types API
CSP 정책과 도입
- MDN: Content Security Policy 가이드
- W3C: Content Security Policy Level 3
- MDN: default-src
- MDN: script-src
- web.dev: Mitigate XSS with a strict CSP
- MDN: report-to
CSRF와 창 간 통신
- OWASP: Cross-Site Request Forgery Prevention Cheat Sheet
- MDN: Set-Cookie
- MDN: Window.postMessage()
- MDN: iframe sandbox
