aster®

[로그인인증] 방어를 한 겹씩 (09)

마무리 · 2026-09

초록으로 채운 큰 열쇠 하나가 가로로 놓여 있고, 그 뒤에 같은 모양의 열쇠 두 개가 남색 윤곽선만으로 겹쳐 있다. 열쇠들 아래로 가는 선이 하나 지나간다.

이 장을 마치면

  • SameSite 를 lax·strict 로 바꿔 가며 같은 공격을 반복하고, 출처와 사이트의 차이를 실측 결과로 설명할 수 있다.
  • Origin(없으면 Referer) 헤더로 요청이 시작된 곳을 확인하는 검사를 만들고, 헤더가 없거나 null 일 때 왜 거부하는지 말할 수 있다.
  • HMAC 으로 서명한 double-submit CSRF 토큰을 발급·검증하고, 공격자가 그 토큰을 읽지도 만들지도 못하는 이유를 설명할 수 있다.
  • 세 겹의 방어를 CsrfGuard 로 묶어 본 API 의 login·refresh·logout 에 걸고, curl 과 Swagger UI 로 확인할 수 있다.
  • CSRF 방어가 XSS 앞에서는 힘을 잃는다는 한계를 안다.

들어가며

8장에서 우리는 공격 페이지 하나로 피해자의 닉네임을 바꾸는 데 성공했습니다. 실습 쿠키 lab_session 은 HttpOnly 이고 Secure 였으며, 안에 든 JWT 는 서명·만료·발급자·대상까지 전부 검증됐습니다. 토큰 자체에는 아무 문제가 없었습니다. 문제는 서버가 "이 요청을 정말 우리 FE 가 보냈는가" 를 묻지 않았다는 데 있었습니다.

이 장에서는 그 질문에 답하는 방법 세 가지를 하나씩 켭니다.

  1. SameSite — 브라우저에게 "다른 사이트에서 시작한 요청에는 이 쿠키를 싣지 마라" 고 부탁한다.
  2. Origin 검사 — 서버가 브라우저가 적어 준 Origin 헤더를 보고 허용한 출처인지 확인한다.
  3. 서명된 CSRF 토큰 — 공격자가 알 수 없는 값을 요청마다 헤더로 싣게 해서 "예측 가능한 요청" 조건을 깨뜨린다.

방법마다 api/.env 의 LAB_* 값을 바꾸고, 8장과 똑같은 공격을 http://localhost:5174 와 http://127.0.0.1:5174 두 곳에서 되풀이합니다. 결과는 한 표에 누적합니다. 표가 채워져 가는 모습을 보면 왜 세 겹이 모두 필요한지가 드러납니다.

마지막으로 실습실에서 확인한 방어를 본 API 의 POST /auth/login·/auth/refresh·/auth/logout 에 적용합니다. 이 장이 끝나면 머리말의 전체 흐름 그림에서 ① GET /auth/csrf 와 ②·⑤ 의 CsrfGuard 가 채워집니다.

9.1 실험하는 방법

