CSRF와 SameSite 쿠키: Spring Security의 방어 메커니즘

1. 개요

CSRF(Cross-Site Request Forgery)는 고전적인 웹 취약점 중 하나이지만 여전히 많은 혼동을 야기합니다: 많은 사람들은 SameSite=Strict를 활성화하면 충분하고 CSRF 토큰이 더 이상 필요 없다고 생각합니다. 이 글에서는 CSRF 공격의 메커니즘, SameSite 쿠키의 실제 역할, Spring Security에서 CSRF 보호를 올바르게 설정하는 방법을 설명합니다. 이 글을 읽은 후에는 CSRF 토큰이 필요한 시점, SameSite만으로 충분한 시점, 그리고 검증 테스트를 작성하는 방법을 알게 될 것입니다.

2. CSRF는 어떻게 작동하는가

세션 쿠키로 인증하는 송금 엔드포인트가 있는 PaymentService가 있다고 가정해봅시다:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/transfer")
    public ResponseEntity<Void> transfer(@RequestParam String toAccount,
                                          @RequestParam BigDecimal amount,
                                          HttpServletRequest request) {
        String customerId = (String) request.getSession().getAttribute("customerId");
        paymentService.transfer(customerId, toAccount, amount);
        return ResponseEntity.ok().build();
    }
}

customer가 이미 로그인한 경우(browser가 세션 쿠키를 가지고 있음), 악의적인 페이지 evil.com은 다음 폼을 삽입하기만 하면 됩니다:

<form action="https://bank.example.com/api/payments/transfer" method="POST">
  <input type="hidden" name="toAccount" value="attacker-account">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>

customer가 evil.com을 열 때, browser는 대상 도메인만 고려하고 요청이 어디에서 시작되었는지는 상관하지 않기 때문에 bank.example.com의 쿠키를 자동으로 첨부합니다. 서버는 세션 관점에서 유효한 요청으로 받아들이고 송금을 실행합니다. 이것이 CSRF의 본질입니다: browser가 credential(쿠키)을 자동으로 보내는 방식을 악용하며, 요청이 어느 페이지에서 시작되었는지는 상관없습니다.

주의: CSRF는 쿠키와 같은 “ambient” 인증 메커니즘에만 위험합니다. Authorization: Bearer <token> 헤더를 사용하고 클라이언트가 직접 헤더를 설정해야 하는 경우(CORS로 인해 JS는 cross-site에서 이를 할 수 없음), 고전적인 CSRF는 적용되지 않습니다.

3. SameSite 쿠키 속성

SameSite는 쿠키의 플래그로, browser에게 이 쿠키를 cross-site 요청과 함께 보낼지 여부를 지시합니다. 3가지 값이 있습니다:

값 동작
Strict 쿠키는 동일 사이트 요청(first-party navigation)에서만 전송됩니다. 사용자가 다른 사이트의 링크를 클릭해서 접근하는 경우에도 전송되지 않습니다.
Lax 쿠키는 top-level navigation GET(사용자가 링크를 클릭)과 함께 전송되지만, 다른 사이트에서의 POST/subrequest에서는 전송되지 않습니다.
None 쿠키는 항상 cross-site에서 전송되며, 반드시 Secure와 함께 사용되어야 합니다.

Spring Boot에서 application.yml을 통해 설정합니다:

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true

same-site: lax를 사용하면 위 예제의 CSRF 요청(evil.com에서 form POST 자동 전송)은 세션 쿠키를 전혀 보내지 않습니다 — 최신 browser는 이를 직접 차단합니다. 이것이 Chrome 2020 이후로 Lax가 기본값이 된 이유이며, 대부분의 form-POST 형식의 CSRF 공격을 방어합니다.

3.1. SameSite만으로는 충분하지 않은 이유

