
CSP(Content Security Policy): XSS를 원천 차단하는 헤더
XSS 공격이 성공해도 실행되지 않게 막는 마지막 방어선이 CSP다. 디렉티브 문법부터 nonce 방식, Next.js 설정, 점진적 도입 전략까지 정리했다.

XSS 공격이 성공해도 실행되지 않게 막는 마지막 방어선이 CSP다. 디렉티브 문법부터 nonce 방식, Next.js 설정, 점진적 도입 전략까지 정리했다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

비트코인은 블록체인의 일부입니다. 이중 지불 문제(Double Spending), 작업 증명(PoW)과 지분 증명(PoS)의 차이, 스마트 컨트랙트, 그리고 Web 3.0이 가져올 미래까지. 개발자 관점에서 본 블록체인의 모든 것.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

XSS(Cross-Site Scripting) 방어의 첫 번째 라인은 입력 이스케이프다. 사용자 입력을 HTML에 넣을 때 <, >, " 같은 문자를 이스케이프해서 스크립트가 실행되지 않게 한다.
근데 이게 완벽하지 않다. 서드파티 라이브러리가 취약점을 가지고 있거나, 레거시 코드에 dangerouslySetInnerHTML이 남아있거나, 리치 텍스트 에디터가 불완전하게 새니타이즈하거나. 수많은 이유로 XSS 페이로드가 HTML에 주입될 수 있다.
CSP는 이 상황에서 "주입은 됐는데, 그래서 실행이 안 됨"을 만드는 두 번째 방어선이다.
비유하자면: 첫 번째 라인은 "나쁜 물건이 들어오지 못하게 막는 것"이고, CSP는 "들어와도 작동하지 못하게 하는 것"이다.
CSP는 HTTP 응답 헤더다. 브라우저에게 "이 페이지에서 어떤 리소스를 어디서 로드해도 되는지"를 알려준다.
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com
이 헤더가 있으면 브라우저는:
self (같은 오리진)에서 온 스크립트만 실행https://trusted-cdn.com에서 온 스크립트만 실행<!-- 이건 실행됨 -->
<script src="/app.js"></script>
<script src="https://trusted-cdn.com/lib.js"></script>
<!-- 이건 차단됨 (인라인 스크립트) -->
<script>alert('XSS')</script>
<!-- 이건 차단됨 (허가되지 않은 외부 도메인) -->
<script src="https://evil.com/malware.js"></script>
XSS 공격의 핵심은 페이지에 스크립트를 주입하는 것이다. CSP는 그 스크립트가 실행되지 못하게 막는다.
CSP는 여러 디렉티브(지시어)로 구성된다. 각 디렉티브는 특정 종류의 리소스를 제어한다.
다른 디렉티브가 명시되지 않은 경우의 기본값.
Content-Security-Policy: default-src 'self'
JavaScript 실행 소스 제어.
# 자기 오리진 + 특정 CDN만 허용
script-src 'self' https://cdn.jsdelivr.net https://www.googletagmanager.com
# unsafe-inline: 인라인 스크립트 허용 (XSS 보호 약화됨, 가능하면 피할 것)
script-src 'self' 'unsafe-inline'
# unsafe-eval: eval() 허용 (더 위험, 절대 피할 것)
script-src 'self' 'unsafe-eval'
CSS 소스 제어.
# 인라인 스타일은 unsafe-inline으로 허용 (스타일은 XSS 위험이 낮음)
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
이미지 소스 제어.
# data: URI도 허용 (base64 인라인 이미지)
img-src 'self' data: https://images.example.com https://www.google-analytics.com
웹폰트 소스.
font-src 'self' https://fonts.gstatic.com
fetch, XMLHttpRequest, WebSocket 연결 제어.
# API 서버와 분석 서비스만 허용
connect-src 'self' https://api.example.com https://www.google-analytics.com wss://ws.example.com
iframe 제어.
# iframe을 완전히 차단 (클릭재킹 방지)
frame-ancestors 'none'
# 같은 오리진에서만 iframe 허용
frame-ancestors 'self'
폼 제출 대상 제어.
# 같은 오리진으로만 폼 제출 가능
form-action 'self'
Content-Security-Policy:
default-src 'self';
script-src 'self' https://www.googletagmanager.com https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://images.example.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com wss://ws.example.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
object-src 'none'
unsafe-inline을 쓰면 인라인 스크립트가 허용되는데, 이러면 XSS 방어가 약해진다. 하지만 인라인 스크립트가 꼭 필요한 경우가 있다. 이때 nonce를 쓴다.
서버가 요청마다 무작위 nonce를 생성해서 CSP 헤더와 인라인 스크립트 양쪽에 넣는다. 브라우저는 CSP 헤더의 nonce와 스크립트 태그의 nonce가 일치하는 경우만 실행한다.
공격자가 XSS로 <script> 태그를 주입해도, nonce를 모르면 실행되지 않는다. nonce는 요청마다 새로 생성되므로 예측 불가능하다.
// Next.js middleware에서 nonce 생성
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// 요청마다 무작위 nonce 생성
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, " ").trim();
const response = NextResponse.next();
response.headers.set("Content-Security-Policy", cspHeader);
// nonce를 요청 헤더에도 넣어서 페이지에서 접근 가능하게
response.headers.set("x-nonce", nonce);
return response;
}
// Next.js 서버 컴포넌트에서 nonce 읽기
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get("x-nonce") ?? "";
return (
<html>
<head>
{/* nonce가 있는 인라인 스크립트는 CSP 통과 */}
<script nonce={nonce} dangerouslySetInnerHTML={{
__html: `window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}`,
}} />
</head>
<body>
{children}
{/* next/script도 nonce 지원 */}
<Script
src="https://www.googletagmanager.com/gtag/js"
strategy="afterInteractive"
nonce={nonce}
/>
</body>
</html>
);
}
nonce와 함께 strict-dynamic을 쓰면 nonce가 있는 스크립트가 동적으로 로드하는 스크립트도 신뢰한다. 이러면 출처 기반 화이트리스트 없이도 스크립트 번들이 동작한다.
# nonce-based + strict-dynamic
script-src 'nonce-{RANDOM}' 'strict-dynamic'
# strict-dynamic 사용 시 'self'나 특정 URL은 무시됨
# nonce를 가진 스크립트가 동적으로 로드하는 스크립트도 허용
nonce는 동적 페이지에 적합하다. 정적 인라인 스크립트라면 해시를 쓸 수도 있다.
import crypto from "crypto";
const inlineScript = `console.log("Hello, World!")`;
const hash = crypto.createHash("sha256").update(inlineScript).digest("base64");
// 헤더: script-src 'sha256-{hash}'
console.log(`script-src 'sha256-${hash}'`);
// → script-src 'sha256-abc123...'
<!-- 이 스크립트의 해시가 CSP에 등록되어 있으면 실행됨 -->
<script>console.log("Hello, World!")</script>
스크립트 내용이 조금이라도 바뀌면 해시가 달라지므로 CSP를 업데이트해야 한다. 그래서 자주 바뀌는 스크립트엔 nonce가 더 실용적이다.
CSP를 바로 적용하면 기존 스크립트들이 차단될 수 있다. 먼저 Report-Only 모드로 위반 사항을 수집한다.
# 차단하지 않고 보고만 함
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report
// CSP 위반 보고 수집 엔드포인트
app.post("/csp-report", express.json({ type: "application/csp-report" }), (req, res) => {
const report = req.body["csp-report"];
console.warn("CSP Violation:", {
documentUri: report["document-uri"],
violatedDirective: report["violated-directive"],
blockedUri: report["blocked-uri"],
sourceFile: report["source-file"],
lineNumber: report["line-number"],
});
// Slack이나 로그 시스템으로 전송
res.status(204).end();
});
위반 보고를 수집해서 화이트리스트를 만들고, 준비가 되면 Content-Security-Policy로 전환한다.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
// nonce를 쓰는 경우 미들웨어에서 동적으로 설정하고 여기선 제거
value: [
"default-src 'self'",
"script-src 'self' https://www.googletagmanager.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'self'",
"upgrade-insecure-requests",
].join("; "),
},
// 기타 보안 헤더
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
],
},
];
},
};
export default nextConfig;
// src/proxy.ts (middleware) — nonce 기반 동적 CSP
import { NextRequest, NextResponse } from "next/server";
import { intlMiddleware } from "./i18n/routing";
export default async function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https:`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
].join("; ");
const response = intlMiddleware(request);
response.headers.set("Content-Security-Policy", csp);
response.headers.set("x-nonce", nonce);
return response;
}
# 이러면 CSP의 의미가 크게 퇴색됨
script-src 'self' 'unsafe-inline' 'unsafe-eval'
unsafe-inline을 허용하면 <script> 태그 인젝션 공격이 그대로 통한다. nonce나 hash를 쓰자.
# 너무 느슨함
img-src *
script-src * 'unsafe-inline'
# 더 나음
img-src 'self' https://images.example.com https://cdn.example.com
Google Analytics, 인터콤, 핫자르 같은 서드파티 스크립트들은 자체적으로 추가 스크립트를 동적으로 로드하거나 인라인 스타일을 사용하는 경우가 많다.
# Google Tag Manager 포함 시
script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com 'nonce-{RANDOM}' 'strict-dynamic'
strict-dynamic이 있으면 GTM이 동적으로 로드하는 스크립트도 nonce 없이 허용된다.
일부 번들러, 템플릿 엔진이 eval()을 사용한다.
Uncaught EvalError: Refused to evaluate a string as JavaScript
because 'unsafe-eval' is not an allowed source of script
in the following Content Security Policy directive: "script-src 'self'"
이 에러가 나면 해당 라이브러리를 eval을 쓰지 않는 버전으로 교체하거나, webpack devtool: 'source-map' 설정을 확인하자. 개발 모드에서 eval을 쓰는 경우가 많다.
1단계: Report-Only 모드로 2-4주 모니터링
Content-Security-Policy-Report-Only: default-src 'self'; ...; report-uri /csp-report
2단계: 위반 보고 분석 → 필요한 소스 화이트리스트 추가
3단계: 느슨한 CSP로 실제 적용
script-src 'self' 'unsafe-inline' ...
4단계: unsafe-inline 제거 → nonce 방식으로 전환
5단계: strict-dynamic 추가로 강화
이 순서대로 하면 갑작스러운 기능 차단 없이 안전하게 도입할 수 있다.
# Mozilla Observatory - CSP 포함 전반적인 보안 헤더 평가
https://observatory.mozilla.org
# CSP Evaluator (Google)
https://csp-evaluator.withgoogle.com
# Security Headers
https://securityheaders.com
CSP가 강력하지만 만능은 아니다.
frame-ancestors로 일부 방어하지만, 완전한 보호를 위해 X-Frame-Options도 함께 사용.CSP는 설정이 복잡하고 디버깅이 까다롭지만, XSS 공격에 대한 가장 강력한 브라우저 레벨 방어선이다.
핵심 포인트:
unsafe-inline과 unsafe-eval은 최대한 피한다입력 이스케이프 + CSP 두 레이어를 갖추면 XSS에 대한 방어가 크게 강화된다.