--- 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" --- # Cookie로 인증하는 요청에서 CSRF token이 하는 일 session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다. ## 관계 - **BFF 인증 구조 설계 기준** 이 확인이 필요한 구조의 설계 항목이다. - **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정** 이 동작을 실제로 재현한 기록이다. - **OAuth Token과 Application Session을 구분하는 기준** session cookie와 CSRF token은 서로 다른 값이다. ## 본문 ## cookie가 credential이 되면 생기는 일 브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다. 이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다. ## token을 받아 오는 요청 브라우저가 먼저 CSRF material을 요청한다. ```http label="CSRF token 요청" GET http://localhost:8083/bff/csrf Accept: application/json Cookie: AP3_SESSION= ``` `CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다. ```json label="CsrfController가 반환하는 JSON" { "headerName": "X-XSRF-TOKEN", "parameterName": "_csrf", "token": "" } ``` ## body의 token과 cookie의 값은 다르다 같은 CSRF material이 세 자리에 서로 다른 형태로 놓인다. | 위치 | 값 | |---|---| | 응답 body의 `token` | XOR와 Base64로 mask된 값 | | `XSRF-TOKEN` cookie | raw 값 | | POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 | `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다. `SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 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=; XSRF-TOKEN= X-XSRF-TOKEN: theme=dark ``` Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다. ## SameSite가 정하는 것과 CSRF token이 정하는 것 | | SameSite | CSRF token | |---|---|---| | 누가 판단하나 | 브라우저 | 서버 | | 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 | | 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 | port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 cookie를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다. 네 가지 입력에서 cookie와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다. | 입력 | cookie 동작 | CSRF 동작 | 결과 | |---|---|---|---| | same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 | | same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 | | 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 | | cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 | 마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다. ## CSRF가 XSS를 대신하지 않는다 브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다. 이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.