SameSite는 완벽한 솔루션이 아닙니다. 몇 가지 이유가 있습니다:

  • Subdomain 탈취: 사이트의 blog.example.com이 손상되면, 여전히 app.example.com과 “동일 사이트”로 간주되므로 쿠키가 전송될 수 있습니다.
  • 구형 browser: 일부 browser/버전은 SameSite를 완전히 지원하지 않으며, 플래그가 없는 것처럼 간주하고 이전처럼 쿠키를 전송합니다.
  • 부작용이 있는 GET 요청: 엔드포인트가 실수로 GET으로 데이터 쓰기 작업을 허용하는 경우(anti-pattern이지만 여전히 존재), Lax는 여전히 top-level navigation GET에서 쿠키를 허용합니다.
// Anti-pattern: GET이 부작용을 일으킴, Lax는 이를 차단할 수 없음
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
    orderService.cancel(id); // <img src="...">을 통해 노출되면 위험
    return ResponseEntity.ok().build();
}

따라서 SameSite는 **추가 방어 계층(defense in depth)**으로 봐야 하며, CSRF 토큰을 대체할 수 없습니다.

4. CSRF 토큰: 핵심 보호 메커니즘

CSRF 토큰은 이중 제출(double-submit) / 동기화 토큰(synchronizer token) 원칙에 따라 작동합니다: 서버가 임의의 토큰을 생성하고 이를 세션이나 별도의 쿠키에 첨부한 후, 데이터를 변경하는 모든 요청(POST/PUT/DELETE)에서 body나 헤더에 이 토큰을 함께 보내도록 요구합니다. evil.com의 attacker는 다른 도메인의 response/쿠키를 읽을 수 없으므로(same-origin policy), 유효한 요청을 위조할 수 없습니다.

4.1. Spring Security 추가

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

기본적으로 Spring Security 6.x는 GET/HEAD/OPTIONS/TRACE 이외의 모든 요청에 대해 CSRF 보호를 활성화합니다.

4.2. 쿠키 기반 토큰 저장소로 CSRF 설정

