글감 1,001개 중 제1부(§3~§11) 앵커를 하나라도 가진 것은 112개뿐이었다. 나머지 889개는
제2부 모듈 분석 65편의 절 제목에서 나온 것이고, 그것이 재판정이 필요했던 이유다.
주제 44 → 16 (43개가 독자 질문 없이 있었다. 지금은 전부 있다)
글감 1,001 → 123 (제1부 앵커 112 + 제1부가 채택했는데 비어 있던 자리 11)
후보 965 → 1,088 · PENDING 905 → 0
error 3,042 → 0
내려온 889개는 후보 대장에 KEEP_IN_SSOT 로 남는다 — 버린 것이 아니라 분석에 남기고 독립
기록으로 만들지 않기로 한 것이다. 그 글감을 받치던 기록 파일 828개는 지웠다. 계약이 정본이고,
파일이 남아 있다는 이유로 계약에서 뺀 주제가 되살아나면 안 된다. 이력에는 그대로 있다 —
git checkout a0ca2bb -- <경로>.
제1부가 채택했는데 글감이 없던 자리 열하나를 채웠다: mongo high-water mark 가 재전달 이벤트를
삼킨 P1, admin plane 이 가드만 켜고 서비스는 켜지 않은 것과 그 짝인 결정, 실패 어휘 세 층과
SQLState 매트릭스 병합 규칙, 부하 아래에서만 새는 admission 경계, 발행 증거와 완료 판정의
분리, keyset·JSONB 결정 둘.
Concept 17개에 basis-version 을 채우고, 계약 제목과 기록 제목이 갈라져 있던 23건을 기록 쪽에
맞췄다. candidateScope 에 excludedAnchorPattern 을 적어 제2부 앵커만 가진 글감이 다시 올라올
수 없게 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
7.3 KiB
Markdown
130 lines
7.3 KiB
Markdown
---
|
|
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
|
|
kind: CONCEPT
|
|
slug: cookie-auth-csrf
|
|
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
|
topic: oauth-oidc-auth-boundary
|
|
topicName: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 전
|
|
version: 4
|
|
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
|
|
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
|
|
assets:
|
|
- key: ap3-csrf-boundary
|
|
file: ../../../final/assets/tech-log-studio/ap3-csrf-boundary.svg
|
|
sourceRevision: keycloak-patterns-lab@2026-08
|
|
source:
|
|
- final/document.md#선택의-이유와-지킨-경계-ap3
|
|
---
|
|
|
|
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
|
|
|
세션 쿠키는 브라우저가 요청마다 자동으로 붙이기 때문에, 상태를 바꾸는 요청이 사용자가 의도한 것인지 서버가 따로 확인해야 한다. CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰이 그 확인을 맡고, SameSite는 브라우저가 쿠키를 언제 보낼지 정하는 별도의 정책이다.
|
|
|
|
## 관계
|
|
|
|
- **BFF 인증 구조 설계 기준**
|
|
이 확인이 필요한 구조의 설계 항목이다.
|
|
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
|
이 동작을 실제로 재현한 기록이다.
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
세션 쿠키와 CSRF 토큰은 서로 다른 값이다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## cookie가 credential이 되면 생기는 일
|
|
|
|
BFF(Backend for Frontend)는 브라우저에 OAuth 토큰을 내려보내지 않는다. 대신 JavaScript가 읽을 수 없는 HttpOnly 세션 쿠키 하나로 로그인한 사용자를 알아본다.
|
|
|
|
이 쿠키를 요청에 붙이는 쪽은 애플리케이션 코드가 아니라 브라우저다. 다른 사이트가 만든 요청에도 같은 쿠키가 실릴 수 있다는 뜻이다. `GET /bff/api/me`처럼 읽기만 하는 요청에서는 이것이 문제로 보이지 않으므로, 값을 바꾸는 요청을 따로 봐야 한다.
|
|
|
|
브라우저에 OAuth 토큰을 내려보내지 않기로 하면서 로그인 상태가 서버로 옮겨 왔고, 값을 바꾸는 요청을 가려내는 일도 그때 BFF가 맡게 됐다. 먼저 브라우저가 토큰을 받아 오는 요청부터 본다.
|
|
|
|
## token을 받아 오는 요청
|
|
|
|
브라우저는 상태를 바꾸는 요청을 보내기 전에 CSRF 값부터 받아 온다.
|
|
|
|
```http label="CSRF token 요청"
|
|
GET http://localhost:8083/bff/csrf
|
|
Accept: application/json
|
|
Cookie: AP3_SESSION=<opaque-session-id>
|
|
```
|
|
|
|
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` 쿠키를 경로 `/`에 만든다. 컨트롤러는 다음 JSON을 반환한다.
|
|
|
|
```json label="CsrfController가 반환하는 JSON"
|
|
{
|
|
"headerName": "X-XSRF-TOKEN",
|
|
"parameterName": "_csrf",
|
|
"token": "<xor-masked-csrf-token>"
|
|
}
|
|
```
|
|
|
|
:::evidence key="ap3-csrf-boundary" alt="BFF의 /bff/csrf 하나에서 두 갈래가 갈리는 그림. Set-Cookie로 나가는 CSRF 쿠키에는 가리지 않은 원본 값이 들어가고 JSON 본문에는 가린 토큰과 headerName이 들어간다. 브라우저 코드는 JSON에서 headerName만 쓰고 실제 헤더 값은 쿠키의 원본 값을 쓴다. Spring CSRF filter가 raw cookie와 raw header를 대조해 일치하면 controller로 보내고 부재나 불일치면 403을 낸다." caption="" zoom="true"
|
|
:::
|
|
|
|
## body의 token과 cookie의 값은 다르다
|
|
|
|
같은 CSRF 값이 응답 본문, 쿠키, 요청 헤더 세 곳에 서로 다른 형태로 놓인다.
|
|
|
|
`XorCsrfTokenRequestAttributeHandler`가 요청 속성에 넣는 토큰을 XOR와 Base64로 가리기 때문에, 위 JSON에는 가려진 값이 담긴다. 쿠키 쪽은 다르다. `XSRF-TOKEN`에는 가리지 않은 원본 값이 들어간다. 그래서 SPA는 JSON에서 `headerName`만 읽고, 실제로 보낼 값은 `document.cookie`에서 `XSRF-TOKEN`을 찾아 쓴다.
|
|
|
|
| 위치 | 값 |
|
|
|---|---|
|
|
| 응답 본문의 `token` | XOR와 Base64로 가린 값 |
|
|
| `XSRF-TOKEN` 쿠키 | 원본 값 |
|
|
| POST의 `X-XSRF-TOKEN` 헤더 | 쿠키와 같은 원본 값 |
|
|
|
|
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. 서버가 기대하는 헤더가 요청에 있으면 제출된 원본 토큰을 그대로 읽고, 없으면 XOR resolver 경로를 쓴다.
|
|
|
|
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다.
|
|
|
|
## 검증이 controller보다 먼저 일어난다
|
|
|
|
검증을 통과하는 상태 변경 요청은 다음과 같다.
|
|
|
|
```http label="CSRF 검증을 통과하는 POST"
|
|
POST http://localhost:8083/bff/api/preferences
|
|
Content-Type: application/x-www-form-urlencoded
|
|
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
|
X-XSRF-TOKEN: <same-raw-csrf-token>
|
|
|
|
theme=dark
|
|
```
|
|
|
|
컨트롤러에 닿기 전에 Spring CSRF 필터가 저장소에 있는 기대값과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 컨트롤러는 실행되지 않고 403이 된다. 검증하는 곳이 컨트롤러보다 앞이라, 엔드포인트를 새로 추가해도 같은 필터를 지난다.
|
|
|
|
다만 이 필터가 보는 것은 CSRF 토큰뿐이다. 경로별 인가는 이 예제의 자동 테스트가 확인하는 범위 밖이라, 새 엔드포인트가 같은 필터를 지났다는 것으로 그 엔드포인트의 인가까지 확인되지는 않는다.
|
|
|
|
## SameSite가 정하는 것과 CSRF token이 정하는 것
|
|
|
|
SameSite는 쿠키를 다른 사이트로 보낼지 브라우저가 정하는 정책이고, CSRF 토큰은 상태를 바꾸는 요청이 사용자의 의도인지 서버가 검증하는 애플리케이션 규약이다.
|
|
|
|
| | SameSite | CSRF 토큰 |
|
|
|---|---|---|
|
|
| 누가 판단하나 | 브라우저 | 서버 |
|
|
| 무엇을 정하나 | 쿠키를 보낼지 | 요청을 받아들일지 |
|
|
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
|
|
|
|
포트가 달라도 사이트 계산상 같은 사이트로 잡히는 경우가 있어서 둘은 서로를 대신하지 못한다. SameSite가 쿠키를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
|
|
|
|
네 가지 입력에서 쿠키와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
|
|
|
|
| 입력 | 쿠키 동작 | CSRF 동작 | 결과 |
|
|
|---|---|---|---|
|
|
| same-origin, CSRF 헤더 없음 | 세션 쿠키 붙음 | 토큰이 없어 거부 | 403 |
|
|
| same-origin, 원본 쿠키와 헤더 일치 | 세션 쿠키 붙음 | 토큰 일치 | 200 |
|
|
| 다른 포트지만 same-site, 헤더 없음 | 쿠키가 붙을 수 있음 | 토큰이 없어 거부 | 403 |
|
|
| cross-site POST | SameSite=Lax로 쿠키 제외 | 이후 서버 처리는 고정하지 않음 | 쿠키가 빠졌는지가 확인 지점 |
|
|
|
|
## CSRF가 XSS를 대신하지 않는다
|
|
|
|
브라우저에 OAuth 토큰을 주지 않아도 XSS가 무해해지지는 않는다. 같은 출처에서 실행되는 악성 스크립트는 피해자의 세션으로 BFF 엔드포인트를 그대로 부를 수 있고, JavaScript가 읽으라고 열어 둔 `XSRF-TOKEN`도 함께 읽을 수 있기 때문이다.
|
|
|
|
이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰의 원문이 스크립트로 새어 나가 다른 클라이언트나 직접 API 호출에 다시 쓰이는 범위다. CSP, 출력 인코딩, 의존성 무결성, 애플리케이션 인가는 이 구조가 대신 막아 주지 않으므로 각각 따로 세워야 한다.
|
|
|
|
<!-- body:end -->
|