CSRF
contents
1. CSRF란 무엇인가?
CSRF(사이트 간 요청 위조) 는 악성 웹사이트가 사용자의 브라우저를 속여, 사용자가 현재 로그인되어 있는(인증된) 신뢰할 수 있는 사이트에 원치 않는 작업을 요청하게 만드는 공격입니다.
이것은 흔히 "혼란스러운 대리인(Confused Deputy)" 문제라고 불립니다. 서버(대리인)는 요청에 당신의 쿠키가 포함되어 있기 때문에, 그 요청이 당신이 보낸 것이라고 착각하고 실행해 버립니다. 하지만 실제로는 해커가 당신의 브라우저를 통해 몰래 보낸 것입니다.
2. 핵심 메커니즘: "브라우저는 쿠키를 자동으로 보낸다"
CSRF를 이해하려면 브라우저의 핵심 동작 하나를 반드시 알아야 합니다.
브라우저는 특정 도메인(예: bank.com)으로 요청을 보낼 때, 그 도메인과 관련된 모든 쿠키를 자동으로 첨부합니다.
브라우저는 그 요청이 어디서 시작되었는지(당신이 bank.com의 버튼을 직접 눌렀는지, 아니면 evil.com의 숨겨진 스크립트가 실행되었는지) 따지지 않고 일단 쿠키를 보냅니다.
3. 공격 시나리오
당신이 은행 사이트(bank.com)에 로그인해 있고, 브라우저에 세션 쿠키가 저장되어 있다고 가정해 봅시다.
- 준비: 당신은
bank.com에 로그인 상태입니다. 서버는 요청이 올 때마다 함께 오는Session-Id쿠키를 보고 "아, 철수님이구나"라고 인식합니다. - 함정: 당신이 새 탭에서 악성 사이트(
evil.com)를 방문합니다 (피싱 메일 등을 통해). - 공격:
evil.com에는 다음과 같은 숨겨진 HTML 코드가 심어져 있습니다.
- 실행:
- 브라우저가 스크립트를 실행하여
bank.com으로POST요청을 보냅니다. - 결정적으로, 브라우저는 당신의
bank.com세션 쿠키를 자동으로 함께 보냅니다.
- 브라우저가 스크립트를 실행하여
- 결과: 은행 서버는 유효한 쿠키가 있으므로, 이 송금 요청을 당신이 직접 한 것으로 판단하고 돈을 이체해 버립니다.
4. CSRF와 CORS의 차이
이 둘은 매우 자주 혼동되는 개념입니다.
- CORS는
evil.com이bank.com에서 돌아온 데이터를 읽는(Reading) 것을 막습니다. - CSRF는
evil.com이bank.com으로 상태를 변경하는 요청(POST,PUT,DELETE)을 보내는(Sending) 것을 막습니다.
위의 공격 시나리오에서 해커는 "이체 완료"라는 응답 메시지를 볼 필요가 없습니다. 그저 이체라는 행위가 일어나게만 하면 됩니다. 즉, CORS 설정만으로는 CSRF를 막을 수 없습니다.
5. CSRF 방어 방법
A. 동기화 토큰 패턴 (Synchronizer Token Pattern / CSRF Token)
Spring Security 같은 프레임워크가 사용하는 표준 방어법입니다.
- 서버 생성: 로그인 시, 서버는 임의의 난수 문자열(CSRF 토큰)을 생성합니다.
- 토큰 저장: 이 토큰은 세션에 저장되지만, 자동으로 전송되는 쿠키에는 저장하지 않습니다 (혹은 쿠키에 저장하더라도 브라우저가 자동으로 읽지 못하게 처리합니다).
- 클라이언트 의무: 상태를 변경하는 모든 요청(
POST등)을 보낼 때, 이 토큰을 헤더(X-CSRF-TOKEN)나 폼 필드에 반드시 포함해야 합니다. - 서버 검증: 요청이 오면 서버는 확인합니다. "이 요청에 들어있는 토큰이 내가 발급한 토큰과 일치하는가?"
- 원리: 악성 사이트(
evil.com)는 동일 출처 정책(SOP) 때문에bank.com에 접속해서 CSRF 토큰을 몰래 읽어올 수 없습니다. 쿠키는 자동으로 보내지지만, 숨겨진 토큰값은 알 방법이 없으므로 서버가 요청을 거부하게 됩니다.
B. SameSite 쿠키 속성
최신 브라우저들이 지원하는 기능으로, 토큰 없이도 방어가 가능하게 해줍니다. 쿠키를 설정할 때 SameSite 속성을 지정합니다.
SameSite=Strict: 다른 사이트에서 보내는 요청에는 쿠키를 절대 보내지 않습니다. (가장 안전하지만, 메일 링크 등을 타고 들어갈 때 로그인이 풀려있을 수 있어 불편할 수 있음)SameSite=Lax:<img>로딩이나POST전송 같은 교차 사이트 요청에는 쿠키를 안 보내지만, 링크를 클릭해서 이동(Navigate)하는 경우에는 보냅니다. (최신 브라우저의 기본값)SameSite=None: 예전처럼 항상 보냅니다. (Secure설정 필수)
6. Spring Boot에서의 CSRF (백엔드 관점)
인증 방식에 따라 대처법이 다릅니다.
상황 A: 세션/쿠키를 사용하는 경우 (전통적인 웹)
서버 측 세션(JSESSIONID)을 쓰거나 JWT를 HttpOnly 쿠키에 담아서 주고받는다면, 반드시 CSRF 보안을 켜야 합니다.
Spring Security는 기본적으로 켜져 있습니다. 프론트엔드(React/Vue 등)에서 쿠키에 담겨 온 CSRF 토큰(보통 XSRF-TOKEN)을 읽어서, 요청 헤더(X-XSRF-TOKEN)에 다시 담아 보내도록 코드를 짜야 합니다.
상황 B: 헤더에 JWT를 담아 쓰는 경우 (Stateless API)
만약 프론트엔드가 JWT를 localStorage 등에 저장했다가, 직접 헤더에 Authorization: Bearer <token> 형식으로 넣어서 보낸다면, CSRF 보안을 꺼도(Disable) 됩니다.
- 이유: 브라우저는 교차 사이트 요청 시
Authorization같은 커스텀 헤더를 자동으로 추가해주지 않습니다.evil.com은 당신의localStorage에 접근할 수 없으므로, 해커가 억지로 JWT를 실어 보낼 방법이 없습니다.
그래서 REST API 서버 설정에서 이런 코드를 자주 보게 됩니다:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 세션을 안 쓰고 JWT만 쓰니까
return http.build();
}
MSA 프로젝트 요약:
- 헤더 방식 (Authorization: Bearer ...): CSRF를 안전하게 꺼도 됩니다 (
disable). - 쿠키 방식: CSRF를 켜거나, 쿠키 설정을
SameSite=Strict로 해야 합니다.
1. 백엔드: Spring Security 설정
Spring Security에게 CSRF 토큰을 쿠키에 저장하라고 지시해야 하며, 결정적으로 그 쿠키를 자바스크립트가 읽을 수 있게 허용해야 합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. CSRF 설정
.csrf(csrf -> csrf
// 기본 세션 저장소(SessionRepository) 대신 쿠키 저장소(CookieRepository)를 사용
// .withHttpOnlyFalse()가 핵심입니다:
// 이 설정이 있어야 프론트엔드 JS(React/Vue)가 쿠키를 읽을 수 있습니다.
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// 선택 사항: 첫 번째 요청부터 토큰이 확실히 생성되어 있도록 돕는 핸들러
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
// 2. 표준 세션 관리 (JSESSIONID를 사용하는 경우)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
왜 .withHttpOnlyFalse()가 필요한가요?
- 인증 쿠키 (
JSESSIONID): XSS 공격으로 세션이 탈취되는 것을 막기 위해HttpOnly(자바스크립트 접근 불가)로 설정되어야 합니다. - CSRF 쿠키 (
XSRF-TOKEN): 반드시HttpOnly가 아니어야 합니다. 프론트엔드 자바스크립트가 이 쿠키를 읽어서 헤더에 넣어야 하기 때문입니다. CSRF 토큰은 인증 쿠키 없이는 무용지물이므로, 노출되어도 보안상 안전합니다.
2. 프론트엔드: 자바스크립트 로직
프론트엔드 코드는 요청을 가로채서 XSRF-TOKEN 쿠키를 읽고, 그것을 헤더에 추가해야 합니다.
다음은 순수 자바스크립트(fetch) 예시입니다:
// 쿠키 이름으로 값을 가져오는 헬퍼 함수
function getCookie(name) {
let value = "; " + document.cookie;
let parts = value.split("; " + name + "=");
if (parts.length === 2) return parts.pop().split(";").shift();
}
async function sendPostRequest() {
// 1. Spring Boot가 설정해준 쿠키에서 토큰 값을 읽습니다.
const csrfToken = getCookie("XSRF-TOKEN");
const response = await fetch("http://localhost:8080/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
// 2. Spring Security가 기대하는 표준 헤더 이름에 토큰을 담습니다.
"X-XSRF-TOKEN": csrfToken
},
body: JSON.stringify({ amount: 1000 })
});
if (response.ok) {
console.log("성공!");
}
}
3. 전체 흐름 요약
- 초기 로드: 사용자가 로그인하거나 사이트에 방문하면, Spring Security가 CSRF 토큰을 생성하고 쿠키를 굽습니다:
Set-Cookie: XSRF-TOKEN=abc-123-def; Path=/. - 사용자 동작: 사용자가 "전송" 버튼을 클릭합니다.
- 브라우저:
document.cookie를 읽어XSRF-TOKEN값을 찾습니다.- 요청을 생성합니다.
- 헤더:
X-XSRF-TOKEN: abc-123-def를 수동으로 추가합니다. - 쿠키:
JSESSIONID=xyz-999와XSRF-TOKEN=abc-123-def를 자동으로 첨부합니다.
- 서버:
- Spring Security가 요청을 가로챕니다.
- 헤더(
X-XSRF-TOKEN)에 있는 토큰을 꺼냅니다. - 이 토큰이 해당 세션에 대해 발급된 토큰과 일치하는지 비교합니다.
- 일치하면 통과, 아니면
403 Forbidden에러를 냅니다.
4. Axios (React/Vue) 사용 시 팁
만약 Axios 라이브러리를 사용하신다면, 이 패턴을 위한 기능이 이미 내장되어 있어 쿠키 파싱 코드를 직접 짤 필요가 거의 없습니다.
// Axios는 자동으로 'XSRF-TOKEN'이라는 이름의 쿠키를 찾아서
// 'X-XSRF-TOKEN'이라는 헤더에 값을 넣어줍니다.
axios.defaults.withCredentials = true; // 이 설정은 필수입니다! (쿠키 전송 허용)
axios.defaults.xsrfCookieName = 'XSRF-TOKEN'; // Spring 기본 쿠키 이름
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN'; // Spring 기본 헤더 이름
// 이제 평소처럼 요청을 보내면 됩니다.
axios.post('/api/transfer', { amount: 1000 });
references