SPA(React, Vue)가 AJAX를 통해 API를 호출하는 경우, CookieCsrfTokenRepository를 사용하여 JS가 쿠키에서 토큰을 읽은 후 헤더에 첨부할 수 있습니다:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/payments/**").authenticated()
                .anyRequest().permitAll()
            );
        return http.build();
    }
}

설명:

  • withHttpOnlyFalse(): XSRF-TOKEN 쿠키는 JS(document.cookie)로 읽을 수 있어야 하므로 HttpOnly를 설정할 수 없습니다. 이것은 의도적인 trade-off입니다 — 이 토큰 자체는 세션 쿠키만큼 민감하지 않으며, 이를 잃어도 attacker가 cross-site에서 이를 읽을 수 없다면 공격에 충분하지 않습니다.
  • CsrfTokenRequestAttributeHandler: 토큰 lazy-load를 처리하여 초기 요청에 토큰을 즉시 렌더링할 필요가 없도록 합니다(SPA에 최적화).

클라이언트 측에서 각 요청은 XSRF-TOKEN 쿠키를 읽고 X-XSRF-TOKEN 헤더를 설정해야 합니다:

function getCsrfToken() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
}

fetch('/api/payments/transfer', {
  method: 'POST',
  headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});

evil.com의 attacker는 bank.example.com의 XSRF-TOKEN 쿠키를 읽을 수 없습니다(same-origin policy), 따라서 올바른 헤더를 설정할 수 없습니다. form 자동 제출은 custom 헤더를 설정할 수 없으므로 SameSite=None인 경우에도 요청이 거부됩니다.

4.3. 전통적인 form 전송(server-side render)으로 설정

Thymeleaf를 사용하는 경우, 토큰이 form에 직접 렌더링됩니다:

<form th:action="@{/api/orders}" method="post">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
  <input type="text" name="productName"/>
  <button type="submit">주문 생성</button>
</form>

Spring MVC는 CsrfRequestDataValueProcessor 덕분에 _csrf를 모델에 자동으로 주입하므로 추가 코드가 필요하지 않습니다.

5. SameSite와 CSRF 토큰의 결합: 심층 방어

이 두 계층은 동일한 문제의 다른 측면을 해결하므로 함께 사용해야 합니다:

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );
    return http.build();
}
  • 세션 쿠키의 SameSite=Lax는 대부분의 cross-site 요청을 browser 단계에서 차단하며, 요청이 서버에 도달하기 전에 이를 수행합니다.
  • CSRF 토큰은 SameSite가 보호하지 못하는 경우를 차단합니다(구형 browser, subdomain 손상, 또는 cross-site embedding을 지원하기 위해 의도적으로 SameSite=None을 사용하는 경우).

6. CSRF 보호 검증 테스트 작성

spring-security-test와 함께 MockMvc를 사용하여 토큰이 누락되면 엔드포인트가 차단되는지 확인합니다:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldRejectPostWithoutCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void shouldAcceptPostWithValidCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isOk());
    }
}

HTTP 계층과 무관한 비즈니스 로직에는 AssertJ를 사용하십시오. 예를 들어 각 세션마다 토큰이 무작위로 생성되는지 확인합니다:

@Test
void csrfTokenShouldBeUniquePerSession() {
    CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
    CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
    CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());

    assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
  • 첫 번째 테스트는 토큰이 없는 POST 요청이 403 Forbidden — Spring Security의 기본 동작을 반환하는지 확인합니다.
  • with(csrf())는 spring-security-test의 헬퍼로, 시뮬레이션된 요청에 유효한 토큰을 자동으로 첨부합니다.
  • 세 번째 테스트는 토큰이 세션 간에 재사용/예측되지 않도록 보장합니다.

7. 흔한 실수와 CSRF 토큰이 필요 없을 때

실수 1 — “테스트를 쉽게 하기 위해” 전체 API에서 CSRF 비활성화:

// Anti-pattern: production에서 이렇게 하지 마십시오
http.csrf(csrf -> csrf.disable());

API가 완전히 stateless — JWT를 Authorization 헤더로 인증하고 쿠키를 인증에 사용하지 않는 경우에만 합리적입니다. 이 경우 attacker가 악용할 “ambient credential”이 없습니다.

실수 2 — SameSite=None을 사용하면서 그 결과를 이해하지 못함:

server:
  servlet:
    session:
      cookie:
        same-site: none  # cross-site iframe에 필요하지만 CSRF 위험을 다시 열어줌
        secure: true

반드시 None을 사용해야 하는 경우(예: 제3자의 iframe에 앱을 삽입), 반드시 CSRF 토큰이 필요하며 건너뛸 수 없습니다.

실수 3 — 부작용이 있는 GET 요청에 CSRF 적용:

데이터를 삭제/업데이트하기 위해 GET을 설계하지 마십시오. Spring Security의 CSRF 필터는 기본적으로 GET을 보호하지 않습니다(RFC 7231에 따라 GET은 안전한 방법으로 간주됨). REST 원칙을 위반하면 스스로 취약점을 열어줍니다.

주의: 백엔드가 동일 도메인에서 web app(쿠키 기반)과 mobile/third-party API(토큰 기반)을 모두 제공하는 경우, RequestMatcher에 따라 CSRF 설정을 분리하십시오 — 쿠키를 사용하는 path에만 CSRF를 활성화하고, pure Bearer 토큰을 사용하는 path에는 비활성화합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .ignoringRequestMatchers("/api/mobile/**") // JWT로 인증, 쿠키 없음
    );
    return http.build();
}

8. 결론

CSRF 토큰과 SameSite 쿠키는 동일한 문제의 두 가지 다른 측면을 해결합니다: 다른 사이트의 악의적인 요청과 함께 browser가 자동으로 credential을 보내는 것을 방지합니다. SameSite는 browser 수준의 방어 계층으로 간단하게 활성화할 수 있지만 완벽하지는 않습니다. CSRF 토큰은 애플리케이션 수준의 방어 계층으로 더 신뢰할 수 있지만 서버와 클라이언트 모두에서 추가 코드가 필요합니다. 세션 쿠키를 사용하는 시스템의 경우 두 가지를 함께 사용하십시오; Bearer 토큰으로 인증하는 stateless API의 경우 CSRF 필터는 제거할 수 있지만 쿠키 관련 다른 위험을 방지하기 위해 SameSite를 유지하십시오.

web-securitycsrfspring-securitycookiespring-boot