모든 실험은 같은 순서를 따릅니다. 한 단계라도 빠뜨리면 결과가 앞 실험의 것으로 남아 해석이 틀어집니다.

  1. api/.env 의 LAB_COOKIE_SAMESITE·LAB_CHECK_ORIGIN·LAB_CHECK_TOKEN 을 바꾼다.
  2. API 를 다시 띄운다. pnpm start:dev 의 감시 모드는 .env 변경을 감지하지 못할 수 있으니, 터미널에서 멈췄다가 다시 실행하는 편이 확실합니다.
  3. curl -s http://localhost:4000/lab/settings | jq -c 로 설정이 반영됐는지 본다.
  4. Swagger UI(http://localhost:4000/docs)에서 POST /lab/login 을 다시 한다. SameSite 는 쿠키를 발급할 때 붙는 속성이라, 다시 로그인해야 새 값의 쿠키를 받습니다.
  5. 닉네임을 김개발 로 되돌린다(방법은 실험마다 적습니다).
  6. 새 탭을 열고 먼저 개발자 도구의 Network 탭을 연 다음 공격 페이지 주소를 입력한다. 숨긴 iframe 으로 간 요청 nickname 의 상태 코드를 볼 수 있습니다.
  7. Swagger 탭으로 돌아와 GET /lab/me 로 닉네임을 확인한다.

공격 페이지 자신은 응답을 읽지 못하지만, 개발자 도구는 브라우저의 관찰자라 상태 코드와 응답을 보여 줍니다. 이 장에서 "401", "403" 이라고 적는 결과는 모두 이 Network 탭에서 본 값입니다. (Chrome 버전에 따라 iframe 으로 간 요청의 응답 본문은 표시되지 않을 수 있습니다. 상태 코드는 보입니다.)

8장의 결과를 출발점으로 표를 시작합니다.

#SameSiteOrigin 검사토큰 검사localhost:5174 (같은 사이트)127.0.0.1:5174 (다른 사이트)
0none끔끔성공 (200)성공 (200)

9.2 첫 번째 겹 — SameSite

lax 로 바꾸기

api/.env 를 이렇게 바꿉니다.

# api/.env
LAB_COOKIE_SAMESITE=lax
LAB_CHECK_ORIGIN=false
LAB_CHECK_TOKEN=false

9.1 의 순서대로 API 를 다시 띄우고, Swagger 에서 POST /lab/login 을 다시 합니다. 개발자 도구 Application → Cookies → http://localhost:4000 에서 lab_session 의 SameSite 열이 Lax 로 바뀌었는지 확인합니다. 닉네임은 Swagger 의 POST /lab/nickname 으로 되돌립니다(아직 검사가 없으니 Swagger 에서 그대로 됩니다).

이제 두 주소로 공격해 봅니다.

http://127.0.0.1:5174 → 실패. Network 탭의 nickname 요청은 401 이고, 응답을 볼 수 있다면 {"statusCode":401,"code":"ACCESS_TOKEN_INVALID","message":"실습 쿠키가 없다"} 입니다. 요청의 Cookies 하위 탭에서 "필터링된 쿠키 표시" 를 켜면 lab_session 옆에 대략 이런 이유가 붙어 있을 것입니다.

This cookie was blocked because it had the "SameSite=Lax" attribute and the request was made
from a different site and was not initiated by a top-level navigation.

(예시 문구입니다. Chrome 버전에 따라 표현이 다릅니다.)

요청은 서버에 도착했습니다. 다만 쿠키 없이 도착했기 때문에 서버는 이 요청을 "로그인하지 않은 누군가" 의 것으로 보고 거절했습니다. Swagger 의 GET /lab/me 는 여전히 김개발 입니다.

http://localhost:5174 → 여전히 성공. 상태 코드는 200 이고 닉네임은 털렸음 이 됩니다.

같은 파일, 같은 폼, 같은 브라우저인데 결과가 갈렸습니다. 두 주소의 차이는 하나뿐입니다.

공격 페이지출처 (스킴+호스트+포트)사이트 (스킴+등록 가능한 도메인)API 와의 관계
http://localhost:5174http://localhost:5174http://localhost다른 출처, 같은 사이트
http://127.0.0.1:5174http://127.0.0.1:5174http://127.0.0.1다른 출처, 다른 사이트
(API) http://localhost:4000http://localhost:4000http://localhost—

SameSite 라는 이름 그대로입니다. 이 속성은 사이트를 기준으로 판단합니다. 포트가 다르든, 서브도메인이 다르든, 등록 가능한 도메인이 같으면 같은 사이트이고 쿠키가 실립니다. 1장에서 표로만 봤던 "출처와 사이트의 구분" 이 공격의 성공과 실패를 가르는 것을 방금 눈으로 확인했습니다.

strict 로 바꾸기

LAB_COOKIE_SAMESITE=strict 로 바꾸고 같은 절차를 반복합니다. 결과는 lax 와 똑같습니다. 127.0.0.1:5174 는 401 로 실패하고, localhost:5174 는 성공합니다.

Strict 가 Lax 보다 엄격하다는데 왜 결과가 같을까요? 두 값이 다르게 동작하는 경우가 우리 공격에는 없기 때문입니다.

요청의 종류NoneLaxStrict
같은 사이트에서 시작한 모든 요청실림실림실림
다른 사이트에서, 링크 클릭 같은 top-level 이동, 안전한 메서드(GET)실림실림안 실림
다른 사이트에서, top-level 이동, POST (폼 전송으로 탭 이동)실림안 실림안 실림
다른 사이트에서, iframe·fetch·<img> 같은 하위 요청실림안 실림안 실림

Lax 와 Strict 가 갈리는 곳은 두 번째 줄 하나, 다른 사이트에서 링크를 눌러 우리 사이트로 들어오는 GET 뿐입니다. 메일의 링크를 눌러 https://app.example.com/orders 로 들어왔을 때, 세션 쿠키가 Strict 면 첫 화면에 쿠키가 실리지 않아 로그인 안 된 화면이 뜹니다. Lax 면 실려서 바로 주문 목록이 보입니다. 그래서 서버가 페이지를 그리는 전통적인 웹에서는 세션 쿠키에 Lax 를 많이 쓰고, 계좌 이체처럼 민감한 기능의 쿠키에만 Strict 를 씁니다.

우리 공격은 POST 이고, iframe 으로 보내므로 세 번째·네 번째 줄에 해당합니다. 두 값 모두 막는 줄이라 결과가 같습니다. 그리고 localhost:5174 는 첫 줄, 같은 사이트라 두 값 모두 막지 못합니다.

💡 우리 설계의 refresh_token 이 Strict 여도 불편하지 않은 이유도 이 표에서 나옵니다. 사용자가 다른 사이트의 링크를 눌러 FE 에 들어오면 그 top-level 이동은 FE(localhost:5173)로 가는 것이지 API 로 가는 것이 아닙니다. 그 뒤 FE 페이지의 코드가 보내는 POST /auth/refresh 는 같은 사이트의 페이지가 시작한 요청이므로 Strict 쿠키가 실립니다. 리프레시 쿠키는 링크 이동에 실릴 일이 없으니 Strict 의 단점을 겪지 않습니다.

SameSite 를 적지 않으면

쿠키에 SameSite 속성을 아예 적지 않으면 어떻게 될까요? 원래 기본값은 None 에 해당하는 동작, 즉 어디서든 실리는 것이었습니다. Chrome 은 2020년 무렵부터 속성이 없는 쿠키를 Lax 로 취급하기 시작했다고 발표했고, 다른 브라우저도 비슷한 방향을 논의해 왔습니다. 다만 브라우저마다 적용 여부와 시점이 다르고 지금도 다를 수 있으므로, 기본값에 기대지 말고 항상 명시하는 것이 이 책의 원칙입니다. 우리 코드의 쿠키는 모두 sameSite 를 명시합니다.

Chrome 은 이 기본값 변경과 함께 호환성을 위한 예외도 두었다고 알려져 있습니다. 속성 없이 설정된 쿠키는 설정된 뒤 약 2분 동안 다른 사이트에서 오는 top-level POST 에도 실어 준다는 것으로, 흔히 "Lax+POST" 라고 부릅니다. 외부 결제·로그인 서비스가 결과를 POST 로 되돌려 보내는 흐름이 깨지지 않게 하려는 임시 조치로 설명됐습니다. 이 내용은 Chrome 문서와 발표를 기준으로 한 것이고 브라우저마다, 버전마다 다를 수 있습니다. 확실한 것은 SameSite 를 명시한 쿠키에는 이 예외가 적용되지 않는다는 점이고, 실습 쿠키는 늘 명시하므로 우리 실험에는 영향이 없습니다.

SameSite=None 실험이 안 될 때

8장과 이 장의 none 실험에서 127.0.0.1:5174 공격이 실패했다면 코드보다 브라우저 설정을 먼저 의심하세요. SameSite=None 쿠키가 다른 사이트의 페이지 안에서 실리는 것은 곧 서드파티 쿠키입니다. 브라우저들은 개인정보 보호를 이유로 서드파티 쿠키를 점점 더 제한하고 있습니다. Chrome 시크릿 창은 기본으로 차단하고, 일반 창도 사용자 설정이나 조직 정책에 따라 차단될 수 있으며, Safari·Firefox 는 기본 동작이 Chrome 과 다릅니다. Chrome 의 서드파티 쿠키 정책도 몇 차례 계획이 바뀌어 왔으니, 실험 당시의 브라우저 설정 화면(설정 → 개인 정보 보호 및 보안 → 서드 파티 쿠키)과 개발자 도구의 "필터링된 쿠키" 이유를 확인하는 것이 가장 정확합니다.

이것은 운영에도 그대로 이어집니다. FE 와 API 가 다른 사이트라서 SameSite=None 을 쓸 수밖에 없는 구성은 사용자의 브라우저 설정에 따라 로그인 자체가 안 될 수 있습니다. 이 책이 FE 와 API 를 같은 사이트에 두는 이유 중 하나입니다(14장).

운영에서 "같은 사이트의 다른 출처" 란

실습에서는 localhost:5174 가 같은 사이트의 공격자였습니다. 운영에서 FE 가 https://app.example.com, API 가 https://api.example.com 이라면 *.example.com 의 모든 호스트가 같은 사이트입니다. 그중 하나라도 공격자 손에 들어가면 SameSite 는 아무것도 막지 못합니다. 흔한 경우는 이렇습니다.

그래서 SameSite 는 첫 번째 겹일 뿐 단독 방어가 아닙니다. 같은 사이트 안의 공격을 막으려면 서버가 출처를 직접 확인해야 합니다.

#SameSiteOrigin 검사토큰 검사localhost:5174 (같은 사이트)127.0.0.1:5174 (다른 사이트)
0none끔끔성공 (200)성공 (200)
1lax끔끔성공 (200)실패 (401, 쿠키 안 실림)
2strict끔끔성공 (200)실패 (401, 쿠키 안 실림)

9.3 두 번째 겹 — Origin 검사

8장의 Network 탭에서 공격 요청에 Origin: http://127.0.0.1:5174 가 붙어 있던 것을 기억할 것입니다. 브라우저는 POST 처럼 상태를 바꿀 수 있는 요청을 보낼 때 요청을 시작한 페이지의 출처를 Origin 헤더에 적습니다. 이 헤더는 페이지의 JavaScript 가 바꿀 수 없는 헤더(forbidden header name)라, 브라우저에서 온 요청이라면 믿을 수 있습니다.

curl 같은 도구로는 Origin 을 마음대로 적을 수 있지 않느냐고 생각할 수 있습니다. 맞습니다. 하지만 curl 에는 피해자의 쿠키가 없습니다. CSRF 는 피해자의 브라우저가 피해자의 쿠키를 싣고 보내는 요청이 문제이고, 그런 요청의 Origin 은 브라우저가 적습니다. 자기 쿠키로 자기 요청을 위조하는 것은 공격이 아닙니다.

CsrfService

검사 로직은 CsrfService 에 모읍니다. 이 서비스는 9.4 의 토큰 발급·검증도 함께 맡으므로 여기서 파일 전체를 만들고, 이 절에서는 isAllowedOrigin 과 originOf 를 자세히 봅니다. 토큰 부분(issue·isValidPair·sign·safeEqual)은 9.4 에서 설명합니다.

설정 계층에는 CSRF_SECRET 을 읽은 csrfSecret 과, 허용 출처 목록을 돌려주는 csrfAllowedOrigins 게터가 이미 들어 있습니다. 에러 코드 CSRF_ORIGIN_REJECTED·CSRF_TOKEN_INVALID 도 4장에서 계약의 에러 코드 표를 옮길 때 ErrorCode 에 들어갔습니다. 혹시 빠져 있다면 부록 C 의 app-config.ts·errors.ts·.env 와 맞춰 두세요.

// api/src/auth/csrf.ts
import { Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { AppConfig } from '../config/app-config';

@Injectable()
export class CsrfService {
  constructor(private readonly config: AppConfig) {}

  /** <nonce>.<HMAC(nonce)> */
  issue(): string {
    const nonce = randomBytes(32).toString('base64url');
    return `${nonce}.${this.sign(nonce)}`;
  }

  isValidPair(header: unknown, cookie: unknown): boolean {
    if (typeof header !== 'string' || typeof cookie !== 'string') return false;
    if (!safeEqual(header, cookie)) return false;
    const [nonce, signature, ...rest] = header.split('.');
    if (!nonce || !signature || rest.length > 0) return false;
    return safeEqual(signature, this.sign(nonce));
  }

  isAllowedOrigin(req: Request): boolean {
    const origin = req.get('origin') ?? originOf(req.get('referer'));
    return origin !== undefined && this.config.csrfAllowedOrigins.includes(origin);
  }

  private sign(nonce: string): string {
    return createHmac('sha256', this.config.csrfSecret).update(nonce).digest('base64url');
  }
}

function safeEqual(a: string, b: string): boolean {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}

function originOf(url: string | undefined): string | undefined {
  if (!url) return undefined;
  try {
    return new URL(url).origin;
  } catch {
    return undefined;
  }
}

isAllowedOrigin 은 두 줄이지만 판단이 여러 개 들어 있습니다.

먼저 Origin 을 봅니다. req.get('origin') 은 헤더가 없으면 undefined 를 줍니다. 있으면 그 값을 그대로 씁니다. Origin 의 값은 http://localhost:5173 처럼 스킴·호스트·포트만 있는 문자열이라 따로 다듬을 필요가 없습니다.

Origin 이 없으면 Referer 로 대신합니다. 오래된 브라우저나 일부 확장 프로그램, 몇몇 요청 종류에서는 Origin 이 빠질 수 있습니다. Referer 는 요청을 시작한 페이지의 전체 URL 이므로(http://localhost:5173/lab?x=1), originOf() 가 new URL(url).origin 으로 출처만 잘라 냅니다. Referer 가 URL 형식이 아니면 new URL() 이 던지므로 try/catch 로 undefined 를 돌려줍니다. 이상한 헤더 하나로 서버가 500 을 내면 안 되니까요. ?? 를 썼으므로 Origin 이 빈 문자열이 아니라 없을 때만 Referer 로 넘어갑니다.

둘 다 없으면 거부합니다. origin !== undefined && 가 그 뜻입니다. 확인할 근거가 없는 요청을 통과시키면 공격자는 헤더를 지우는 방법만 찾으면 됩니다. 실제로 공격 페이지는 <meta name="referrer" content="no-referrer"> 한 줄로 Referer 를 지울 수 있습니다. 근거가 없을 때 막는 쪽(fail closed)을 택하면, 드물게 두 헤더를 모두 지우는 정상 사용자가 불편을 겪는 대신 공격 경로 하나가 사라집니다.

허용 목록과 정확히 같은 문자열이어야 합니다. includes() 는 배열 원소와 === 로 비교합니다. startsWith('http://localhost') 같은 느슨한 비교는 http://localhost.evil.test 를 통과시키고, 정규식은 점(.)의 뜻을 놓치기 쉽습니다. 허용 목록을 문자열 배열로 두고 완전 일치로 비교하는 것이 가장 덜 틀립니다.

Origin: null 은 거부됩니다. 브라우저는 출처를 밝힐 수 없거나 밝히지 않기로 한 경우 Origin 헤더에 null 이라는 문자열을 적습니다. sandbox 속성이 붙은 iframe, data: URL, file:// 로 연 페이지, 일부 출처 간 리다이렉트, 그리고 Referrer-Policy: no-referrer 가 걸린 페이지의 POST 등이 그렇습니다(세부 조건은 브라우저마다 다를 수 있습니다). 문자열 'null' 은 허용 목록에 없으므로 자연히 거부됩니다. 허용 목록에 'null' 을 넣어서는 안 됩니다. 공격자는 sandbox iframe 하나로 언제든 Origin: null 을 만들 수 있습니다.

모듈에 등록한다

CsrfService 를 AuthModule 의 프로바이더로 등록하고, 실습 컨트롤러가 쓸 수 있게 내보냅니다.

// api/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { CsrfService } from './csrf';
import { LoginThrottlerGuard } from './login-throttler.guard';
import { PasswordService } from './password.service';
import { RefreshTokenRepository } from './refresh-token.repository';
import { TokenModule } from './token.module';

@Module({
  imports: [TokenModule, UsersModule, ThrottlerModule.forRoot([{ ttl: 60_000, limit: 5 }])],
  controllers: [AuthController],
  providers: [AuthService, PasswordService, RefreshTokenRepository, CsrfService, LoginThrottlerGuard],
  exports: [CsrfService, PasswordService],
})
export class AuthModule {}

8장에서 LabModule 이 AuthModule 을 가져오게 해 두었으므로 LabModule 은 고칠 것이 없습니다.

실습 컨트롤러에 Origin 검사를 끼운다

// api/src/lab/lab.controller.ts
// … 기존 import …
import { CsrfService } from '../auth/csrf';
// …

export class LabController {
  constructor(
    private readonly users: UsersRepository,
    private readonly passwords: PasswordService,
    private readonly tokens: TokenService,
    private readonly csrf: CsrfService,
    private readonly config: AppConfig,
  ) {}

  // …

  async changeNickname(@Req() req: Request, @Body() dto: UpdateNicknameDto): Promise<UserDto> {
    const user = await this.userFromCookie(req);
    if (this.config.lab.checkOrigin && !this.csrf.isAllowedOrigin(req)) {
      throw new AppError(403, ErrorCode.CSRF_ORIGIN_REJECTED, '허용되지 않은 출처의 요청이다');
    }
    // 9.4 에서 이 자리에 CSRF 토큰 검사를 끼운다
    const row = await this.users.updateNickname(user.userId, dto.nickname);
    return toUserDto(row!);
  }

import { CsrfService } from '../auth/csrf'; 는 '../auth/dto' import 바로 위에 둡니다(import 를 경로 알파벳순으로 정렬해 왔습니다).

검사가 userFromCookie() 뒤에 있다는 점을 눈여겨 두세요. 실습실에서는 쿠키 확인 → Origin 확인 순서라, 쿠키가 없는 요청은 Origin 검사까지 가기 전에 401 로 끝납니다. 본 API 의 CsrfGuard(9.6)는 가드이므로 핸들러보다 먼저 돌고, 그래서 순서가 반대입니다. 이 차이는 9.5 의 결과표에서 드러납니다.

실험 — SameSite 는 none 으로 되돌리고 Origin 검사만

Origin 검사의 효과만 보려고 SameSite 는 가장 약한 none 으로 되돌립니다. 한 번에 한 변수만 바꿔야 결과의 원인을 말할 수 있습니다.

# api/.env
LAB_COOKIE_SAMESITE=none
LAB_CHECK_ORIGIN=true
LAB_CHECK_TOKEN=false

API 를 다시 띄우고 Swagger 에서 POST /lab/login 을 다시 합니다(쿠키를 SameSite=None 으로 새로 받기 위해).

이번에는 닉네임을 되돌리는 정상 요청을 curl 로 보냅니다. curl 의 쿠키 저장소가 곧 "정상 사용자의 브라우저" 역할입니다.

$ API=http://localhost:4000
$ JAR=$(mktemp)
$ curl -s -o /dev/null -w '%{http_code}\n' -c "$JAR" -b "$JAR" \
    -H 'Content-Type: application/json' \
    -d '{"email":"kim@example.com","password":"correct horse battery"}' \
    "$API/lab/login"
204
$ curl -s -b "$JAR" -H 'Origin: http://localhost:5173' -H 'Content-Type: application/json' \
    -d '{"nickname":"김개발"}' "$API/lab/nickname" | jq -c '{nickname}'
{"nickname":"김개발"}

(예상 결과입니다.) Origin: http://localhost:5173 은 우리 FE 의 출처, 즉 WEB_ORIGINS 에 있는 값입니다. curl 과 브라우저는 쿠키 저장소가 다르지만 닉네임은 DB 의 사용자 행에 있으므로 브라우저 쪽 GET /lab/me 도 김개발 을 보여 줍니다.

이제 두 주소로 공격합니다.

Network 탭에서 볼 수 있는 응답은 다음과 같습니다.

{ "statusCode": 403, "code": "CSRF_ORIGIN_REJECTED", "message": "허용되지 않은 출처의 요청이다" }

SameSite 가 막지 못했던 같은 사이트의 공격이 처음으로 막혔습니다. SameSite 는 사이트를 보지만 Origin 검사는 출처를 보기 때문입니다. localhost:5174 와 localhost:5173 은 포트가 달라 다른 출처이고, 허용 목록은 출처 단위로 적혀 있습니다.

curl 로 경계 조건도 확인해 둡니다. 모두 예상 결과입니다.

$ nick() { curl -s -b "$JAR" -H 'Content-Type: application/json' -d '{"nickname":"김개발"}' "$@" "$API/lab/nickname" | jq -c '{statusCode, code, nickname}'; }

$ nick -H 'Origin: http://evil.test'
{"statusCode":403,"code":"CSRF_ORIGIN_REJECTED","nickname":null}

$ nick                                              # Origin·Referer 둘 다 없음
{"statusCode":403,"code":"CSRF_ORIGIN_REJECTED","nickname":null}

$ nick -H 'Origin: null'
{"statusCode":403,"code":"CSRF_ORIGIN_REJECTED","nickname":null}

$ nick -H 'Referer: http://localhost:5173/lab?from=menu'   # Origin 없음 → Referer 로 대신
{"statusCode":null,"code":null,"nickname":"김개발"}

$ nick -H 'Origin: http://localhost:5173/'          # 끝에 / 가 붙으면 다른 문자열
{"statusCode":403,"code":"CSRF_ORIGIN_REJECTED","nickname":null}

마지막 줄은 실수하기 쉬운 곳입니다. 브라우저가 적는 Origin 에는 끝의 / 가 없습니다. .env 의 WEB_ORIGINS 에 http://localhost:5173/ 처럼 끝에 / 를 붙여 적으면, 이번에는 정상 요청이 전부 403 이 됩니다. CORS 설정도 같은 목록을 쓰므로 CORS 도 함께 깨집니다.

#SameSiteOrigin 검사토큰 검사localhost:5174 (같은 사이트)127.0.0.1:5174 (다른 사이트)
0none끔끔성공 (200)성공 (200)
1lax끔끔성공 (200)실패 (401, 쿠키 안 실림)
2strict끔끔성공 (200)실패 (401, 쿠키 안 실림)
3none켬끔실패 (403 CSRF_ORIGIN_REJECTED)실패 (403 CSRF_ORIGIN_REJECTED)

⚠️ 3번 줄의 127.0.0.1:5174 결과는 브라우저가 서드파티 쿠키를 허용할 때의 것입니다. 차단돼 있으면 쿠키가 실리지 않아 401 이 나옵니다. 어느 쪽이든 공격은 실패합니다.

Origin 검사만으로 충분하지 않을까요? 대부분의 경우 충분합니다. 그런데도 세 번째 겹을 두는 이유는 Origin 검사가 헤더가 올바르게 붙는다는 가정에 기대기 때문입니다. Origin 이 빠지는 요청 종류, Referer 폴백의 빈틈, 프록시가 헤더를 바꾸는 배포 환경, 허용 목록 설정 실수 같은 것들이 한 번에 하나씩 문제가 될 수 있습니다. 세 번째 겹은 헤더와 무관하게 "공격자가 모르는 값" 을 요구해서 이런 경우를 덮습니다.

9.4 세 번째 겹 — 서명된 double-submit 토큰

원리

8장에서 본 공격 성립 조건 두 번째는 "예측 가능한 요청" 이었습니다. 이 조건을 깨려면 요청에 공격자가 알 수 없는 값을 넣게 하면 됩니다.

double-submit 방식은 이렇게 동작합니다.

  1. 서버가 난수 토큰을 만들어 같은 값을 두 곳으로 내려 줍니다. 하나는 쿠키(csrf_token), 하나는 응답 본문({ csrfToken })입니다.
  2. FE 는 본문으로 받은 값을 메모리에 두었다가, 쿠키를 쓰는 요청마다 X-CSRF-Token 헤더에 싣습니다.
  3. 서버는 요청의 헤더 값과 쿠키 값이 같은지 확인합니다.

쿠키는 브라우저가 알아서 싣지만 헤더는 페이지 코드가 직접 붙여야 합니다. 공격 페이지가 헤더를 맞추려면 토큰 값을 알아야 하는데, 두 가지 이유로 알 수 없습니다.

게다가 헤더를 붙이는 것 자체가 막힙니다. X-CSRF-Token 은 simple 헤더가 아니므로 이 헤더를 붙인 다른 출처의 요청은 preflight 를 부릅니다.

 시간  공격 페이지 (localhost:5174)                       API (localhost:4000)
 ───  ─────────────────────────────────────────────     ──────────────────────────────
  t0  fetch('/lab/nickname', { method: 'POST',
        credentials: 'include',
        headers: { 'X-CSRF-Token': '아무값' }, … })
      → 커스텀 헤더가 있으니 preflight 가 먼저

  t1  OPTIONS /lab/nickname ─────────────────────────▶  cors: Origin(localhost:5174) ∉ WEB_ORIGINS
        Origin: http://localhost:5174                    → 허용 헤더를 붙이지 않음
        Access-Control-Request-Headers: x-csrf-token
      ◀──────────── 204 (Access-Control-Allow-* 없음)

  t2  브라우저: 본 요청을 보내지 않음                      (POST 는 오지 않았다)

  공격자가 헤더를 빼고 폼으로 보내면?
  t3  POST /lab/nickname (폼, 헤더 없음) ─────────────▶  X-CSRF-Token 없음 → 403 CSRF_TOKEN_INVALID

공격자는 헤더를 붙이면 preflight 에서 막히고, 헤더를 빼면 서버의 토큰 검사에서 막힙니다. 어느 쪽으로도 빠져나갈 수 없습니다. 단, 이 논리는 CORS 허용 목록이 엄격하다는 전제 위에 서 있습니다. origin: true 로 요청 출처를 그대로 되돌려 주도록 설정하면 t1 의 preflight 가 통과하고, /auth/csrf 의 본문도 공격자가 읽을 수 있게 되어 이 겹 전체가 무너집니다. main.ts 의 주석 "명시적인 배열. true(요청 출처 반사)나 '*' 를 쓰지 않는다" 가 이 뜻입니다.

서명은 왜 필요한가 — 쿠키 주입

헤더와 쿠키가 같은지만 보는 단순한 double-submit 에는 알려진 약점이 있습니다. 서버는 "두 값이 같다" 만 확인할 뿐 그 값을 자기가 만들었는지는 모릅니다. 그래서 공격자가 피해자 브라우저에 쿠키를 심을 수 있다면 자기가 정한 값(attacker)을 쿠키로 심고, 같은 값을 요청에도 실어 짝을 맞출 수 있습니다.

다른 출처가 우리 API 의 쿠키를 심을 수 있을까요? 9.2 에서 본 "같은 사이트의 다른 출처" 가 그럴 수 있습니다. evil.example.com 은 Set-Cookie: csrf_token=attacker; Domain=example.com 으로 api.example.com 에도 실리는 쿠키를 만들 수 있고, HTTP 로 오가는 구간이 있으면 중간자가 응답에 Set-Cookie 를 끼워 넣을 수도 있습니다. 헤더 기반 double-submit 에서는 공격자가 헤더까지 맞추려면 여전히 preflight 를 넘어야 하지만, 토큰을 폼 필드로 받는 변형이나 CORS 설정이 느슨해진 경우에는 이 주입만으로 뚫립니다.

서명은 이 틈을 닫습니다. 우리 토큰은 <nonce>.<HMAC(nonce)> 모양이고, HMAC 은 서버만 아는 CSRF_SECRET 으로 계산합니다. 공격자가 임의의 값 attacker 나 abc.def 를 심어도 서버가 서명을 다시 계산해 보면 맞지 않습니다. 서버 비밀 없이는 유효한 짝을 만들 수 없습니다.

다만 한 가지 여지가 남습니다. 공격자가 자기 브라우저로 GET /auth/csrf 를 불러 서버가 서명한 진짜 토큰을 하나 받아 두었다가, 그것을 피해자 브라우저에 심는 경우입니다. 이 설계의 토큰은 특정 사용자나 세션에 묶여 있지 않으므로 서명 검사를 통과합니다. 이것을 막는 방법이 세션 바인딩이고, 연습문제로 남겨 둡니다. 그리고 이 경우에도 공격자는 여전히 헤더를 붙이려면 preflight 를, Origin 을 속이려면 브라우저를 넘어야 합니다. 겹을 여러 개 두는 이유가 여기 있습니다.

💡 쿠키 주입 자체를 더 강하게 막고 싶다면 쿠키 이름에 __Host- 접두사를 붙이는 방법이 있습니다. __Host-csrf_token 은 Secure·Path=/·Domain 없음 조건을 브라우저가 강제해서, 서브도메인이 Domain= 으로 덮어쓰는 것을 막아 줍니다. 14장에서 다시 봅니다.

토큰 코드 읽기

이제 9.3 에서 만든 csrf.ts 의 나머지 부분을 봅니다.

issue() 는 32바이트 난수를 base64url 로 바꾼 43글자의 nonce 를 만들고, 그 nonce 의 HMAC-SHA256 서명(역시 base64url, 43글자)을 점으로 이어 붙입니다. 결과는 87글자 문자열입니다. base64url 은 +·/·= 를 쓰지 않아 헤더·쿠키에 그대로 넣어도 인코딩 문제가 없습니다. 구분자로 점을 쓴 것은 base64url 문자에 점이 없어서 split('.') 이 모호하지 않기 때문입니다. JWT 가 점을 쓰는 것과 같은 이유입니다.

sign() 은 private 입니다. 서명을 만드는 방법이 밖으로 새지 않게, 서비스 밖에서는 issue() 와 isValidPair() 만 쓰게 합니다. CSRF_SECRET 은 JWT_ACCESS_SECRET 과 다른 값이어야 합니다. 한 비밀이 새더라도 다른 쪽까지 무너지지 않게 용도별로 나눕니다(.env 의 주석 참고).

isValidPair(header, cookie) 는 네 단계로 거절할 이유를 찾습니다.

  1. typeof … !== 'string' — 헤더나 쿠키가 없으면 undefined 이고, Express 는 같은 이름의 헤더가 여러 번 오면 형태가 달라질 수 있습니다. 문자열이 아니면 바로 거절합니다. 매개변수 타입을 unknown 으로 받은 것은 호출하는 쪽에서 검사를 빠뜨리지 못하게 하려는 것입니다.
  2. safeEqual(header, cookie) — double-submit 의 본체입니다. 헤더와 쿠키가 같은 문자열이어야 합니다.
  3. split('.') 으로 정확히 두 조각인지 봅니다. [nonce, signature, ...rest] 로 받아 rest.length > 0 이면, 즉 점이 두 개 이상이면 거절합니다. 빈 조각도 거절합니다.
  4. safeEqual(signature, this.sign(nonce)) — 서버가 같은 nonce 로 서명을 다시 계산해 비교합니다.

safeEqual() 이 === 대신 timingSafeEqual 을 쓰는 이유는 타이밍 공격 때문입니다. 일반적인 문자열 비교는 앞에서부터 한 글자씩 보다가 다른 글자를 만나면 곧바로 끝납니다. 그러면 첫 글자가 맞은 추측은 틀린 추측보다 아주 조금 늦게 거절되고, 공격자가 이 시간 차를 많이 모아 재면 정답을 한 글자씩 알아낼 수 있다는 것이 이론적인 위협입니다. timingSafeEqual 은 내용과 무관하게 항상 끝까지 비교합니다. 네트워크 너머에서 이 차이를 실제로 재기는 매우 어렵지만, 비밀 값을 비교하는 곳에는 습관처럼 쓰는 것이 좋습니다.

timingSafeEqual 은 두 Buffer 의 길이가 다르면 예외를 던집니다. 그래서 left.length === right.length && 로 먼저 길이를 확인하고, 다르면 false 를 돌려줍니다. 길이 정보가 시간 차로 드러날 수는 있지만 우리 토큰은 길이가 늘 87글자로 고정돼 있어 숨길 것이 없습니다. Buffer.from(a) 는 문자열을 UTF-8 바이트로 바꿉니다. 글자 수가 같아도 바이트 수가 다를 수 있으므로(한글 등) 길이는 바이트로 비교해야 맞습니다.

쿠키와 발급 엔드포인트

토큰을 내려 줄 쿠키 이름과 설정 함수를 cookies.ts 에 더합니다.

// api/src/auth/cookies.ts
import type { CookieOptions, Response } from 'express';
import type { AppConfig } from '../config/app-config';

export const REFRESH_COOKIE = 'refresh_token';
export const CSRF_COOKIE = 'csrf_token';

// … refreshOptions, setRefreshCookie, clearRefreshCookie 는 그대로 …

export function setCsrfCookie(res: Response, token: string, config: AppConfig) {
  res.cookie(CSRF_COOKIE, token, {
    httpOnly: true,
    secure: config.cookieSecure,
    sameSite: 'strict',
    path: '/',
    maxAge: 2 * 60 * 60 * 1000,
  });
}

옵션을 하나씩 보면 이렇습니다.

응답 본문 모양은 dto.ts 에 더합니다.

// api/src/auth/dto.ts
// …

export class CsrfResponseDto {
  @ApiProperty({ description: 'X-CSRF-Token 헤더에 그대로 싣는다' })
  csrfToken!: string;
}

// … SessionDto …

그리고 AuthController 에 발급 엔드포인트를 만듭니다.

// api/src/auth/auth.controller.ts
// …
import { CSRF_COOKIE, REFRESH_COOKIE, clearRefreshCookie, setCsrfCookie, setRefreshCookie } from './cookies';
import { CsrfService } from './csrf';
import { CsrfResponseDto, LoginDto, SessionDto, SignupDto, TokenResponseDto } from './dto';
// …

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(
    private readonly auth: AuthService,
    private readonly csrf: CsrfService,
    private readonly config: AppConfig,
  ) {}

  @Get('csrf')
  @ApiOperation({
    summary: 'CSRF 토큰 발급',
    description: '본문의 csrfToken 을 login·refresh·logout 요청의 X-CSRF-Token 헤더에 싣는다. 같은 값이 쿠키로도 내려간다.',
  })
  @ApiOkResponse({ type: CsrfResponseDto })
  issueCsrf(@Req() req: Request, @Res({ passthrough: true }) res: Response): CsrfResponseDto {
    // 이미 유효한 쿠키가 있으면 그 값을 돌려준다. 탭마다 새로 만들면 먼저 받은 탭의 토큰이 쿠키와 어긋난다.
    const current: unknown = req.cookies?.[CSRF_COOKIE];
    const csrfToken = this.csrf.isValidPair(current, current) ? (current as string) : this.csrf.issue();
    setCsrfCookie(res, csrfToken, this.config);
    res.setHeader('Cache-Control', 'no-store');
    return { csrfToken };
  }

  // … signup, login, refresh, logout … (7장 그대로)
}

실습 컨트롤러에 토큰 검사를 끼운다

// api/src/lab/lab.controller.ts
// …
import { CSRF_COOKIE } from '../auth/cookies';
import { CsrfService } from '../auth/csrf';
// …

  async changeNickname(@Req() req: Request, @Body() dto: UpdateNicknameDto): Promise<UserDto> {
    const user = await this.userFromCookie(req);
    if (this.config.lab.checkOrigin && !this.csrf.isAllowedOrigin(req)) {
      throw new AppError(403, ErrorCode.CSRF_ORIGIN_REJECTED, '허용되지 않은 출처의 요청이다');
    }
    if (this.config.lab.checkToken && !this.csrf.isValidPair(req.get('x-csrf-token'), req.cookies?.[CSRF_COOKIE])) {
      throw new AppError(403, ErrorCode.CSRF_TOKEN_INVALID, 'CSRF 토큰이 없거나 맞지 않는다');
    }
    const row = await this.users.updateNickname(user.userId, dto.nickname);
    return toUserDto(row!);
  }

import { CSRF_COOKIE } from '../auth/cookies'; 는 '../auth/csrf' import 바로 위에 둡니다. 이제 lab.controller.ts 는 부록 C 와 같습니다. req.get('x-csrf-token') 은 헤더 이름의 대소문자를 가리지 않고 찾아 주고, req.cookies?.[CSRF_COOKIE] 는 cookie-parser 가 파싱한 쿠키에서 꺼냅니다.

실험 — Origin 검사는 끄고 토큰 검사만

# api/.env
LAB_COOKIE_SAMESITE=none
LAB_CHECK_ORIGIN=false
LAB_CHECK_TOKEN=true

API 를 다시 띄우고, Swagger 에서 POST /lab/login 을 다시 합니다. 먼저 curl 로 정상 요청이 통과하는지 봅니다. 9.3 에서 만든 JAR 를 그대로 쓰되 lab_session 을 다시 받습니다.

$ curl -s -o /dev/null -w '%{http_code}\n' -c "$JAR" -b "$JAR" \
    -H 'Content-Type: application/json' \
    -d '{"email":"kim@example.com","password":"correct horse battery"}' "$API/lab/login"
204
$ T=$(curl -s -c "$JAR" -b "$JAR" "$API/auth/csrf" | jq -r .csrfToken); echo "$T"
Xk3…(43글자)….q9Z…(43글자)…

$ curl -s -b "$JAR" -H "X-CSRF-Token: $T" -H 'Content-Type: application/json' \
    -d '{"nickname":"김개발"}' "$API/lab/nickname" | jq -c '{statusCode, code, nickname}'
{"statusCode":null,"code":null,"nickname":"김개발"}

$ curl -s -b "$JAR" -H 'Content-Type: application/json' \
    -d '{"nickname":"김개발"}' "$API/lab/nickname" | jq -c '{statusCode, code}'
{"statusCode":403,"code":"CSRF_TOKEN_INVALID"}

$ curl -s -b "$JAR" -H 'X-CSRF-Token: abc.def' -H 'Content-Type: application/json' \
    -d '{"nickname":"김개발"}' "$API/lab/nickname" | jq -c '{statusCode, code}'
{"statusCode":403,"code":"CSRF_TOKEN_INVALID"}

(모두 예상 결과입니다.) -c "$JAR" 로 /auth/csrf 가 내려 준 csrf_token 쿠키를 저장소에 받아 두었으므로, 이후 요청에는 쿠키와 헤더가 함께 실립니다. 헤더를 빼거나 엉뚱한 값을 넣으면 403 입니다. Origin 검사는 꺼져 있으므로 Origin 헤더는 넣지 않았습니다.

브라우저 공격 결과는 이렇습니다.

#SameSiteOrigin 검사토큰 검사localhost:5174 (같은 사이트)127.0.0.1:5174 (다른 사이트)
0none끔끔성공 (200)성공 (200)
1lax끔끔성공 (200)실패 (401, 쿠키 안 실림)
2strict끔끔성공 (200)실패 (401, 쿠키 안 실림)
3none켬끔실패 (403 CSRF_ORIGIN_REJECTED)실패 (403 CSRF_ORIGIN_REJECTED)
4none끔켬실패 (403 CSRF_TOKEN_INVALID)실패 (403 CSRF_TOKEN_INVALID)

9.5 세 겹을 모두 켠다

마지막으로 세 겹을 모두 켭니다. 본 API 에 적용할 구성과 같은 모양입니다(SameSite 는 실습실이라 lax 로 둡니다).

# api/.env
LAB_COOKIE_SAMESITE=lax
LAB_CHECK_ORIGIN=true
LAB_CHECK_TOKEN=true

정상 요청은 이제 Origin 과 X-CSRF-Token 을 둘 다 실어야 합니다.

$ curl -s -o /dev/null -w '%{http_code}\n' -c "$JAR" -b "$JAR" \
    -H 'Content-Type: application/json' \
    -d '{"email":"kim@example.com","password":"correct horse battery"}' "$API/lab/login"
204
$ T=$(curl -s -c "$JAR" -b "$JAR" "$API/auth/csrf" | jq -r .csrfToken)
$ curl -s -b "$JAR" -H 'Origin: http://localhost:5173' -H "X-CSRF-Token: $T" \
    -H 'Content-Type: application/json' -d '{"nickname":"김개발"}' "$API/lab/nickname" | jq -c '{nickname}'
{"nickname":"김개발"}

(예상 결과입니다.) Swagger 에서 POST /lab/login 을 다시 하고 두 주소로 공격하면 최종 표가 됩니다.

#SameSiteOrigin 검사토큰 검사localhost:5174 (같은 사이트)127.0.0.1:5174 (다른 사이트)
0none끔끔성공 (200)성공 (200)
1lax끔끔성공 (200)실패 (401, 쿠키 안 실림)
2strict끔끔성공 (200)실패 (401, 쿠키 안 실림)
3none켬끔실패 (403 CSRF_ORIGIN_REJECTED)실패 (403 CSRF_ORIGIN_REJECTED)
4none끔켬실패 (403 CSRF_TOKEN_INVALID)실패 (403 CSRF_TOKEN_INVALID)
5lax켬켬실패 (403 CSRF_ORIGIN_REJECTED)실패 (401, 쿠키 안 실림)

5번 줄의 두 결과가 서로 다른 코드로 실패한 것은 실습 컨트롤러의 검사 순서 때문입니다. 127.0.0.1:5174 는 SameSite 에서 쿠키가 빠져 userFromCookie() 가 먼저 401 을 던졌고, localhost:5174 는 쿠키가 실려 그다음 Origin 검사에서 403 이 났습니다. 각 요청은 가장 먼저 걸리는 겹에서 멈춥니다. 앞 겹이 뚫려도 뒤 겹이 받친다는 "여러 겹" 의 뜻이 이 한 줄에 들어 있습니다.

표를 세로로 읽어 보면 각 겹이 무엇을 맡는지 정리됩니다.

세 겹은 서로 다른 것에 기대고 있어서, 하나의 가정이 무너져도 나머지가 버팁니다.

9.6 본 API 에 적용하기

실습실에서 확인한 두 서버 쪽 검사를 가드 하나로 묶어 본 API 에 겁니다. SameSite 는 이미 6장에서 refresh_token 에 Strict 로 걸려 있습니다.

CsrfGuard

csrf.ts 끝에 가드를 더하고 import 를 늘립니다. 이제 파일은 부록 C 와 같습니다.

// api/src/auth/csrf.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { AppError, ErrorCode } from '../common/errors';
import { AppConfig } from '../config/app-config';
import { CSRF_COOKIE } from './cookies';

// … CsrfService, safeEqual, originOf 는 9.3 그대로 …

/** Origin 검사 → double-submit 토큰 검사 순서로 막는다. */
@Injectable()
export class CsrfGuard implements CanActivate {
  constructor(private readonly csrf: CsrfService) {}

  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest<Request>();
    if (!this.csrf.isAllowedOrigin(req)) {
      throw new AppError(403, ErrorCode.CSRF_ORIGIN_REJECTED, '허용되지 않은 출처의 요청이다');
    }
    if (!this.csrf.isValidPair(req.get('x-csrf-token'), req.cookies?.[CSRF_COOKIE])) {
      throw new AppError(403, ErrorCode.CSRF_TOKEN_INVALID, 'CSRF 토큰이 없거나 맞지 않는다');
    }
    return true;
  }
}

실습 컨트롤러의 두 if 와 내용이 같고, 스위치(checkOrigin·checkToken)만 없습니다. 본 API 에서는 두 검사가 항상 켜져 있습니다. Origin 검사를 먼저 하는 것은 비용이 더 싸고(문자열 비교 하나), 실패 원인이 더 분명하기 때문입니다. 설정 실수로 허용 목록이 틀렸다면 CSRF_ORIGIN_REJECTED 가, FE 가 토큰을 빠뜨렸다면 CSRF_TOKEN_INVALID 가 나와 어디를 고칠지 바로 알 수 있습니다.

가드로 만든 이유는 실행 시점 때문입니다. Nest 의 가드는 파이프(ValidationPipe)와 핸들러보다 먼저 돕니다. CSRF 로 판정된 요청은 본문 검증도, DB 조회도, 비밀번호 해싱도 하지 않고 바로 거절됩니다. 실습실과 달리 쿠키 확인보다도 먼저입니다.

AuthController

login·refresh·logout 세 핸들러에 가드와 문서 데코레이터를 붙입니다. 바뀌는 부분만 보입니다.

// api/src/auth/auth.controller.ts
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Post, Req, Res, UseGuards } from '@nestjs/common';
import {
  ApiBadRequestResponse,
  ApiBearerAuth,
  ApiConflictResponse,
  ApiCookieAuth,
  ApiCreatedResponse,
  ApiForbiddenResponse,
  ApiNoContentResponse,
  ApiNotFoundResponse,
  ApiOkResponse,
  ApiOperation,
  ApiSecurity,
  ApiTags,
  ApiTooManyRequestsResponse,
  ApiUnauthorizedResponse,
} from '@nestjs/swagger';
// …
import { CsrfGuard, CsrfService } from './csrf';
// …

const CSRF_ERRORS = 'CSRF_ORIGIN_REJECTED | CSRF_TOKEN_INVALID';

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  // … constructor, issueCsrf, signup …

  @Post('login')
  @HttpCode(200)
  @UseGuards(LoginThrottlerGuard, CsrfGuard)
  @ApiSecurity('csrf')
  @ApiOperation({ summary: '로그인', description: '액세스 토큰은 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 준다.' })
  @ApiOkResponse({ type: TokenResponseDto })
  @ApiUnauthorizedResponse({ type: ErrorResponseDto, description: 'INVALID_CREDENTIALS' })
  @ApiBadRequestResponse({ type: ErrorResponseDto, description: 'VALIDATION_FAILED' })
  @ApiForbiddenResponse({ type: ErrorResponseDto, description: CSRF_ERRORS })
  @ApiTooManyRequestsResponse({ type: ErrorResponseDto, description: 'TOO_MANY_REQUESTS' })
  async login(
    // … 본문 그대로 …

  @Post('refresh')
  @HttpCode(200)
  @UseGuards(CsrfGuard)
  @ApiSecurity('csrf')
  @ApiCookieAuth('refresh-cookie')
  @ApiOperation({ summary: '리프레시 토큰 회전', description: '쿠키의 refresh_token 을 새 것으로 바꾸고 새 액세스 토큰을 준다.' })
  @ApiOkResponse({ type: TokenResponseDto })
  @ApiUnauthorizedResponse({ type: ErrorResponseDto, description: 'REFRESH_TOKEN_INVALID | REFRESH_TOKEN_REUSED' })
  @ApiConflictResponse({ type: ErrorResponseDto, description: 'REFRESH_TOKEN_ROTATED — 한 번 더 시도한다' })
  @ApiForbiddenResponse({ type: ErrorResponseDto, description: CSRF_ERRORS })
  async refresh(
    // … 본문 그대로 …

  @Post('logout')
  @HttpCode(204)
  @UseGuards(CsrfGuard)
  @ApiSecurity('csrf')
  @ApiCookieAuth('refresh-cookie')
  @ApiOperation({ summary: '로그아웃', description: '이 세션(family)을 폐기하고 쿠키를 지운다. 쿠키가 없어도 204.' })
  @ApiNoContentResponse()
  @ApiForbiddenResponse({ type: ErrorResponseDto, description: CSRF_ERRORS })
  async logout(
    // … 본문 그대로 …

  // … logoutAll, listSessions, deleteSession 은 그대로 (Bearer 라 CSRF 대상이 아니다) …
}

AuthModule

// api/src/auth/auth.module.ts
// …
import { CsrfGuard, CsrfService } from './csrf';
// …

@Module({
  imports: [TokenModule, UsersModule, ThrottlerModule.forRoot([{ ttl: 60_000, limit: 5 }])],
  controllers: [AuthController],
  providers: [AuthService, PasswordService, RefreshTokenRepository, CsrfService, CsrfGuard, LoginThrottlerGuard],
  exports: [CsrfService, PasswordService],
})
export class AuthModule {}

CsrfGuard 를 providers 에 넣는 것은 필수는 아니지만(@UseGuards 가 알아서 만들어 줍니다), 이 모듈이 어떤 가드를 쓰는지 한눈에 보이게 하려는 것입니다. LoginThrottlerGuard 를 넣어 둔 것과 같은 이유입니다. 이것으로 AuthModule 이 부록 C 와 같아졌습니다.

csrfAllowedOrigins 에 API 자신의 출처가 들어가는 이유

설정 계층의 허용 목록 게터를 다시 봅시다.

// api/src/config/app-config.ts
  /** CSRF Origin 검사 허용 목록. Swagger UI 는 API 자신의 출처에서 요청한다. */
  get csrfAllowedOrigins(): string[] {
    return this.swaggerEnabled ? [...this.webOrigins, this.apiOrigin] : this.webOrigins;
  }

FE 에서 오는 요청의 Origin 은 http://localhost:5173 이고, 이것은 WEB_ORIGINS 에 있습니다. 그런데 Swagger UI 는 API 서버가 직접 내보내는 페이지라 http://localhost:4000/docs 에서 돌고, 거기서 보낸 요청의 Origin 은 http://localhost:4000 입니다. 이 값이 목록에 없으면 Swagger 에서 login 을 시험할 수 없습니다. 그래서 Swagger 를 켰을 때만 API_ORIGIN 을 허용 목록에 더합니다.

운영에서 SWAGGER_ENABLED=false 로 두면 API_ORIGIN 은 목록에서 빠집니다. API 출처에서 요청을 보낼 정상적인 페이지가 Swagger 말고는 없기 때문입니다. 허용 목록은 짧을수록 좋습니다. 켜 둘 필요가 없는 출처를 넣어 두면, 그 출처에서 HTML 을 내보내는 경로(예: 에러 페이지, 정적 파일)가 생겼을 때 공격면이 됩니다.

로그인 CSRF 를 다시 보면

8.4 에서 login 은 쿠키를 읽지 않는데도 방어 대상이라고 했습니다. 세 겹이 이 공격을 어떻게 막는지 확인해 봅시다.

공격자가 자기 계정의 이메일·비밀번호를 담은 폼을 피해자 브라우저에서 POST /auth/login 으로 보낸다고 합시다.

로그인에는 아직 세션이 없으니 "로그인 전의 CSRF 토큰" 이 어떻게 가능하냐고 물을 수 있습니다. 우리 토큰은 세션에 묶이지 않은 서명 토큰이라 로그인 전에도 GET /auth/csrf 로 받을 수 있습니다. 이것이 로그인 CSRF 까지 같은 가드로 막을 수 있는 이유입니다.

9.7 curl 과 Swagger 로 확인하기

curl — 준비 함수

이제 login·refresh·logout 은 Origin 과 X-CSRF-Token 없이는 403 입니다. 7장에서 쓰던 curl -c "$JAR" -b "$JAR" -X POST …/auth/refresh 같은 명령은 그대로 두면 모두 403 CSRF_ORIGIN_REJECTED 를 받습니다(curl 은 Origin 을 스스로 붙이지 않습니다). 매번 헤더를 적기 번거로우니 준비 함수를 만듭니다. 13장의 시나리오 스크립트도 이 함수들을 씁니다.

API=http://localhost:4000
ORIGIN='Origin: http://localhost:5173'
JAR=$(mktemp)                       # 쿠키 저장소 — 브라우저 역할
csrf() { curl -s -c "$JAR" -b "$JAR" "$API/auth/csrf" | jq -r .csrfToken; }
T=$(csrf)
post() { curl -s -i -c "$JAR" -b "$JAR" -H "$ORIGIN" -H "X-CSRF-Token: $T" -H 'Content-Type: application/json' -X POST "$API$1" ${2:+-d "$2"}; }

이 함수로 7장의 흐름을 다시 돌려 봅니다.

$ post /auth/login '{"email":"kim@example.com","password":"correct horse battery"}' | grep -i -E '^HTTP|^set-cookie'
HTTP/1.1 200 OK
Set-Cookie: refresh_token=…; Path=/auth; Expires=…; HttpOnly; Secure; SameSite=Strict

$ post /auth/refresh | head -1
HTTP/1.1 200 OK

$ post /auth/logout | head -1
HTTP/1.1 204 No Content

(예상 결과입니다.) 그리고 방어가 실제로 동작하는지, 스킬 문서의 시나리오 S10~S13 에 해당하는 네 가지를 확인합니다.

$ # S10 — X-CSRF-Token 없음
$ curl -s -c "$JAR" -b "$JAR" -H "$ORIGIN" -X POST "$API/auth/refresh" | jq -c '{statusCode, code}'
{"statusCode":403,"code":"CSRF_TOKEN_INVALID"}

$ # S11 — 헤더·쿠키에 같은 임의 값 (서명 불일치)
$ curl -s -H "$ORIGIN" -H 'X-CSRF-Token: abc.def' -H 'Cookie: csrf_token=abc.def' \
    -X POST "$API/auth/refresh" | jq -c '{statusCode, code}'
{"statusCode":403,"code":"CSRF_TOKEN_INVALID"}

$ # S12 — Origin 위조
$ curl -s -c "$JAR" -b "$JAR" -H 'Origin: http://evil.test' -H "X-CSRF-Token: $T" \
    -X POST "$API/auth/refresh" | jq -c '{statusCode, code}'
{"statusCode":403,"code":"CSRF_ORIGIN_REJECTED"}

$ # S13 — Origin·Referer 둘 다 없음
$ curl -s -c "$JAR" -b "$JAR" -H "X-CSRF-Token: $T" \
    -X POST "$API/auth/refresh" | jq -c '{statusCode, code}'
{"statusCode":403,"code":"CSRF_ORIGIN_REJECTED"}

(예상 결과입니다.) S11 은 헤더와 쿠키가 같은 값이라 단순 double-submit 이었다면 통과했을 요청입니다. 서명 검사가 막았습니다.

Swagger UI — Authorize 에 csrf 넣기

Swagger UI 에서도 같은 절차를 손으로 합니다. openapi.ts 의 설명문에 적힌 순서 그대로입니다.

  1. GET /auth/csrf 를 Try it out → Execute 합니다. 응답 본문의 csrfToken 값을 복사합니다. 이때 csrf_token 쿠키도 브라우저에 저장됩니다.
  2. 오른쪽 위 Authorize 를 누르고 csrf (apiKey) 칸에 붙여 넣은 뒤 Authorize 합니다.
  3. POST /auth/login 을 실행합니다. 자물쇠 아이콘이 붙은 요청에는 X-CSRF-Token 헤더가 자동으로 실립니다. 응답의 accessToken 은 access-token 칸에 넣어 Bearer 요청에 씁니다.
  4. POST /auth/refresh·POST /auth/logout 도 그대로 실행할 수 있습니다.

main.ts 의 persistAuthorization: true 덕분에 새로고침해도 Authorize 값이 남습니다. 다만 쿠키는 2시간 뒤 만료되므로, 어느 순간 CSRF_TOKEN_INVALID 가 나오면 1~2단계를 다시 합니다. 쿠키가 살아 있는 동안에는 GET /auth/csrf 를 다시 실행해도 같은 값이 나옵니다(9.4절). 만료 뒤에 다시 실행하면 새 값이 나오니 그때는 Authorize 값도 새 값으로 바꿉니다.

⚠️ Swagger 에서 403 CSRF_ORIGIN_REJECTED 가 나온다면 먼저 주소창이 http://localhost:4000/docs 인지(127.0.0.1 이 아닌지), .env 의 API_ORIGIN 이 그 출처와 글자 하나까지 같은지, SWAGGER_ENABLED=true 인지 봅니다. 그래도 나온다면 개발자 도구에서 그 요청의 Origin 요청 헤더 값을 확인하세요. 💡 helmet 은 Referrer-Policy: no-referrer 를 기본으로 붙이는데, 이것이 Swagger 의 Origin 을 null 로 만들지는 않습니다. Fetch 표준에서 이 정책 때문에 Origin 을 null 로 바꾸는 규칙은 요청 모드가 cors 가 아닐 때(폼 제출 같은 탐색 요청, no-cors fetch)에만 적용됩니다. Swagger UI 는 fetch 로 요청하므로 모드가 cors 이고, Origin 에는 실제 출처가 실립니다. 그래도 null 이 보인다면 브라우저 확장 프로그램이나 프록시가 헤더를 바꾸고 있지 않은지 확인하고, 부록 B 의 해당 항목을 보세요.

9.8 한계 — CSRF 방어가 막지 못하는 것

XSS 앞에서는 무력하다

세 겹의 방어는 모두 공격 코드가 우리 출처 밖에서 돈다는 가정 위에 서 있습니다. 공격자의 스크립트가 우리 FE 페이지 안에서 돌면 사정이 완전히 달라집니다. FE 에 XSS 취약점이 있어 공격자의 스크립트가 http://localhost:5173 에서 실행된다고 해 봅시다.

즉 XSS 는 CSRF 방어를 정당하게 통과합니다. CSRF 방어는 "다른 곳에서 위조한 요청" 을 막는 장치이지, "우리 페이지 안의 악성 코드" 를 막는 장치가 아닙니다. XSS 는 별도의 방어가 필요합니다. 출력할 때 이스케이프하기(React 는 기본으로 해 줍니다), dangerouslySetInnerHTML 을 신뢰할 수 없는 값에 쓰지 않기, 그리고 CSP(Content-Security-Policy) 로 허용한 곳의 스크립트만 실행되게 하는 것이 대표적입니다. API 에는 helmet 이 기본 CSP 를 붙이지만, 사용자가 보는 FE 페이지의 CSP 는 FE 를 내보내는 서버(운영의 정적 호스팅이나 CDN)에서 따로 설정해야 합니다(14장).

이 책이 액세스 토큰을 localStorage 가 아니라 메모리에 두는 것(11장)도 XSS 가 일어났을 때 피해를 줄이려는 것이지 막으려는 것이 아닙니다. XSS 가 일어나면 공격자는 토큰을 가져가지 못하더라도, 페이지가 열려 있는 동안 사용자 대신 API 를 부를 수 있습니다.

우연히 작동하고 있는 방어 — text/plain

8장의 변형 1(fetch + no-cors + text/plain)이 400 으로 실패한 것을 기억할 것입니다. 우리가 CSRF 를 의식하고 만든 결과가 아니라, Nest(Express 어댑터)의 기본 본문 파서가 application/json 과 application/x-www-form-urlencoded 만 파싱하고 text/plain 은 건드리지 않은 덕분이었습니다.

본 API 의 login 에서 이 점이 더 중요합니다. 만약 누군가 "JSON 을 text/plain 으로 보내는 클라이언트도 받아 주자" 며 text/plain 본문을 JSON 으로 파싱하는 미들웨어를 추가하면, 공격 페이지는 preflight 없이(text/plain 은 simple 이므로) {"email":"attacker@…","password":"…"} 를 POST /auth/login 으로 보낼 수 있게 됩니다. 그때 남는 방어는 Origin 검사와 토큰 검사뿐입니다. 지금은 CsrfGuard 가 있으니 막히지만, 방어가 하나 줄었다는 사실은 변하지 않습니다.

그래서 "JSON 만 받으니 안전하다" 는 문장은 본문 파서 설정에 대한 가정이라는 것을 알아 두어야 합니다. 본문 파서를 넓히는 변경은 보안 검토 대상입니다. 같은 이유로, 폼 본문(urlencoded)을 파싱하는 기본 동작 때문에 실습실의 닉네임 변경이 폼 하나로 뚫렸다는 것도 기억해 두세요. 본 API 에서 쿠키를 쓰는 세 엔드포인트는 CsrfGuard 가 지키므로 본문 형식과 무관하게 안전합니다.

확인하기

1. 실습실 최종 설정에서 정상 요청은 통과하고 헤더 하나라도 빠지면 막힌다. LAB_COOKIE_SAMESITE=lax, LAB_CHECK_ORIGIN=true, LAB_CHECK_TOKEN=true 로 띄우고 9.5 의 curl 을 실행합니다. Origin 과 X-CSRF-Token 을 모두 실으면 {"nickname":"김개발"}, 하나라도 빼면 403 이어야 합니다.

2. 브라우저 공격이 두 주소 모두에서 실패한다. 9.5 의 표 5번 줄처럼 localhost:5174 는 403 CSRF_ORIGIN_REJECTED, 127.0.0.1:5174 는 401 이고 Swagger 의 GET /lab/me 는 김개발 그대로여야 합니다.

3. 본 API 의 정상 흐름이 준비 함수로 동작한다.

$ T=$(csrf)
$ post /auth/login '{"email":"kim@example.com","password":"correct horse battery"}' | head -1
HTTP/1.1 200 OK
$ post /auth/refresh | head -1
HTTP/1.1 200 OK

4. S10~S13 이 모두 403 이다. 9.7 의 네 명령을 실행해 CSRF_TOKEN_INVALID 두 번, CSRF_ORIGIN_REJECTED 두 번이 나오는지 봅니다.

5. GET /auth/csrf 응답이 계약대로다.

$ curl -s -i "$API/auth/csrf" | grep -i -E '^HTTP|^set-cookie|^cache-control'
HTTP/1.1 200 OK
Cache-Control: no-store
Set-Cookie: csrf_token=…; Max-Age=7200; Path=/; Expires=…; HttpOnly; Secure; SameSite=Strict

(예상 결과입니다. 헤더 순서와 Set-Cookie 속성의 순서는 다를 수 있습니다.)

6. Swagger 문서에 반영됐다. http://localhost:4000/docs 에서 login·refresh·logout 에 자물쇠 아이콘과 403 응답이 보이고, GET /auth/csrf 가 auth 태그에 있어야 합니다.

자주 하는 실수

설정을 바꿨는데 결과가 그대로다. 세 가지를 차례로 봅니다. API 를 다시 띄웠는가, /lab/settings 가 새 값을 보여 주는가, POST /lab/login 을 다시 해서 새 속성의 쿠키를 받았는가. 세 번째를 가장 많이 빠뜨립니다.

curl 에서 모든 요청이 CSRF_ORIGIN_REJECTED 다. curl 은 Origin 을 붙이지 않습니다. -H "$ORIGIN" 을 빠뜨리지 않았는지, ORIGIN 변수의 값이 WEB_ORIGINS 와 정확히 같은지(끝의 / 포함) 봅니다.

curl 에서 CSRF_TOKEN_INVALID 가 계속 난다. T=$(csrf) 를 한 저장소($JAR)로 받았는데 요청은 다른 저장소로 보냈거나, csrf() 를 두 번 불러 쿠키가 바뀌었는데 $T 는 옛 값인 경우가 대부분입니다. JAR 를 새로 만들고 T=$(csrf) 부터 다시 합니다. 또 curl 버전에 따라 http://localhost 로 Secure 쿠키를 보내지 않을 수 있습니다(부록 B).

허용 목록에 'null' 이나 와일드카드를 넣는다. 테스트가 안 된다고 WEB_ORIGINS=null,… 을 넣거나 origin.endsWith('localhost:5173') 처럼 느슨하게 비교하면 그 순간 Origin 검사는 의미를 잃습니다. 허용 목록은 완전한 출처 문자열의 목록이어야 합니다.

CORS 를 origin: true 로 바꿔 문제를 "해결" 한다. FE 개발 중 CORS 에러가 귀찮아 요청 출처를 그대로 되돌려 주는 설정으로 바꾸면, preflight 가 모든 출처를 통과시키고 /auth/csrf 의 본문도 누구나 읽게 되어 토큰 겹이 통째로 무너집니다. CORS 에러는 WEB_ORIGINS 를 고쳐서 해결합니다.

CSRF 토큰을 localStorage 에 저장한다. 토큰은 비밀번호만큼 민감하지는 않지만, 저장해 둘 이유도 없습니다. 메모리에 두고, 새로고침 뒤에는 GET /auth/csrf 로 다시 받으면 됩니다(12장).

=== 로 토큰을 비교한다. 동작은 하지만 타이밍 공격의 여지를 남깁니다. 비밀 값 비교는 safeEqual 을 거칩니다. 반대로 timingSafeEqual 을 직접 부르면서 길이 확인을 빠뜨리면 길이가 다른 입력 하나로 500 이 납니다.

CsrfGuard 를 전역 가드로 건다. 모든 요청에 걸면 Bearer 요청과 GET /auth/csrf 자신까지 막힙니다. CSRF 방어는 쿠키를 쓰는 상태 변경 요청에만 겁니다.

정리

연습문제

1. LAB_COOKIE_SAMESITE=strict, LAB_CHECK_ORIGIN=false, LAB_CHECK_TOKEN=true 로 설정했을 때 localhost:5174 와 127.0.0.1:5174 에서의 공격 결과(상태 코드와 에러 코드)를 예측하세요. 실습 컨트롤러의 검사 순서를 근거로 드세요.

해답
  • 127.0.0.1:5174: 401 ACCESS_TOKEN_INVALID. 다른 사이트라 Strict 쿠키가 실리지 않고, changeNickname 은 userFromCookie() 를 먼저 부르므로 토큰 검사까지 가지 않습니다.
  • localhost:5174: 403 CSRF_TOKEN_INVALID. 같은 사이트라 lab_session 이 실려 쿠키 확인은 통과합니다. Origin 검사는 꺼져 있고, 폼은 X-CSRF-Token 헤더를 붙일 수 없으므로 토큰 검사에서 막힙니다. (참고로 csrf_token 쿠키도 같은 사이트라 실리지만, 헤더가 없으니 isValidPair 의 첫 줄에서 거절됩니다.)

2. 다음 두 설정 중 어느 것이 더 위험한지, 이유와 함께 답하세요. (a) WEB_ORIGINS 에 운영 FE 출처와 함께 http://localhost:5173 이 남아 있다. (b) CsrfService.isAllowedOrigin 을 origin === undefined || allowed.includes(origin) 으로 바꿨다(헤더가 없으면 통과).

해답

(b) 가 훨씬 위험합니다. 공격자는 Origin 과 Referer 를 빼는 방법만 찾으면 Origin 검사를 건너뜁니다. Referer 는 <meta name="referrer" content="no-referrer"> 로 지울 수 있고, Origin 이 빠지는 요청 종류가 있거나 생기면 그대로 통과합니다. 근거가 없을 때 통과시키는 검사는 검사가 아닙니다(fail open).

(a) 도 고쳐야 할 설정 실수입니다(14장 체크리스트). 다만 공격이 되려면 공격자가 피해자 컴퓨터의 localhost:5173 에서 페이지를 띄워야 합니다. 피해자가 개발자이고 그 포트에서 악성 코드가 도는 개발 서버를 띄운 경우 등 조건이 까다롭습니다. 그리고 그 경우에도 토큰 검사가 남아 있습니다.

3. 우리 CSRF 토큰은 사용자나 세션과 무관합니다. 공격자가 자기 브라우저로 GET /auth/csrf 를 불러 얻은 진짜 토큰을 피해자 브라우저에 쿠키로 심을 수 있다면(같은 사이트 공격), 토큰 검사는 어떻게 되나요? 이것을 막는 세션 바인딩을 설계해 보세요. issue() 와 isValidPair() 가 어떻게 바뀌어야 하고, FE 는 언제 토큰을 다시 받아야 하나요?

해답

쿠키 쪽은 통과할 수 있습니다. 서명이 서버가 만든 진짜 서명이기 때문입니다. 공격자가 여전히 막히는 곳은 헤더입니다(같은 값을 X-CSRF-Token 에 실으려면 preflight 를 넘어야 함). 하지만 이 겹만 놓고 보면 "서버가 만든 아무 토큰이나" 통과하는 것은 약점입니다.

세션 바인딩은 HMAC 입력에 세션 식별자를 넣는 것입니다.

  • 발급: token = nonce + "." + HMAC(CSRF_SECRET, sessionId + "." + nonce). 로그인 전에는 세션이 없으므로 익명용 식별자(예: 빈 문자열이나 별도의 사전 세션 쿠키)를 씁니다.
  • 검증: 요청의 세션 식별자를 서버가 알아낸 뒤(우리 설계에서는 리프레시 쿠키의 family id 를 DB 에서 찾거나, 로그인 전이면 익명) 같은 방식으로 서명을 재계산해 비교합니다.
  • FE: 로그인 직후와 로그아웃 직후 세션이 바뀌므로 토큰을 다시 받아야 합니다. 로그인 응답에 새 토큰을 함께 주거나, 로그인 뒤 GET /auth/csrf 를 다시 부릅니다.

이러면 공격자가 자기 세션으로 받은 토큰은 피해자 세션에서 서명이 맞지 않습니다. 대가는 검증할 때 세션을 알아내는 비용(우리 설계에서는 refresh 경로의 DB 조회)과 FE 흐름의 복잡도입니다. 이 책의 기본 설계가 넣지 않은 이유가 이것입니다.

4. 동료가 "Origin 검사가 있으니 CSRF 토큰은 빼자. FE 코드도 단순해진다" 고 제안합니다. 이 제안을 받아들이기 전에 확인해야 할 조건을 두 가지 이상 드세요.

해답

예시 답:

  • 지원해야 하는 모든 브라우저가 상태 변경 요청에 Origin 을 확실히 붙이는가. 오래된 브라우저나 일부 확장 프로그램, 특정 요청 종류에서 빠지면 Referer 폴백에 기대게 되고, 그마저 없으면 정상 사용자가 거부됩니다.
  • API 앞의 프록시·CDN·WAF 가 Origin·Referer 헤더를 지우거나 바꾸지 않는가. 배포 구성이 바뀔 때마다 다시 확인해야 합니다.
  • 허용 목록 관리가 확실한가. 새 FE 도메인을 추가하거나 스테이징 출처를 넣다 뺄 때 실수가 생기면 단일 방어가 곧바로 무너집니다.
  • 같은 출처에서 HTML 을 내보내는 다른 경로가 없는가. 허용 목록에 있는 출처에서 사용자 콘텐츠를 보여 주는 페이지가 생기면 Origin 검사는 그 페이지를 막지 못합니다.

이 조건들을 모두 장담하기 어렵기 때문에, 이 책은 두 검사를 함께 둡니다. FE 쪽 비용은 ensureCsrf() 하나(12장)로 작습니다.

5. 9.8 에서 XSS 앞에서는 CSRF 방어가 무력하다고 했습니다. 그렇다면 XSS 가 있는 상황에서도 여전히 의미가 있는 우리 설계의 선택을 하나 들고, 무엇을 지켜 주는지 설명하세요.

해답

예시 답: 리프레시 토큰을 HttpOnly 쿠키로 둔 것. XSS 스크립트는 사용자의 브라우저 안에서 API 를 부를 수는 있지만 리프레시 토큰 값을 읽어 밖으로 가져갈 수는 없습니다. 그래서 사용자가 탭을 닫으면 공격자의 손도 거기서 멈춥니다. 공격자가 토큰을 가져가 자기 서버에서 14일 동안 계속 쓰는 것보다 피해 범위가 훨씬 좁습니다. 또 가져간 리프레시 토큰을 썼다면 7장의 재사용 탐지가 걸릴 기회가 생깁니다. 액세스 토큰을 메모리에 두는 것도 같은 방향의 선택입니다.