G-5: 프론트엔드 보안 ② — 미리 알려두는 방법
목차 52
안녕하세요, 홍순구 튜터입니다.
숫자 세 개로 시작할게요.
49 개발 서버에서 우리 홈 화면이 불러오는 스크립트 수
49 오늘 우리가 그 전부에 "이건 우리가 허락한 것" 표시를 붙이는 데 성공하는 수
0 똑같은 코드를 프로덕션에 올렸을 때 실제로 실행되는 자바스크립트 수
마지막 줄이 오늘의 사건입니다. 공식 문서에 적힌 예제를 그대로 따라 했는데, 개발 서버에서는 완벽하게 동작하던 것이 프로덕션에서는 앱을 통째로 멈춰 세워요. 빌드는 성공하고 화면도 뜨고 상태 코드도 200으로 나오는데, 아무 버튼도 안 눌립니다.
지난 시간 마지막에 이렇게 말씀드렸죠. 구멍을 하나씩 막는 방식으로는 마지막 구멍을 찾았다고 말할 수 없으니, 방향을 뒤집어서 브라우저에게 미리 알려두자고요. 오늘 그걸 하면서, 그 과정에서 만나는 위 세 번째 숫자가 왜 그렇게 됐는지 끝까지 따라가서 되살릴 거예요.
오늘의 여정
1. 헤더를 어디에 둘지 고른다 → 어디에 둬도 하나씩 부족하다
2. 문지기의 시야를 넓힌다 → 넓히니 새 부채가 생긴다
3. 요청마다 다른 번호표를 붙인다 → 개발 서버 49개 전부 성공
4. 프로덕션에 올린다 → 아무것도 안 눌린다
5. 한 단어를 지운다 → 되살아난다
💡 오늘 수업의 핵심 — "개발 서버가 통과시킨 것이 프로덕션에서도 통과한다는 보장은 없습니다"
🎯 학습 목표
- 브라우저에게 실행 규칙을 미리 알려주는 방법을 이해하고, 그 규칙을 어디에 두느냐가 무엇을 포기하게 만드는지 직접 재봅니다.
- 요청마다 다른 값을 발급해 인라인 스크립트를 선별 허용하는 방법을 우리 앱에 적용합니다.
- 공식 예제를 그대로 따랐는데 프로덕션에서만 앱이 멈추는 상황을 만나고, 원인을 좁혀 들어가 되살립니다.
Step 1: "헤더를 어디에 둘까요"
브라우저에게 미리 알려준다는 것
지난 시간에 우리는 댓글에 들어온 위험한 태그를 걸러냈습니다. 하지만 걸러내는 쪽은 우리가 아는 구멍만 막을 수 있고 모르는 구멍은 못 막아서, 방향을 반대로 잡는 방법도 있습니다. 구멍을 찾아다니는 대신, 브라우저에게 먼저 규칙을 알려주는 거예요.
이 페이지에서는 내가 허락한 출처의 스크립트만 실행해라. 나머지는 무엇이 들어와 있든 실행하지 마라.
이 규칙을 브라우저에게 전달하는 통로가 응답 헤더입니다. 서버가 HTML을 보낼 때 그 앞에 붙여 보내는 정보예요. 우리가 D-7에서 404 상태 코드를 얹었던 것과 같은 통로입니다. 헤더 이름은 Content-Security-Policy이고, 흔히 앞 글자를 따서 CSP라고 부릅니다.
중요한 건 이게 브라우저에게 하는 부탁이라는 점이에요. 서버가 뭔가를 지우는 게 아닙니다. 위험한 코드는 여전히 HTML 안에 들어 있고, 브라우저가 그걸 읽되 실행만 안 하는 겁니다. 이 차이는 다음 시간에 눈으로 확인할 거예요.
우리 앱에는 지금 이 헤더가 없습니다
먼저 현재 상태를 확인하기 위해 응답 헤더를 통째로 찍어보세요.
curl -s -o /dev/null -D - http://localhost:3000/
Content-Security-Policy는 안 보일 텐데, 45일 동안 우리가 이 헤더를 한 번도 안 붙였기 때문이에요.
첫 번째 후보 — 설정 파일
Next는 next.config.ts에서 응답 헤더를 붙일 수 있게 해줍니다. 가장 단순한 규칙부터 하나 얹어볼게요.
// apps/web-next/next.config.ts
// 모든 주소에 같은 헤더를 붙인다. 여기서는 요청마다 다른 값을 만들 수 없다.
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: "default-src 'self'" },
],
},
];
},
default-src 'self'는 "모든 자원은 우리 출처에서 온 것만 허용한다"는 뜻이고, source: '/(.*)'는 모든 주소를 가리켜요.
주소 네 개에 각각 요청해서 헤더가 붙는지 확인해봅시다. /jaehoon은 로그인이 필요하니 쿠키를 함께 보내세요.
/ Content-Security-Policy: default-src 'self'
/explore Content-Security-Policy: default-src 'self'
/signup Content-Security-Policy: default-src 'self'
/jaehoon Content-Security-Policy: default-src 'self'
네 곳 전부 붙었는데, 빠지는 주소가 없다는 것이 설정 파일에 두는 쪽의 값어치예요.
그런데 앱의 스크립트가 막히기 시작합니다
default-src 'self'에는 인라인 스크립트를 허용한다는 말이 없거든요. 인라인 스크립트는 <script src="...">처럼 파일을 가리키는 게 아니라, HTML 안에 코드가 직접 적혀 있는 스크립트를 말합니다. 출처가 따로 없으니 "우리 출처에서 온 것"에 해당하지 않아요.
우리 홈 화면이 그런 스크립트를 몇 개나 갖고 있는지 세어봅시다.
curl -s http://localhost:3000/ | grep -o '<script[^>]*>' | wc -l
49개 정도가 나오는데, 그중 파일을 가리키는 것이 15개고 나머지 34개가 인라인이에요. 숫자가 한두 개씩 오르내리는데, 화면을 스트리밍으로 내보내는 중이라 재는 순간에 따라 갈립니다.
34개가 왜 이렇게 많냐면, 지난 시간에 우리가 댓글을 넣으면서 게시물 열 장에 각각 댓글 영역이 붙어 세 배로 늘었기 때문입니다.
막혔는지는 브라우저 콘솔에서 한 줄로 확인할 수 있어요. 홈 화면을 열고 이렇게 쳐보세요.
> typeof $RC
$RC는 우리가 만든 게 아니라 Next가 인라인 스크립트로 심어두는 함수입니다. 서버가 나중에 보낸 내용을 화면의 제자리에 채워 넣는 일을 해요.
CSP 를 붙이기 전 "function" ← 인라인 스크립트가 돌았다
CSP 를 붙인 뒤 "undefined" ← 그 스크립트가 실행되지 않았다
우리가 방금 붙인 규칙은 앱이 화면을 채우는 데 쓰는 스크립트까지 막을 만큼 너무 거칩니다.
여기서 방향이 정해지는데, 인라인을 전부 막을 수도 전부 열 수도 없으니 우리가 넣은 34개만 골라서 허용해야 합니다. 그 방법이 Step 3의 주제예요.
그런데 설정 파일로는 그걸 할 수가 없습니다
"골라서 허용"을 하려면 우리가 넣은 스크립트에만 표시를 붙여야 하고, 그 표시는 매 요청마다 새로 만든 값이어야 합니다. 남이 예측할 수 있으면 표시를 흉내 낼 수 있으니까요.
설정 파일에서 값이 매번 바뀔 수 있는지, 같은 주소를 세 번 요청해서 확인해봅시다.
1회차 Content-Security-Policy: default-src 'self'
2회차 Content-Security-Policy: default-src 'self'
3회차 Content-Security-Policy: default-src 'self'
글자 하나 안 다른데, next.config.ts는 서버가 시작할 때 한 번 읽히는 설정 파일이라 요청이 들어올 때마다 실행되는 코드가 아니고, 여기서는 요청마다 다른 값을 만들 방법이 없어요.
두 번째 후보 — 문지기
요청마다 실행되는 코드는 이미 우리에게 있습니다. D-7에서 만들고 D-8·G-2에서 키운 proxy.ts예요. 요청이 도착했지만 아직 아무것도 안 그려진 시점에 끼어드는 코드죠.
여기라면 요청마다 새 값을 만들 수 있습니다. 그럼 이쪽으로 옮기면 끝일까요?
문지기가 어떤 주소를 보고 있는지부터 확인해야 하는데, 지금 설정은 이렇게 돼 있어요.
// apps/web-next/proxy.ts
export const config = {
// 프로필로 갈 수 있는 주소만 본다. 나머지는 아예 안 거친다.
matcher: ['/((?!api|_next|favicon.ico|explore).+)'],
};
주소별로 문지기를 거치는지 재보면 여기 함정이 하나 있는데, 로그아웃 상태로 주소별 상태 코드를 찍어보면 이렇게 나옵니다.
/ 200
/explore 200
/signup 200
/jaehoon 307
/zzzzz 307
앞의 셋이 전부 200 이죠. 이것만 보고 "앞의 세 주소는 문지기를 안 거치는구나"라고 읽으면 틀립니다.
/signup은 문지기를 거치고 나서 통과한 겁니다. G-2에서 우리가 공개 주소 목록에 넣었으니까요. /와 /explore는 아예 거치지도 않았고요. 안 봐서 통과한 것과 보고 나서 통과시킨 것이 똑같은 200으로 보입니다.
둘을 가르려면 문지기가 실제로 돌았을 때만 남는 흔적이 필요해요. 문지기 안에서 표시 헤더를 하나 붙이고 다시 재보면 이렇게 갈립니다.
/ 문지기 안 거침
/explore 문지기 안 거침
/jaehoon 거침
/signup 거침 ← 거치고 나서 통과시킨 것
/zzzzz 거침
/와 /explore는 문지기가 아예 안 본다는 것이 이제 분명해졌습니다.
이유는 저 패턴에 그대로 적혀 있어요. explore는 괄호 안에 명시적으로 빠져 있고, 홈은 패턴 끝의 .+ 때문에 빠집니다. .+는 "한 글자 이상"을 요구하는데 / 뒤에는 아무것도 없거든요.
이 설정은 D-8에서 우리가 이유를 갖고 정한 것인데, 그때 matcher를 모든 주소로 넓혔더니 로그인 화면인 홈까지 막혀서 로그인 자체가 불가능해졌고, 들어오는 문을 막으면 안 된다는 이유로 좁혀둔 겁니다.
두 후보를 나란히 놓으면
next.config.ts headers()
○ 모든 주소에 붙는다
✗ 요청마다 다른 값을 못 준다
proxy.ts
○ 요청마다 다른 값을 줄 수 있다
✗ / 와 /explore 를 안 본다
양쪽 다 우리에게 필요한 것을 하나씩 갖고 있고 하나씩 빠뜨리고 있는데, 우리는 둘 다 필요해요.
그렇다면 답은 하나입니다. 요청마다 값을 만들 수 있는 쪽을 고르고, 그쪽이 못 보던 주소를 보게 만드는 것. 다음 Step에서 문지기의 시야를 넓히면서, 넓히는 순간 D-8이 경고했던 문제가 실제로 나타나는지도 확인할 거예요.
💡 한 줄 정리
CSP는 브라우저에게 미리 알려주는 실행 규칙이고, 그 규칙을 설정 파일에 두면 모든 주소에 걸리지만 요청마다 다른 값을 줄 수 없습니다. 그런데 문지기에 두면 값은 만들 수 있지만, 지금은 홈과 탐색을 보지 못합니다.
🙋 학생 질문 — "상태 코드가 200이면 통과한 거니까 어느 쪽이든 결과는 같은 것 아닌가요?"
결과만 보면 같아 보이는데, 그 뒤에 하려는 일이 달라서 갈립니다.
우리가 문지기에 CSP를 달면 문지기가 안 보는 주소에는 헤더가 안 붙습니다. /signup처럼 거치고 통과한 주소는 헤더를 받고, /처럼 안 거친 주소는 못 받아요. 둘 다 화면은 200으로 잘 뜨는데 한쪽만 보호받는 상태가 됩니다.
더 나쁜 건 이게 조용하다는 거예요. 홈 화면은 멀쩡히 뜨고 콘솔에 경고도 없고 빌드도 통과하다 보니, 보호가 빠졌다는 걸 알려주는 신호가 하나도 없습니다.
오늘처럼 "이 코드가 실제로 돌았나"를 물어야 하는 상황에서는, 상태 코드 같은 결과만 보면 안 되고 그 코드가 돌았을 때만 남는 흔적을 따로 만들어서 봐야 하는데, 표시 헤더를 붙여본 게 그거였어요.
Step 2: "문지기의 시야를 넓힙니다"
두 글자를 고칩니다
문지기가 홈을 못 보는 이유는 패턴 끝의 .+였는데, "한 글자 이상" 대신 "없어도 된다"로 바꾸면 홈도 들어오고, 괄호 안에서 explore를 빼면 탐색도 들어옵니다.
// apps/web-next/proxy.ts
export const config = {
// 화면으로 나가는 모든 주소를 본다. 헤더를 빠짐없이 붙이려면 여기가 다 봐야 한다.
// 끝이 .+ 가 아니라 .* 라 홈(/)도 들어온다.
matcher: ['/((?!api|_next|favicon.ico).*)'],
};
api와 _next는 계속 빼둡니다. 앞엣것은 화면이 아니고, 뒤엣것은 스크립트·이미지 같은 정적 파일이라 문지기를 거칠 이유가 없어요.
그런데 이대로 두면 로그인이 불가능해집니다
D-8에서 우리가 이걸 한 번 해봤다가 되돌렸죠. 이유를 다시 짚어볼게요.
우리 문지기는 세션 쿠키가 없으면 홈으로 되돌려보내는데, 이제 홈도 문지기가 봅니다. 로그인 안 한 사람이 홈에 오면, 문지기가 "쿠키 없네" 하고 홈으로 보내고, 그 홈을 또 문지기가 보고, 다시 홈으로 보내고….
로그인 화면에 영영 도착하지 못합니다. 홈이 곧 로그인 화면이니까요.
그래서 공개 목록에 넣습니다
G-2에서 우리는 이런 상황을 위한 장치를 이미 만들어뒀습니다. 공개 주소 목록이죠. 그때는 /signup 하나였는데, 이제 둘을 더합니다.
// apps/web-next/proxy.ts
// 로그인 안 한 사람이 와야 하는 주소. 여기 없는 주소는 전부 보호된다.
// 새 주소를 만들면 기본이 "보호" 라서, 공개할 것만 이 목록에 적는다.
//
// matcher 를 모든 주소로 넓히면서 홈과 탐색이 여기로 들어왔다.
// 홈은 로그인 화면이라 막으면 로그인 자체가 불가능해지고,
// 탐색은 로그인 없이 둘러보는 화면이다.
const PUBLIC_PATHS = ['/', '/explore', '/signup'];
로그아웃 상태와 로그인 상태 양쪽으로 다시 재봅시다.
로그아웃 로그인
/ 200 200
/explore 200 200
/jaehoon 307 200
/zzzzz 307 404
무한 반복은 안 일어나는데, 마지막 줄을 보면 /zzzzz가 로그인 상태에서 여전히 404입니다. D-7에서 405밀리초를 주고 산 그 404가 살아 있어요. 문지기를 넓히면서 그걸 잃지 않았습니다.
표시 헤더로 한 번 더 확인해보면 이제 네 주소 전부 문지기를 거칩니다.
/ 거침 ← Step 1 에서는 안 거쳤다
/explore 거침 ← Step 1 에서는 안 거쳤다
/signup 거침
/jaehoon 거침
목표를 이뤘는데, 이제 모든 화면 주소가 문지기를 지나가니 헤더를 여기서 붙이면 빠지는 곳이 없습니다.
대신 부채가 하나 생겼습니다
넓히면 좋기만 한 게 아닙니다. 직접 확인해보죠. 새 화면을 하나 만들어보세요. 내용은 아무거나 좋습니다.
// apps/web-next/app/settings/page.tsx
export default function SettingsPage() {
return <main>설정 화면</main>;
}
로그인한 채로 /settings에 들어가면 어떻게 될까요. 화면이 뜰 것 같죠.
404가 납니다.
연습용 서버 로그를 보면 이유가 드러나요.
GET /api/users/settings
문지기가 /settings를 보고 "settings라는 사람이 있나"를 물어본 겁니다. 그런 사람은 없으니 404가 돌아왔고, D-7에서 우리가 만든 404 회복이 그대로 동작해서 없는 페이지 화면을 그렸어요.
문지기 입장에서는 정상 동작입니다. /jaehoon이 사람 이름이듯 /settings도 사람 이름이라고 본 거니까요. 둘을 구별할 방법이 문지기에게 없습니다.
G-2에서 /signup이 정확히 이 문제를 겪었죠. 그때는 주소가 하나뿐이라 목록에 넣고 끝냈습니다. 그런데 matcher를 넓힌 지금은 앞으로 만드는 모든 새 주소가 이 문제를 겪다 보니, 정확한 대가는 이렇습니다.
새 화면을 만들 때마다 공개 목록을 함께 챙겨야 한다. 안 챙기면 그 화면은 404가 되는데, 빌드도 통과하고 오류도 안 나서 직접 들어가 보기 전엔 모른다.
이걸 없애려면 문지기가 사람 이름을 확인하는 검사를 프로필 주소에만 걸면 됩니다. 하지만 그러면 "어떤 주소가 프로필인가"를 문지기가 또 알아야 하고 그 목록도 새 주소가 생길 때마다 챙겨야 합니다. 어느 쪽이든 어딘가에 목록이 하나 필요하니, 오늘은 이미 있는 목록을 쓰는 쪽으로 갑니다.
그리고 없는 페이지 화면도 문지기를 거치게 됐습니다
작은 것 하나 더요. 404 회복은 주소를 /_not-found로 바꿔서 그리는 방식이었습니다. 그런데 matcher가 모든 주소를 보니 그 /_not-found도 문지기를 거칩니다.
/_not-found 거침
문지기는 그것도 사람 이름으로 보고 한 번 더 물어봐요. 화면은 정상이고 상태 코드도 404로 맞게 나오지만, 연습용 서버에는 한 사람을 찾는 요청이 더 갑니다. 지금 우리 규모에서는 문제가 안 되지만, 넓히는 결정이 이런 것까지 함께 데려온다는 건 알고 있는 편이 좋아요.
💡 한 줄 정리
문지기가 모든 화면 주소를 보게 만들면 헤더를 빠짐없이 붙일 수 있습니다. 하지만 그 대가로 새 주소를 만들 때마다 공개 목록을 함께 챙겨야 하고, 안 챙기면 조용히 404가 됩니다.
🙋 학생 질문 — "그냥 헤더만 붙이고 나머지 검사는 건너뛰게 하면 안 되나요?"
좋은 생각이고, 실제로 그렇게 씁니다. 다음 Step에서 우리가 쓰는 모양이 정확히 그거예요.
문지기 안에서 헤더를 붙이는 일은 모든 주소에 하고, 로그인 확인이나 사람 이름 확인은 필요한 주소에만 합니다. 공개 목록에 들어 있는 주소는 뒤의 두 검사를 건너뛰고 바로 통과시키되, 헤더는 붙여서 내보내는 거죠.
지금 우리 코드도 이미 그 모양입니다. 공개 목록에 걸리면 곧바로 통과시키고 있잖아요. 다음 Step에서 헤더를 붙일 때, 그 통과시키는 길에도 헤더가 실리게 만들 겁니다. 통과시키는 길이 여러 개라서 한 군데라도 빠뜨리면 그 주소만 조용히 보호가 빠지다 보니, 헤더를 붙이는 일을 함수 하나로 모아두고 모든 출구가 그 함수를 거치게 할 거예요.
Step 3: "요청마다 다른 번호표"
번호표라는 아이디어
우리에게 필요한 건 이거였죠. 인라인 스크립트 34개 중 우리가 넣은 것만 골라서 허용하기.
방법은 생각보다 단순합니다. 요청이 올 때마다 아무도 못 맞히는 값을 하나 만들어서, 우리가 넣은 스크립트 태그에는 그 값을 적어두고, 규칙에도 "이 값이 적힌 스크립트는 허용"이라고 쓰는 거예요.
이 값을 nonce라고 부릅니다. number used once를 줄인 말이고, 한 번 쓰고 버리는 값이라는 뜻이에요.
공격자가 우리 페이지에 스크립트를 밀어 넣어도 그 값을 모르고, 요청마다 새로 만들어지니 지난번 값을 베껴 와도 안 통하다 보니, 예측 불가능하다는 성질이 이 방법의 전부입니다. 값을 고정해두면 아무 의미가 없어요.
설정 파일에서 걷어내고 문지기로 옮깁니다
Step 1에서 next.config.ts에 넣었던 헤더는 지워주세요. 두 곳에서 CSP를 보내면 브라우저가 둘 다 적용해서 더 엄격한 쪽에 맞춰버립니다. 우리가 의도하지 않은 조합이 생겨요.
문지기에서 만듭니다.
// apps/web-next/proxy.ts
export async function proxy(request: NextRequest) {
// 요청마다 새로 만든다. 남이 예측할 수 있으면 표시를 흉내 낼 수 있다.
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const isDev = process.env.NODE_ENV === 'development';
// 개발 서버에서만 'unsafe-eval' 을 연다.
// React 가 서버 오류를 브라우저에서 다시 보여주려고 eval 을 쓰기 때문이고,
// 프로덕션에서는 쓰지 않는다.
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data: https://picsum.photos`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
].join('; ');
한 줄씩 읽어볼게요.
script-src에'nonce-...'가 들어갔습니다. 이 번호표가 적힌 스크립트는 허용한다는 뜻이에요.img-src에https://picsum.photos를 넣었습니다. 다만 D-9에서 우리가 사진을 가져오기로 한 곳이지만 이 줄은 지금 당장은 없어도 됩니다 — 왜 그런지는 과제에서 직접 확인해보세요. 나중에 사진을 다른 방식으로 넣을 때를 대비해 남겨둡니다.style-src에'unsafe-inline'을 열어뒀어요. 이름이 불안해 보이는데, 스타일에는 이 부분을 여는 게 일반적입니다. 스타일로 할 수 있는 나쁜 짓이 스크립트보다 훨씬 제한적이거든요.frame-ancestors 'none'은 남의 사이트가 우리 화면을 틀 안에 넣어 띄우지 못하게 합니다. 이건 다음 시간 이야기의 앞자락이에요.
Next에게도 번호표를 알려줘야 합니다
만든 값을 응답 헤더에만 넣으면 규칙은 생기는데 정작 스크립트 태그에는 번호표가 안 붙습니다. Next가 그 값을 알아야 자기가 만드는 태그마다 적어줄 수 있다 보니, 요청 헤더에도 실어서 안쪽으로 넘깁니다.
// apps/web-next/proxy.ts
// Next 가 script 태그에 번호표를 붙이려면 요청 헤더로도 받아야 한다.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);
Next는 요청에 실려 온 Content-Security-Policy를 읽고 'nonce-…' 부분을 뽑아내서, 자기가 만드는 모든 script 태그에 그 값을 자동으로 붙여줍니다. 우리가 태그를 하나하나 찾아다닐 필요가 없습니다.
나가는 길이 넷입니다
여기가 이 Step에서 가장 실수하기 쉬운 곳이에요. 우리 문지기는 끝나는 길이 네 개입니다. POST 통과, 공개 주소 통과, 로그인 안 했을 때 되돌려보내기, 없는 사람일 때 404. 이 중 하나라도 헤더를 안 붙이면 그 경로로 나간 응답만 보호가 빠지다 보니, 붙이는 일을 함수 하나로 모으고 모든 출구가 그걸 거치게 합니다.
// apps/web-next/proxy.ts
// 나가는 길이 넷이라 한 군데라도 빠뜨리면 그 주소만 조용히 보호가 빠진다.
// 그래서 붙이는 일을 여기 한 곳에 모으고, 모든 출구가 이걸 거치게 한다.
const withCsp = <T extends NextResponse>(response: T): T => {
response.headers.set('Content-Security-Policy', csp);
return response;
};
const pass = () => withCsp(NextResponse.next({ request: { headers: requestHeaders } }));
기존의 return NextResponse.next()는 전부 return pass()로, 되돌려보내기와 404는 withCsp(...)로 감싸면 됩니다. 다 고치고 나면 파일에 맨 NextResponse.next()가 하나도 안 남아야 해요.
재봅니다
확인할 게 셋입니다.
하나, 네 주소에 다 붙었나.
/ CSP 헤더 1개
/explore CSP 헤더 1개
/signup CSP 헤더 1개
/jaehoon CSP 헤더 1개
둘, 요청마다 값이 다른가. 같은 주소를 세 번 부르고 번호표 앞부분만 잘라 보세요.
nonce-MTA5NWIwMTAt
nonce-YzRlMDFiM2Mt
nonce-Mzk2ZmI5MDUt
Step 1에서 세 번 다 똑같았던 것과 대비되는데, 이제 매번 새 값이에요.
셋, 스크립트에 번호표가 붙었나. 홈 화면 HTML에서 script 태그를 세고, 그중 nonce=가 적힌 것을 세어보세요.
script 총 49 = 외부 15 (nonce 15) + 인라인 34 (nonce 34)
49개 전부입니다. 하나도 안 빠졌고, Step 1에서 막혔던 함수도 돌아왔습니다.
> typeof $RC
"function"
화면을 열어보면 피드도 사진도 정상이고 좋아요도 눌리는데, 우리는 방금 엄격한 CSP를 켠 채로 앱을 온전히 돌리는 데 성공한 셈이에요.
여기까지가 공식 문서가 안내하는 방법 그대로인데, 개발 서버에서는 완벽하게 동작하죠.
💡 한 줄 정리
요청마다 새로 만든 번호표를 응답 헤더와 요청 헤더 양쪽에 실으면, Next가 자기가 만드는 script 태그마다 그 값을 붙여주고, 개발 서버에서는 49개 전부가 허용받아 앱이 그대로 동작합니다.
🙋 학생 질문 — "번호표를 응답 헤더에 실어 보내면 공격자도 그 값을 볼 수 있는 것 아닌가요?"
맞습니다. 응답 헤더는 브라우저 개발자 도구에서 누구나 볼 수 있어요. 그런데도 안전한 이유는 공격자가 그 값을 보는 시점 때문입니다.
공격이 성립하려면 공격자가 심어둔 문자열이 우리 페이지 HTML 안에 들어간 상태로 브라우저에 도착해야 하는데, 그 문자열은 공격자가 미리 저장해둔 것이라 피해자가 페이지를 여는 순간 만들어지는 번호표를 그 시점에 알 방법이 없습니다. G-4에서 댓글에 넣었던 것과 같은 경로예요.
자기 브라우저로 우리 사이트를 열어 자기 번호표를 확인할 수는 있습니다. 다만 그건 자기 요청의 값이고 피해자의 요청에는 다른 값이 발급되기 때문에, 요청마다 새로 만든다는 조건이 이 방법의 전부예요. 값을 상수로 박아두거나 사용자별로 고정하면 그 순간 방어가 사라집니다.
Step 4: "올렸더니 아무것도 안 눌립니다"
배포 전 점검은 전부 통과합니다
개발 서버에서 잘 되니 이제 내보낼 차례죠. D-10에서 배운 대로 빌드하고 컨테이너용 결과물로 띄웁니다.
빌드부터 봅시다.
Route (app)
┌ ◐ /
├ ◐ /_not-found
│ └ ◐ /[username]
├ ƒ /api/auth/[...all]
├ ƒ /api/profiles/revalidate
├ ◐ /explore
└ ◐ /signup
종료 코드 0, 오류 0. 라우트 표도 지난 시간과 글자 하나 안 다릅니다. 전부 ◐ 그대로예요. D-5에서 켠 Cache Components와 부딪히는 기색이 전혀 없습니다.
띄우고 홈에 요청해보면 상태 코드도 200입니다. 브라우저로 열어보면 피드가 보이고, 사진 열 장이 다 뜨고, 글자도 정상이에요.
여기까지 보면 성공입니다. 실제로 이 상태로 배포하는 경우가 있어요.
그런데 좋아요가 안 눌립니다
하트를 눌러보세요. 아무 일도 안 일어납니다. 몇 번을 눌러도요.
무슨 일이 벌어진 건지 재보려면, 브라우저가 실제로 받아온 자바스크립트 파일이 몇 개인지 개발자 도구 콘솔에서 세어보면 됩니다.
> performance.getEntriesByType('resource')
.filter(e => e.name.includes('/_next/static/chunks/') && e.decodedBodySize > 0).length
개발 서버 15
프로덕션 0
0개입니다. 브라우저가 우리 앱의 자바스크립트를 한 바이트도 안 받았어요. React는 애초에 시작조차 못 했습니다. 화면에 보이는 건 전부 서버가 그려 보낸 HTML이고, 그 위에 아무것도 안 얹힌 상태예요.
번호표가 어디까지 붙었는지 봅니다
개발 서버에서는 49개 전부에 번호표가 붙었죠. 프로덕션에서 같은 걸 세어보면 이렇게 갈립니다.
전체 외부(파일) 인라인
개발 서버 49 15 중 15개 34 중 34개
프로덕션 42 10 중 1개 32 중 31개
인라인은 32개 중 31개가 받았는데, 외부 파일은 10개 중 1개만 받았습니다. 못 받은 9개가 바로 우리 앱을 굴리는 파일들이에요. 프레임워크 런타임이 거기 들어 있습니다.
그러니까 지금 상태는 이렇습니다. 서버가 보낸 글자를 채워 넣는 인라인 스크립트들은 번호표를 받아서 잘 돌았고, 그래서 화면은 멀쩡히 보이지만 그 화면을 살아 움직이게 만들 파일들은 전부 거절당했습니다.
절반은 아직 동작합니다
재미있는 건 앱이 통째로 죽은 게 아니라는 점이에요. 프로덕션 HTML에서 폼 태그를 세어보면 두 종류가 나옵니다.
10개 <form action="javascript:throw new Error('React form unexpectedly submitted.')">
12개 <form action="" encType="multipart/form-data" method="POST">
앞엣것이 좋아요 버튼입니다. D-8에서 우리가 useOptimistic을 얹으면서 자바스크립트 전용 폼이 됐죠. 자바스크립트가 없으면 정말 아무 일도 안 일어납니다.
뒤엣것은 댓글·로그인·팔로우 폼이에요. 이건 평범한 HTML 폼이라 자바스크립트가 하나도 없어도 제출됩니다. 실제로 지금 상태에서 댓글을 쓰면 써지고, 로그인도 됩니다.
D-7에서 "서버 액션 폼은 평범한 HTML 폼이다"를 확인했던 게 여기서 값을 하는데, 그때 얻은 성질 덕분에 앱의 절반이 살아남았지만 좋아요가 안 눌리는 앱을 배포할 수는 없습니다.
가장 무서운 건 아무도 안 알려준다는 것입니다
정리해보면 이렇습니다.
빌드 종료 코드 0 · 오류 0
라우트 표 지난 시간과 동일
타입 검사 0
린트 0
상태 코드 200
화면 정상 (사진 10장 · 글자 정상)
개발 서버 완벽하게 동작
─────────────────────────────
실제로는 좋아요가 안 눌린다
우리가 배포 전에 확인하는 것들이 전부 초록불인데, 사용자는 버튼이 안 눌리는 앱을 받습니다.
D-4에서 loading.tsx 하나가 404를 조용히 200으로 바꿨던 것, D-6에서 캐시 안에서 쿠키를 읽었는데 빌드가 통과했던 것과 같은 종류입니다. 통과했다는 것이 맞다는 뜻은 아니에요.
다음 Step에서 원인을 찾습니다. 범인은 우리가 Step 3에서 적은 규칙 안에 있어요.
💡 한 줄 정리
공식 문서 예제 그대로 CSP를 켰더니 개발 서버에서는 49개 전부가 번호표를 받았는데, 프로덕션에서는 외부 파일 10개 중 9개가 번호표를 못 받아 앱 자바스크립트가 한 바이트도 안 실렸습니다. 그런데도 빌드·타입·린트·상태 코드·화면이 전부 정상으로 보입니다.
🙋 학생 질문 — "개발 서버에서는 왜 전부 붙었나요? 같은 코드인데요."
같은 코드지만 화면을 만드는 시점이 다릅니다.
개발 서버는 요청이 올 때마다 화면을 처음부터 만들어요. 그 순간 문지기가 번호표를 넘겨준 상태니까, script 태그를 만들면서 전부 붙일 수 있다 보니, 49개 중 49개입니다.
프로덕션은 다릅니다. 화면의 일부가 빌드할 때 미리 만들어져 있어요. 우리가 D-5에서 켠 것이 바로 그거죠. 미리 만들어두고 요청이 오면 그걸 먼저 내보낸 다음 나머지를 채우는 방식인데, 빌드는 요청이 오기 전에 돌아갑니다. 그 시점에는 문지기가 돈 적이 없고, 따라서 번호표도 없어요. 미리 만들어둔 부분에 들어 있는 script 태그는 붙일 값 자체가 없었던 겁니다.
다음 Step에서 이걸 정확히 확인하고, 이 조건에서 우리가 고를 수 있는 선택지를 봅니다.
Step 5: "한 단어가 'self'를 지우고 있었습니다"
범인을 좁힙니다
Step 3에서 우리가 적은 줄을 다시 봅시다.
script-src 'self' 'nonce-...' 'strict-dynamic'
세 개가 나란히 있어요. 'self'는 "우리 출처 파일 허용", 'nonce-...'는 "이 번호표가 적힌 것 허용"입니다.
그럼 'strict-dynamic'은 뭘까요. 이름만 보면 "더 엄격하게" 정도로 읽히죠. 실제로 하는 일은 이렇습니다.
번호표를 받은 스크립트가 불러온 스크립트는 따로 번호표가 없어도 믿는다. 그리고
'self'같은 출처 기반 허용은 전부 무시한다.
두 번째 줄이 범인입니다. 'strict-dynamic'을 넣는 순간 'self'가 적혀 있어도 없는 것과 같아져요.
앞뒤가 맞다는 것이 여기서 드러나는데, 프로덕션에서 번호표를 못 받은 외부 파일 9개는 우리 출처('self')에서 오는 파일이었어요. 원래대로면 그 한 단어로 허용됐을 텐데, 'strict-dynamic'이 그 허용을 지워버린 겁니다. 남은 판단 기준은 번호표뿐인데 그 9개엔 번호표가 없었고요.
왜 그 9개는 번호표를 못 받았을까요
바로 앞 질문에서 짚은 그대로입니다. 그 script 태그들은 빌드할 때 미리 만들어진 화면 조각에 들어 있어요. 빌드는 요청보다 먼저 돌아가니 그 시점엔 번호표가 존재하지 않는데, 이건 우리가 실수한 게 아니라 원리적으로 그렇습니다. 실제로 Next 공식 문서가 CSP 문서 안에서 직접 말하고 있어요.
Partial Prerendering (PPR) is incompatible with nonce-based CSP since static shell scripts won't have access to the nonce.
미리 그려둔 껍데기의 스크립트는 번호표에 접근할 수 없다는 뜻입니다. 그런데 우리는 D-5에서 그 기능을 켰고 라우트 표의 ◐가 전부 그 표시이며, 우리 앱은 문서가 말한 그 조합에 정확히 해당합니다.
도망갈 길이 있는지 하나씩 확인해봤습니다
문서는 "페이지를 완전히 동적으로 만들면 된다" 고 안내합니다. 세 가지를 시도해볼 수 있어요.
하나, 미리 그리기를 끈다. cacheComponents를 꺼보면 빌드가 종료 코드 1로 죽습니다. D-6에서 넣은 cacheLife·cacheTag가 그 스위치를 전제로 하거든요. 설정 한 줄을 되돌리는 일이 아니라 지난 시간 작업을 걷어내는 일이 됩니다.
둘, 라우트 하나만 완전 동적으로 만든다. 라우트 표를 다시 보세요.
◐ / ◐ /_not-found ◐ /[username]
◐ /explore ◐ /signup
ƒ /api/auth/[...all] ƒ /api/profiles/revalidate
화면 주소는 전부 ◐입니다. ƒ는 API 주소 둘뿐이고 그건 화면이 아니에요. 미리 그리기를 켠 상태에서는 화면 라우트를 완전 동적으로 만들 수가 없습니다.
셋, 문서가 제시하는 다른 방법을 쓴다. 번호표 대신 파일 지문을 쓰는 방식이 있는데, 이건 옛 번들러 전용이라 Next 16의 기본 번들러에서는 동작하지 않습니다.
셋 다 막혔으니, 남은 선택은 하나입니다.
한 단어를 지웁니다
// apps/web-next/proxy.ts
// 'strict-dynamic' 은 일부러 뺐다. 그것을 넣으면 'self' 가 무효가 되는데,
// 우리는 미리 만들어둔 화면 조각에 번호표 없는 script 태그가 남는다.
// 그 태그들이 전부 거절돼 앱 자바스크립트가 한 바이트도 안 실린다.
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ''}`,
다시 빌드해서 프로덕션으로 띄우고, Step 4와 똑같은 방법으로 재봅니다.
받은 JS 파일
CSP 없음 9
nonce + 'self' + 'strict-dynamic' 0
nonce + 'self' 8
돌아왔습니다. 브라우저가 484KB 정도를 받아갔고, 하트도 다시 눌립니다.
무엇을 내주고 되찾았는지 정직하게 봅시다
공짜가 아니에요. 'strict-dynamic'을 뺐다는 건 'self'가 다시 살아났다는 뜻이고, 그건 우리 출처에서 오는 스크립트 파일은 번호표 없이도 전부 허용된다는 뜻이라서, 누군가 우리 서버에 파일을 올릴 수 있게 되고 그 파일이 우리 주소로 서빙된다면 막지 못합니다. 그건 "우리 출처" 니까 통과해요.
그럼 우리가 지난 시간에 막았던 건 어떻게 됐을까요. 확인해봤습니다. 번호표 없는 인라인 스크립트를 서버가 그린 HTML에 심어두고 프로덕션에서 열어보면요.
nonce 없는 인라인 → 실행되지 않음
여전히 막힙니다. 'self'는 파일에 대한 허용이지 인라인에 대한 허용이 아니거든요. 인라인을 허용하려면 'unsafe-inline'이라는 별도의 단어를 적어야 하는데 우리는 안 적었습니다.
지금 우리 방어선은 이렇습니다.
막는다 댓글·소개글 같은 입력으로 밀어 넣은 인라인 스크립트
막는다 남의 출처에서 불러오는 스크립트
못 막는다 우리 출처에 올라온 스크립트 파일
G-4에서 다룬 공격은 첫 줄에 해당합니다. 그 방어는 유지됐어요. 세 번째 줄은 우리 앱에 파일 업로드가 생기면 그때 다시 봐야 할 문제고요.
오늘 배운 방식 자체가 남습니다
이 Step에서 우리가 한 일을 되짚어보면, 사실 CSP 지식보다 좁혀 들어간 순서가 더 오래 남습니다.
증상 프로덕션에서만 버튼이 안 눌린다
측정 받은 JS 파일 수 — 개발 15 · 프로덕션 0
좁히기 번호표 분포 — 외부 10개 중 1개만
가설 'self' 가 안 먹고 있다
확인 한 단어를 빼고 같은 방법으로 재측정 → 0에서 8로
증상에서 곧장 원인으로 뛰지 않고 갈리는 지점을 숫자로 찾아 들어간 이 순서는, C-8에서 성능을 다룰 때 "측정 먼저"라고 했던 것과 같습니다.
💡 한 줄 정리
'strict-dynamic'은 번호표를 못 받은 스크립트를 막는 대신 'self'를 통째로 무효로 만듭니다. 그런데 미리 그려둔 화면 조각의 스크립트는 원리적으로 번호표를 받을 수 없어서, 그 한 단어를 빼고 'self'를 살리는 쪽을 골랐습니다.
🙋 학생 질문 — "공식 문서 예제를 그대로 썼는데 앱이 죽는다는 게 이해가 안 돼요. 문서가 틀린 건가요?"
문서가 틀린 건 아니고, 문서의 예제와 우리 앱의 조건이 다릅니다.
그 예제는 모든 페이지가 요청 때마다 만들어지는 앱을 가정합니다. 그런 앱에서는 모든 script 태그가 요청 시점에 생기니까 번호표를 다 받을 수 있어요. 'strict-dynamic'도 문제없이 동작하고요.
우리 앱은 D-5에서 "굳힐 건 굳히고 흘려보낼 건 흘려보내자"는 결정을 했습니다. 그리고 화면 일부가 빌드 때 미리 만들어졌고, 그 결정이 오늘 조건을 바꾼 겁니다.
같은 문서 안에 이 충돌이 실제로 적혀 있고, 예제 코드에는 안 적혀 있어요. 예제만 복사하면 못 보고 지나갑니다.
여기서 얻을 교훈은 이렇습니다. 문서의 예제는 그 문서가 가정한 조건에서 맞는 코드입니다. 우리 앱이 그 조건과 다르면 그대로 써도 안 돌아가고, 오늘처럼 그 어긋남이 개발 서버에서는 안 드러나고 프로덕션에서만 드러나는 경우가 있습니다.
마무리
오늘 배운 핵심 세 가지
💡 하나. 규칙을 어디에 두느냐가 무엇을 포기할지 정합니다. 설정 파일은 모든 주소에 걸리는 대신 요청마다 다른 값을 못 주고, 문지기는 값은 만들지만 보는 주소가 정해져 있었어요. 우리는 문지기를 골라 시야를 넓혔고, 그 대가로 새 주소를 만들 때마다 공개 목록을 챙길 부채를 졌습니다. 어느 쪽을 골라도 뭔가는 따라옵니다.
💡 둘. 개발 서버가 통과시킨 것이 프로덕션에서도 통과한다는 보장은 없습니다. 똑같은 코드가 개발에서는 49개 전부 성공하고 프로덕션에서는 앱 자바스크립트를 0바이트 받았어요. 갈린 이유는 화면을 언제 만드느냐였습니다. 빌드 때 만들어진 조각은 요청 시점의 값을 가질 수 없으니까요.
💡 셋. 초록불이 전부 켜져도 틀릴 수 있습니다. 빌드도, 타입 검사도, 린트도, 상태 코드도, 화면도 정상이었는데, 버튼이 안 눌렸어요. 우리가 평소에 보는 신호들이 어디까지 봐주는지를 알고 있어야 합니다. 그것들은 "이 코드가 실행 가능한가"를 보지 "사용자가 쓸 수 있는가"를 보지 않아요.
다음 시간 예고
오늘 우리는 규칙을 세웠지만, 아직 확인 안 한 게 있어요. 이 규칙이 실제로 무엇을 막고 있나요?
지난 시간에 댓글로 밀어 넣었던 그 공격 문자열, 지금 다시 넣으면 어떻게 될까요. 다음 시간 첫머리에서 그걸 직접 확인하면서, 막혔는데도 그 태그는 화면 안에 그대로 남아 있다는 것을 눈으로 보게 될 거예요. CSP는 구멍을 메우는 게 아니라 구멍으로 들어온 것의 실행만 막거든요.
그다음에는 오늘 세운 규칙이 원리적으로 못 막는 것으로 넘어갑니다. 남의 사이트가 자기 쪽에서 우리 앱으로 요청을 보내는 경우예요. 우리 페이지 안에서 코드가 도는 게 아니라 아예 밖에서 요청이 날아오는 거라, 오늘 붙인 헤더로는 손댈 수가 없습니다.
그중 하나는 이미 막혀 있고 하나는 활짝 열려 있어요. 열려 있는 쪽은 D-7에서 우리가 직접 만든 것입니다. 그걸 우리 손으로 닫고, 마지막으로 비밀값을 어디에 둬야 하는지까지 정리하면 이 두 시간이 끝납니다.
과제
[구현] 새 화면을 하나 만들어보세요
Step 2에서 예고한 부채를 직접 겪어보는 과제입니다.
설정 화면을 하나 만드세요. 내용은 아무거나 좋습니다.
// apps/web-next/app/settings/page.tsx
export default function SettingsPage() {
return <main>설정 화면</main>;
}
로그인한 채로 /settings에 들어가 보세요. 무슨 일이 일어나는지 확인하고, 연습용 서버 로그에서 그 이유를 찾아보세요.
그다음 고치세요. 가장 먼저 떠오르는 방법으로 고쳐보고, 고친 뒤에 로그아웃 상태로 다시 들어가 보세요. 거기서 두 번째 문제를 만나게 될 겁니다.
그 두 번째 문제까지 해결하려면 문지기가 하는 일 두 가지를 갈라야 해요. 지금 문지기는 공개 주소 목록에 걸리면 두 검사를 한꺼번에 건너뜁니다. 로그인 확인과 사람 이름 확인이죠. 설정 화면에는 그중 하나만 건너뛰어야 합니다.
최종 상태에서 아래 셋이 전부 맞아야 합니다.
/settings 로그인 200 · 로그아웃 307
/zzzzz 로그인 404 ← D-7 의 404 가 살아 있어야 한다
/jaehoon 로그인 200
[구현] 출구 하나에서 헤더를 빼보세요
Step 3에서 "나가는 길이 넷이라 하나라도 빠뜨리면 그 주소만 조용히 보호가 빠진다" 고 했죠. 정말 그런지 직접 만들어보는 과제입니다.
네 출구 중 없는 사람일 때 404를 그리는 출구에서 withCsp를 벗겨내고, 주소별로 CSP 헤더가 붙는지 세어보세요.
어느 주소가 보호를 잃었나요? 그 주소의 응답 본문 크기도 함께 재보세요. 빈 응답인지 아니면 실제로 화면을 그려서 보내는지가 중요합니다.
그다음 생각해보세요. 이 상태를 평소에 앱을 써보는 것만으로 발견할 수 있을까요? 발견하려면 무엇을 해야 하는지 한 문장으로 적어보세요.
[탐구] 우리 규칙에서 필요 없는 줄을 찾아보세요
Step 3에서 img-src에 https://picsum.photos를 적었고, 그 줄이 지금은 없어도 된다고 했습니다. 왜 그런지 직접 확인하는 과제예요.
먼저 홈 화면 HTML에서 <img> 태그의 src를 하나 찾아보세요. 어느 주소를 가리키고 있나요? D-9에서 우리가 무엇을 도입했는지 떠올려보면 답이 보일 겁니다.
그다음 img-src에서 picsum을 빼고 브라우저에서 사진이 뜨는지 확인하세요.
⚠️ 여기서 재는 방법을 조심해야 합니다. 화면 아래쪽 사진들은 스크롤해서 보이기 전까지 안 불러오다 보니, "사진이 안 보인다"를 CSP 탓으로 오해하기 쉬워요. 탭을 앞에 두고, 아래로 스크롤해서, 막기 전과 후를 같은 방법으로 재세요. 대조군이 없으면 틀린 결론이 나옵니다.
마지막으로 답해보세요. 그럼 그 줄은 지워야 할까요, 남겨둬야 할까요? 어떤 경우에 그 줄이 필요해지는지도 함께 적어보세요.
[탐구] 표를 직접 채워보세요
오늘 우리가 겪은 갈림을 손으로 재현하는 과제입니다. 'strict-dynamic'을 다시 넣고 아래 표를 채우세요.
| 개발 서버 | 프로덕션 | |
|---|---|---|
| script 태그 수 | ||
| 그중 번호표를 받은 수 | ||
| 브라우저가 받은 JS 파일 수 | ||
| 하트가 눌리나 |
'strict-dynamic'을 뺀 상태로 같은 표를 한 번 더 채우세요.
표 여덟 칸 중 개발 서버 쪽 네 칸이 두 경우에 똑같이 나올 겁니다. 그게 오늘 사건의 핵심이에요. 왜 개발 서버로는 이 문제를 절대 못 잡는지 그 표를 근거로 설명해보세요.
생각해볼 주제
1. 개발에서만 통과하는 문제를 어떻게 미리 잡을까요
오늘 우리는 개발 서버에서 완벽하게 동작하던 것이 프로덕션에서 앱을 멈춰 세우는 걸 봤습니다. 그런데 빌드·타입 검사·린트·상태 코드가 전부 초록불이었죠.
이런 종류의 문제를 배포 전에 잡으려면 무엇이 필요할까요. 우리가 지금 갖고 있는 확인 수단들이 왜 이걸 못 잡았는지부터 짚어보고, 무엇을 하나 더 두면 잡을 수 있었을지 생각해보세요.
2. 'self'를 살린 대가는 언제 청구될까요
우리는 'strict-dynamic'을 빼면서 "우리 출처에서 오는 스크립트 파일은 전부 허용"이라는 조건을 되살렸습니다. 지금 우리 앱에서는 문제가 안 돼요. 우리 출처에 파일을 올릴 수 있는 사람이 우리뿐이니까요.
그런데 인스타그램 클론에 언젠가 추가될 법한 기능 중에 이 전제를 깨는 것이 있습니다. 무엇일지 떠올려보고, 그때 우리가 무엇을 다시 확인해야 할지 정리해보세요.
3. 걸러내기와 미리 알려두기 중 무엇을 먼저 해야 할까요
지난 시간에는 들어온 값을 걸러냈고, 오늘은 브라우저에게 미리 알려뒀습니다. 둘 다 같은 공격을 막는 방법이에요.
만약 시간이 부족해서 하나만 할 수 있다면 어느 쪽을 먼저 해야 할까요. 그리고 오늘 우리가 겪은 일을 떠올려보면, 둘 중 하나가 다른 하나보다 잘못 켰을 때의 대가가 큽니다. 그 점이 순서를 정하는 데 어떤 영향을 주는지도 함께 생각해보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 새 화면을 하나 만들어보세요
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 404 가 나는 이유를 서버 로그로 확인했는가 | 6 | GET /api/users/settings를 찾아냈다 |
| 첫 번째 해결 후 두 번째 문제를 발견했는가 | 7 | 로그아웃 상태로 다시 들어가 봤다 |
| 두 검사를 갈라서 해결했는가 | 8 | 로그인 확인은 남기고 사람 이름 확인만 건너뛴다 |
| 세 주소가 모두 기대대로인가 | 4 | /zzzzz의 404 가 살아 있다 |
풀이 예시
화면을 만들고 들어가면 404가 납니다. 연습용 서버 로그에 이유가 남아요.
GET /api/users/settings
문지기가 /settings를 사람 이름으로 보고 "그런 사람 있나"를 물어본 겁니다. 없으니 404가 왔고, D-7의 404 회복이 정상 동작해서 없는 페이지를 그렸어요.
가장 먼저 떠오르는 해결은 공개 주소 목록에 넣는 것이죠.
// apps/web-next/proxy.ts
const PUBLIC_PATHS = ['/', '/explore', '/signup', '/settings'];
/settings가 200으로 뜨는데, 로그아웃 상태로 들어가 보면 그것도 200이에요. 설정 화면이 아무나 볼 수 있게 열렸습니다.
원인은 공개 주소 목록이 두 가지를 한꺼번에 건너뛰기 때문입니다. 로그인 확인과 사람 이름 확인이죠. 설정 화면에는 뒤엣것만 건너뛰면 되니까, 목록을 하나 더 둡니다.
// apps/web-next/proxy.ts
const PUBLIC_PATHS = ['/', '/explore', '/signup'];
// 로그인은 필요하지만 사람 이름이 아닌 주소. 프로필 확인만 건너뛴다.
const NON_PROFILE_PATHS = ['/settings'];
로그인 확인이 끝난 뒤, 사람 이름을 묻기 직전에 걸러냅니다. 순서가 전부예요.
// apps/web-next/proxy.ts
// 사람 이름이 아닌 주소는 여기서 끝난다. 로그인 확인은 이미 위에서 했다.
if (NON_PROFILE_PATHS.includes(pathname)) {
return pass();
}
const username = pathname.slice(1);
확인해보면 셋 다 맞습니다.
/settings 로그인 200 · 로그아웃 307
/zzzzz 로그인 404
/jaehoon 로그인 200
자주 나오는 실수
공개 목록에 넣고 끝내기. 404가 사라지니 고쳐진 것처럼 보입니다. 로그아웃 상태로 다시 안 들어가 보면 발견할 수 없어요. 고친 뒤에 반대 조건으로 한 번 더 재보는 습관이 이 과제의 진짜 목표입니다.
새 검사를 로그인 확인보다 위에 두기. 위에 두면 로그인 확인을 건너뛰게 되어 공개 목록에 넣은 것과 결과가 같아집니다. 코드는 달라 보이는데 동작은 똑같아요.
💡 튜터의 한마디 — 이 과제가 드러내는 건 문지기 코드가 아니라 목록이 늘어난다는 사실입니다. 오늘 우리는 헤더를 빠짐없이 붙이려고 문지기의 시야를 넓혔고, 그 대가로 목록을 하나 더 얻었어요. 넓히는 결정에는 언제나 이런 뒷정리가 따라옵니다. 지금은 두 개지만 화면이 서른 개가 되면 이 방식이 버틸지 그때 다시 물어야 해요.
🎯 [과제 2 예시답안] 출구 하나에서 헤더를 빼보세요
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 보호를 잃은 주소를 특정했는가 | 7 | /zzzzz 같은 없는 사람 주소 |
| 다른 주소는 멀쩡하다는 것을 확인했는가 | 6 | /jaehoon은 헤더가 그대로 |
| 응답 본문 크기를 재서 의미를 파악했는가 | 7 | 빈 응답이 아니라 실제 화면이다 |
| 발견 방법을 한 문장으로 적었는가 | 5 | 주소별로 헤더를 전수 확인해야 한다 |
풀이 예시
404 출구에서 withCsp를 벗겨냅니다.
// 일부러 망가뜨린 상태 — 확인용입니다
return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 });
로그인한 채로 주소별 헤더 수를 세면 이렇게 갈립니다.
/jaehoon 상태 200 · CSP 헤더 1개 · 본문 26,701 B
/zzzzz 상태 404 · CSP 헤더 0개 · 본문 48,520 B
없는 사람 주소만 보호를 잃었습니다.
본문 크기가 중요한 이유는 이겁니다. 되돌려보내기 출구였다면 응답에 내용이 거의 없어서 실질 피해가 적어요. 그런데 이 출구는 48KB 짜리 화면을 실제로 그려서 보냅니다. 그 화면에는 우리 머리말·검색칸이 다 들어 있고, 사용자가 실제로 보는 페이지예요. 거기에만 규칙이 없습니다.
발견 방법: 주소를 하나씩 넣어가며 응답 헤더를 전수로 세어보는 수밖에 없습니다. 화면을 눌러보는 것으로는 못 찾아요.
찾기 어려운 이유를 정리하면 이렇습니다.
화면 정상으로 보인다
상태 코드 404 로 맞게 나온다
빌드·타입·린트 전부 통과
콘솔 아무 경고도 없다
CSP가 빠졌다는 사실은 아무 신호도 만들지 않습니다. 있으면 위반이 보이지만 없으면 조용해요.
자주 나오는 실수
로그인 상태로만 재기. 되돌려보내기 출구는 로그아웃 상태에서만 지나갑니다. 한쪽 조건으로만 재면 출구 네 개 중 둘밖에 못 봐요.
💡 튜터의 한마디 — 오늘 Step 3에서 헤더 붙이는 일을 함수 하나로 모은 게 취향 문제가 아니었다는 걸 이 과제가 보여줍니다. 출구가 넷인데 각각 따로 붙이면, 넷 다 맞게 붙였는지 확인할 방법이 네 조건을 전부 재보는 것뿐이에요. 한 곳에 모아두면 그 한 곳만 보면 됩니다. 보안 코드에서 "빠뜨려도 아무 일도 안 일어나는" 구조는 그 자체가 위험합니다.
🎯 [과제 3 예시답안] 우리 규칙에서 필요 없는 줄을 찾아보세요
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
<img>의 실제 출처를 확인했는가 |
7 | /_next/image?url=... — 우리 출처다 |
| D-9과 연결했는가 | 6 | next/image가 우리 서버를 거쳐 내보낸다 |
| 대조군을 두고 측정했는가 | 7 | 빼기 전과 후를 같은 방법으로 |
| 남길지 지울지 근거를 들어 답했는가 | 5 | 어떤 경우에 필요해지는지 |
풀이 예시
HTML에서 <img>의 src를 보면 답이 바로 나옵니다.
src="/_next/image?url=https%3A%2F%2Fpicsum.photos%2Fseed%2Fpost1%2F640%2F640&w=1920&q=75"
브라우저가 요청하는 주소는 picsum이 아니라 우리 서버의 /_next/image입니다. D-9에서 도입한 next/image가 사진을 우리 서버를 거쳐 다시 만들어 내보내기 때문이에요. 브라우저 입장에서 그건 우리 출처('self')에서 오는 이미지입니다.
그러니 img-src 'self' 만으로 이미 충분하고, https://picsum.photos는 지금 아무 일도 하지 않습니다.
빼고 재보면 사진은 그대로 뜹니다.
picsum 있음 화면에 그려진 사진 1장 / img 태그 10개
picsum 없음 화면에 그려진 사진 1장 / img 태그 10개
두 조건이 완전히 같아요. 나머지 9장이 안 그려진 건 CSP 때문이 아니라 화면 아래에 있어서 아직 안 불러온 것입니다.
답: 남겨둡니다. 지금은 필요 없지만, 나중에 next/image를 안 거치고 <img src="https://picsum.photos/...">처럼 직접 쓰는 곳이 생기면 그 순간 필요해집니다. 그때 사진이 안 뜨는 이유를 CSP에서 찾기는 쉽지 않아요.
다만 주석 없이 남기면 안 됩니다. 왜 있는지 모르는 줄은 다음 사람이 지우거나, 반대로 무서워서 못 건드리게 되거든요.
자주 나오는 실수
대조군 없이 "사진이 안 보인다"로 결론 내기. 지문의 경고가 이걸 노린 겁니다. 아래쪽 사진은 원래 스크롤 전엔 안 불러와요. 빼기 전 상태에서도 똑같이 안 보이는데, 그걸 안 재보면 CSP가 막았다고 오해합니다. 바꾸기 전 숫자를 먼저 적어두는 것이 유일한 방어입니다.
HTML 에 <img> 태그가 10개 있는 걸 보고 "안 막혔다"로 판단하기. CSP는 태그를 지우지 않습니다. 태그는 그대로 있고 불러오기만 막혀요. 태그 수를 세면 언제나 10개입니다.
💡 튜터의 한마디 — 이 과제의 답이 "필요 없다" 인데도 지우지 않는다는 게 재미있는 자리예요. 보안 설정은 지금 필요한가보다 없을 때 어떻게 드러나는가를 함께 봐야 합니다. 이 줄이 없어서 생기는 문제는 사진이 조용히 안 뜨는 형태로 나타나고, 원인을 CSP에서 찾기까지 한참 걸립니다. 대신 주석으로 이유를 남겨두세요.
🎯 [과제 4 예시답안] 표를 직접 채워보세요
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 두 경우의 표를 다 채웠는가 | 8 | 여덟 칸 |
| 개발 서버 네 칸이 같다는 것을 확인했는가 | 7 | 이 과제의 핵심 |
| 프로덕션에서 갈리는 것을 확인했는가 | 5 | 0 과 8 |
| 개발 서버로 못 잡는 이유를 표로 설명했는가 | 5 | 판별력이 없다 |
풀이 예시
'strict-dynamic' 있음
| 개발 서버 | 프로덕션 | |
|---|---|---|
| script 태그 수 | 49 | 42 |
| 그중 번호표를 받은 수 | 49 | 32 |
| 브라우저가 받은 JS 파일 수 | 15 | 0 |
| 하트가 눌리나 | 눌린다 | 안 눌린다 |
'strict-dynamic' 없음
| 개발 서버 | 프로덕션 | |
|---|---|---|
| script 태그 수 | 49 | 42 |
| 그중 번호표를 받은 수 | 49 | 32 |
| 브라우저가 받은 JS 파일 수 | 15 | 8 |
| 하트가 눌리나 | 눌린다 | 눌린다 |
개발 서버 열 네 칸이 두 표에서 완전히 같습니다.
이유는 이렇습니다. 개발 서버는 화면을 요청 때마다 처음부터 만들어서, script 태그가 전부 요청 시점에 생기고 번호표를 다 받습니다. 번호표가 전부 붙은 상태에서는 'strict-dynamic'이 있든 없든 결과가 같은데, 그 단어는 "번호표 없는 것을 어떻게 할까"를 정하는 규칙이라서, 번호표 없는 게 하나도 없으니 판단할 대상이 없는 겁니다.
프로덕션은 화면 일부를 빌드 때 미리 만들어둡니다. 그때는 번호표가 없으니 그 조각의 script 태그 9개가 번호표 없이 남아요. 여기서 비로소 'strict-dynamic'이 판단할 대상이 생기고, 있으면 'self'를 무효로 만들어 9개를 전부 막고, 없으면 'self'가 살아서 통과시키는데, 개발 서버는 이 문제에 대해 판별력이 0입니다. 두 설정이 같은 결과를 내니까요.
자주 나오는 실수
프로덕션 표를 개발 서버 숫자로 채우기. next dev를 띄운 채로 재면 두 표가 통째로 같아집니다. 반드시 빌드해서 만들어진 결과물로 띄우고 재야 해요.
"번호표를 받은 수"를 세면서 외부와 인라인을 안 나누기. 합계만 보면 프로덕션이 32라 꽤 많아 보입니다. 나눠 보면 인라인 31/32 · 외부 1/10이고, 문제는 전부 외부 쪽이에요. 평균이 사실을 가리는 자리입니다.
💡 튜터의 한마디 — 이 표가 말하는 건 CSP 지식이 아니라 테스트 환경이 무엇을 재고 무엇을 못 재는가입니다. 개발 서버에서 잘 된다는 게 이 문제에 관해서는 아무 정보도 아니었어요. 여러분이 앞으로 만나는 "로컬에선 되는데요" 상황도 대개 이 모양입니다. 그럴 때는 두 환경이 무엇을 다르게 하는지부터 찾으세요. 오늘의 답은 "화면을 언제 만드는가" 였습니다.
🤔 [생각해볼 주제 1] 개발에서만 통과하는 문제를 어떻게 미리 잡을까요
문제 상황 요약
빌드도, 타입 검사도, 린트도, 상태 코드도, 화면도 전부 정상인데 버튼이 안 눌렸습니다. 우리가 배포 전에 확인하는 수단이 하나도 이 문제를 못 잡았어요.
튜터의 가이드 및 해설
먼저 왜 못 잡았는지를 갈라보면, 각 수단이 무엇을 보는지에 이유가 분명하게 드러나요.
타입 검사 값의 모양이 맞나
린트 코드 작성 규칙을 지켰나
빌드 변환하고 미리 그릴 수 있나
상태 코드 서버가 응답을 만들었나
넷 다 서버 쪽 일을 보는데, 오늘 문제는 브라우저가 파일을 받고 실행하는 단계에서 벌어져서, 아무도 그 단계를 안 보고 있었던 겁니다.
그래서 필요한 건 브라우저를 실제로 띄워서 사용자 동작을 해보는 확인입니다. 하트를 눌러보고 숫자가 바뀌는지 보는 것 하나면 오늘 문제가 잡히는데, 조건이 하나 더 붙어서 프로덕션 결과물로 해야 해요. 개발 서버로 하면 과제 4에서 본 대로 판별력이 0입니다. 그러니 정확히는 "빌드한 결과물을 띄우고, 브라우저로 핵심 동작 하나를 해보는 확인"입니다.
여기서 비용 이야기가 나옵니다. 이런 확인은 느리고 잘 깨지다 보니, 전부에 걸 수는 없고 가장 중요한 몇 개에만 겁니다. 인스타그램 클론이라면 로그인·좋아요·댓글 정도겠죠.
F 카테고리에서 배운 테스팅 트로피를 떠올려보면 이게 맨 위층인데, 아래층이 넓고 위층이 좁은 이유가 여기 있어요. 위층은 비싸지만, 아래층 전부가 초록불인데도 앱이 안 굴러가는 상황을 잡아주는 유일한 층입니다.
한 가지 더, 오늘 문제는 개발과 프로덕션의 동작 차이에서 왔다 보니, 이런 차이가 있는 설정을 켤 때는 그 사실 자체를 기록해두는 게 좋아요. 우리 코드에도 isDev로 갈리는 줄이 있는데, 그런 줄이 있다는 건 두 환경이 다르게 동작한다는 선언이고 그만큼 두 곳에서 다 확인해야 한다는 뜻입니다.
🎯 면접관을 홀리는 핵심 멘트
"저희 확인 수단이 전부 서버 쪽만 보고 있었다는 게 원인이었습니다. 타입·린트·빌드·상태 코드는 '응답을 만들 수 있나'를 보지 '사용자가 쓸 수 있나'를 보지 않거든요. 그래서 프로덕션 빌드로 띄워놓고 핵심 동작 하나를 브라우저에서 눌러보는 확인을 추가했습니다. 전부에 걸면 비싸니까 로그인·좋아요처럼 죽으면 서비스가 끝나는 것들에만 걸었어요."
🤔 [생각해볼 주제 2] 'self'를 살린 대가는 언제 청구될까요
문제 상황 요약
'strict-dynamic'을 빼면서 "우리 출처에서 오는 스크립트 파일은 번호표 없이도 전부 허용"이라는 조건이 살아났습니다. 지금은 문제가 안 되지만 언제까지 그럴까요.
튜터의 가이드 및 해설
전제를 정확히 적어봅시다. 지금 우리가 기대고 있는 건 이겁니다.
우리 출처에 파일을 올릴 수 있는 사람은 우리뿐이다.
인스타그램 클론에 이 전제를 깨는 기능이 하나 있는데, 사진 업로드예요.
인스타그램인데 사진을 못 올린다는 게 이상하죠. 언젠가 반드시 들어올 기능입니다. 그리고 사용자가 올린 파일을 우리 서버가 받아서 우리 주소로 내보내는 순간, 전제가 깨집니다.
그때 무슨 일이 생길 수 있을까요. 누군가 자바스크립트가 담긴 파일을 사진인 척 올리고, 그게 우리 주소로 서빙되고, 어떤 경로로든 그 파일이 스크립트로 불리면 — CSP는 그걸 막지 않습니다. 우리 출처니까요.
그래서 확인해야 할 것이 정해집니다.
올라온 파일이 우리 주소로 나가나 아니면 대부분 해결된다
그 파일의 종류를 우리가 검사하나 이름만 보나 내용을 보나
브라우저에게 종류를 어떻게 알려주나 엉뚱한 종류로 알려주면 실행될 수 있다
가장 깔끔한 해결은 업로드된 파일을 다른 출처에서 내보내는 것인데, 별도 도메인을 쓰면 그건 우리 출처가 아니라서 'self'에 안 걸려요. 실무에서 사용자 업로드를 별도 도메인으로 빼는 데는 이런 이유도 있습니다.
여기서 더 중요한 걸 짚고 싶은데, 오늘 우리가 내린 결정은 그 시점의 앱 구조를 전제로 옳았습니다. 그런데 그 전제는 코드 어디에도 안 적혀 있어요. 나중에 업로드 기능을 만드는 사람은 CSP 설정을 볼 이유가 없고, 그래서 전제가 깨지는 걸 아무도 못 봅니다.
그러니 결정을 내릴 때 무엇을 전제했는지 함께 적어두는 것이 결정 자체만큼 중요합니다. 우리 proxy.ts 주석이 그 일을 하고 있어요.
🎯 면접관을 홀리는 핵심 멘트
"
'strict-dynamic'을 뺀 건 그때 앱 구조에서는 맞는 선택이었는데, '우리 출처에 파일을 올릴 수 있는 건 우리뿐'이라는 전제 위에 서 있었습니다. 사진 업로드가 들어오면 그 전제가 깨지고요. 그래서 결정과 함께 전제를 주석으로 남겨뒀습니다. 나중에 업로드를 만드는 사람이 CSP 설정을 들여다볼 이유는 없으니까, 전제가 깨지는 걸 알아챌 방법이 그것밖에 없거든요."
🤔 [생각해볼 주제 3] 걸러내기와 미리 알려두기 중 무엇을 먼저 해야 할까요
문제 상황 요약
지난 시간에는 들어온 값을 걸러냈고, 오늘은 브라우저에게 미리 알려뒀습니다. 시간이 부족해 하나만 해야 한다면 어느 쪽일까요.
튜터의 가이드 및 해설
두 방법이 어떻게 다른지부터 정리해봅시다.
걸러내기 우리가 아는 통로를 막는다. 새 통로가 생기면 놓친다.
미리 알려두기 통로와 무관하게 실행을 막는다. 놓친 통로도 덮인다.
덮는 범위만 보면 미리 알려두는 쪽이 넓은데, 그럼 그쪽이 먼저일까요.
아닙니다. 걸러내기가 먼저입니다. 이유가 둘이에요.
첫째, CSP는 브라우저가 지켜줄 때만 동작하다 보니, 오래된 브라우저나 헤더를 무시하는 환경에서는 아무 일도 안 해요. 반면 걸러내기는 서버에서 값 자체를 바꾸니 어디서든 유효합니다. 마지막 방어선이 아니라 유일하게 확실한 방어선이 걸러내기예요.
둘째, 오늘 우리가 직접 겪은 것인데, 잘못 켰을 때의 대가가 완전히 다릅니다.
걸러내기를 잘못하면 그 값이 이상하게 보인다 — 화면 일부 문제
CSP 를 잘못 켜면 앱이 통째로 안 굴러간다 — 그런데 초록불이다
걸러내기를 잘못 설정하면 댓글에 태그가 글자로 보이는 정도라서, 눈에 보이고 금방 고칩니다. 그런데 CSP를 잘못 켜면 오늘처럼 모든 점검을 통과한 채로 앱이 멈춥니다.
순서는 이렇게 정리됩니다.
1. 입력을 걸러낸다 — 확실하고, 틀려도 대가가 작다
2. CSP 를 켠다 — 넓게 덮지만, 프로덕션에서 반드시 확인한다
두 번째를 할 때 "프로덕션에서 반드시 확인한다"가 조건으로 붙는다는 게 오늘의 수확인데, 그 확인 없이 켜는 건 안 켜느니만 못할 수 있어요. 안 켜면 XSS 위험이 남을 뿐이지만, 잘못 켜면 아무도 앱을 못 씁니다.
마지막으로, 둘 다 하는 게 정답이라는 걸 잊지 마세요. 오늘 우리가 확인했듯 CSP를 켜도 인라인은 여전히 막히고, G-4의 걸러내기도 그대로 살아 있습니다. 한 겹이 뚫려도 다른 겹이 남는 게 이 조합의 값어치예요.
🎯 면접관을 홀리는 핵심 멘트
"저는 입력 걸러내기를 먼저 합니다. CSP는 브라우저가 지켜줄 때만 동작하는데 걸러내기는 서버에서 값 자체를 바꾸니까 어디서든 유효하거든요. 그리고 잘못 켰을 때 대가가 다릅니다. 걸러내기가 틀리면 화면 일부가 이상해지는 정도인데, CSP를 잘못 켜면 빌드도 상태 코드도 다 정상인 채로 앱 전체가 멈춥니다. 실제로 프로덕션에서만 자바스크립트가 0바이트 실리는 걸 겪어봤어요. 그래서 CSP는 켜되 프로덕션 빌드로 확인하는 걸 조건으로 붙입니다."