🔒 보안

CSRF

Cross-Site Request Forgery

사이트 간 요청 위조 공격. 사용자의 인증된 세션을 악용하여 의도하지 않은 요청을 서버에 전송하는 웹 보안 취약점입니다.

📖 상세 설명

CSRF(Cross-Site Request Forgery)는 사용자가 인증된 상태에서 악의적 요청을 의도치 않게 실행하게 만드는 웹 공격입니다. 공격자는 피해자가 로그인한 사이트에 대한 요청을 악성 페이지에 숨겨놓고, 피해자가 해당 페이지를 방문하면 브라우저가 자동으로 쿠키를 포함해 요청을 전송합니다. 은행 송금, 비밀번호 변경, 이메일 주소 수정 같은 중요한 작업이 피해자 모르게 실행될 수 있습니다.

CSRF 공격이 성공하려면 세 가지 조건이 필요합니다. 첫째, 피해자가 대상 사이트에 로그인되어 있어야 합니다. 둘째, 세션 쿠키가 요청에 자동으로 포함되어야 합니다. 셋째, 대상 액션의 파라미터가 예측 가능해야 합니다. 예를 들어 비밀번호 변경 시 현재 비밀번호를 확인하지 않으면 CSRF에 취약합니다. XSS가 "신뢰된 사이트에서 악성 코드 실행"이라면, CSRF는 "악성 사이트에서 신뢰된 요청 전송"입니다.

CSRF 방어의 핵심은 CSRF 토큰(Anti-CSRF Token)입니다. 서버가 세션별로 고유한 토큰을 생성하고, 모든 상태 변경 요청에 이 토큰을 포함하도록 요구합니다. 공격자는 피해자의 토큰 값을 알 수 없으므로 유효한 요청을 만들 수 없습니다. Django, Rails, Spring Security 같은 주요 프레임워크는 CSRF 토큰을 내장 지원합니다. SPA(Single Page Application)에서는 토큰을 HTTP 헤더로 전송하는 방식을 사용합니다.

현대 브라우저의 SameSite 쿠키 속성은 CSRF 방어의 새로운 표준입니다. SameSite=Strict은 크로스 사이트 요청에 쿠키를 전혀 보내지 않고, SameSite=Lax는 안전한 메서드(GET)의 탑레벨 네비게이션에만 쿠키를 전송합니다. Chrome 80부터 SameSite=Lax가 기본값이 되면서 많은 CSRF 공격이 자동 차단됩니다. 하지만 SameSite만으로는 완전하지 않으므로 CSRF 토큰과 함께 사용해야 합니다.

💻 코드 예제

// CSRF 방어 - Node.js Express 예제
const express = require('express');
const csrf = require('csurf');
const cookieParser = require('cookie-parser');

const app = express();

// 쿠키 파서 설정
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));

// CSRF 미들웨어 설정
const csrfProtection = csrf({
    cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict'  // SameSite 쿠키 설정
    }
});

// CSRF 토큰을 쿠키로 전달 (SPA용)
app.get('/api/csrf-token', csrfProtection, (req, res) => {
    res.json({ csrfToken: req.csrfToken() });
});

// 로그인 폼 (서버 사이드 렌더링)
app.get('/login', csrfProtection, (req, res) => {
    res.send(`
        
`); }); // 로그인 처리 - CSRF 토큰 검증 자동 수행 app.post('/login', csrfProtection, (req, res) => { // CSRF 토큰이 유효하지 않으면 403 에러 발생 const { email, password } = req.body; // 인증 로직... res.send('로그인 성공'); }); // 비밀번호 변경 - 민감한 작업에 CSRF 필수 app.post('/api/change-password', csrfProtection, (req, res) => { const { currentPassword, newPassword } = req.body; // 현재 비밀번호 확인 (CSRF와 별개로 추가 보안) if (!verifyCurrentPassword(req.user, currentPassword)) { return res.status(401).json({ error: '현재 비밀번호가 일치하지 않습니다' }); } // 비밀번호 변경 처리... res.json({ message: '비밀번호가 변경되었습니다' }); }); // CSRF 에러 핸들러 app.use((err, req, res, next) => { if (err.code === 'EBADCSRFTOKEN') { res.status(403).json({ error: 'CSRF 토큰이 유효하지 않습니다', message: '세션이 만료되었거나 요청이 위조되었습니다' }); } else { next(err); } }); // 쿠키 설정 예시 - SameSite 포함 app.post('/api/session', (req, res) => { res.cookie('sessionId', 'abc123', { httpOnly: true, secure: true, sameSite: 'lax', // 크로스 사이트 요청 제한 maxAge: 24 * 60 * 60 * 1000 // 24시간 }); res.json({ success: true }); });
# CSRF 방어 - Django 예제
# Django는 CSRF 보호가 기본 활성화

# settings.py
CSRF_COOKIE_SECURE = True  # HTTPS에서만 쿠키 전송
CSRF_COOKIE_HTTPONLY = True  # JavaScript에서 쿠키 접근 불가
CSRF_COOKIE_SAMESITE = 'Lax'  # SameSite 설정
CSRF_TRUSTED_ORIGINS = ['https://example.com']  # 신뢰 도메인

# views.py
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from django.middleware.csrf import get_token
from django.http import JsonResponse

# 템플릿에서 CSRF 토큰 사용
# {% csrf_token %} - 폼에 hidden 필드로 토큰 삽입

@ensure_csrf_cookie  # 쿠키에 CSRF 토큰 설정
def get_csrf_token(request):
    """SPA를 위한 CSRF 토큰 엔드포인트"""
    return JsonResponse({'csrfToken': get_token(request)})

@csrf_protect  # CSRF 검증 강제
def change_password(request):
    """비밀번호 변경 - CSRF 보호 필수"""
    if request.method == 'POST':
        current_password = request.POST.get('current_password')
        new_password = request.POST.get('new_password')

        # 현재 비밀번호 확인 (추가 보안)
        if not request.user.check_password(current_password):
            return JsonResponse({'error': '현재 비밀번호 불일치'}, status=401)

        request.user.set_password(new_password)
        request.user.save()
        return JsonResponse({'message': '비밀번호 변경됨'})

# AJAX 요청에서 CSRF 토큰 전송 (JavaScript)
"""
// 쿠키에서 CSRF 토큰 읽기
function getCookie(name) {
    let cookieValue = null;
    if (document.cookie && document.cookie !== '') {
        const cookies = document.cookie.split(';');
        for (let i = 0; i < cookies.length; i++) {
            const cookie = cookies[i].trim();
            if (cookie.substring(0, name.length + 1) === (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}

// fetch 요청에 CSRF 토큰 포함
fetch('/api/change-password', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': getCookie('csrftoken')  // 헤더로 전송
    },
    credentials: 'include',  // 쿠키 포함
    body: JSON.stringify({ new_password: 'newPass123' })
});
"""

# REST Framework에서 CSRF
from rest_framework.decorators import api_view
from rest_framework.response import Response

@api_view(['POST'])
def api_transfer_money(request):
    """
    DRF는 SessionAuthentication 사용 시 CSRF 검증 수행
    TokenAuthentication 사용 시 CSRF 불필요 (토큰 자체가 인증)
    """
    amount = request.data.get('amount')
    to_account = request.data.get('to_account')
    # 송금 처리...
    return Response({'success': True})
// CSRF 방어 - React SPA 예제
import axios from 'axios';
import { useEffect, useState } from 'react';

// Axios 인스턴스 설정 - CSRF 토큰 자동 포함
const api = axios.create({
    baseURL: '/api',
    withCredentials: true,  // 쿠키 포함
});

// 응답에서 CSRF 토큰 쿠키 추출
function getCsrfToken() {
    const name = 'csrftoken';
    const cookies = document.cookie.split(';');
    for (let cookie of cookies) {
        const [cookieName, cookieValue] = cookie.trim().split('=');
        if (cookieName === name) {
            return decodeURIComponent(cookieValue);
        }
    }
    return null;
}

// 요청 인터셉터 - 모든 요청에 CSRF 토큰 추가
api.interceptors.request.use(config => {
    const csrfToken = getCsrfToken();
    if (csrfToken) {
        config.headers['X-CSRFToken'] = csrfToken;
    }
    return config;
});

// CSRF 토큰 초기화 훅
function useCsrfToken() {
    const [isReady, setIsReady] = useState(false);

    useEffect(() => {
        // 앱 시작 시 CSRF 토큰 쿠키 획득
        api.get('/csrf-token')
            .then(() => setIsReady(true))
            .catch(err => console.error('CSRF 토큰 초기화 실패:', err));
    }, []);

    return isReady;
}

// 비밀번호 변경 폼 컴포넌트
function ChangePasswordForm() {
    const csrfReady = useCsrfToken();
    const [formData, setFormData] = useState({
        currentPassword: '',
        newPassword: '',
        confirmPassword: ''
    });
    const [error, setError] = useState('');
    const [success, setSuccess] = useState(false);

    const handleSubmit = async (e) => {
        e.preventDefault();
        setError('');

        if (formData.newPassword !== formData.confirmPassword) {
            setError('새 비밀번호가 일치하지 않습니다');
            return;
        }

        try {
            // CSRF 토큰이 헤더에 자동 포함됨
            await api.post('/change-password', {
                current_password: formData.currentPassword,
                new_password: formData.newPassword
            });
            setSuccess(true);
        } catch (err) {
            if (err.response?.status === 403) {
                setError('세션이 만료되었습니다. 다시 로그인해주세요.');
            } else {
                setError(err.response?.data?.error || '오류가 발생했습니다');
            }
        }
    };

    if (!csrfReady) {
        return 
로딩 중...
; } return (
{error &&
{error}
} {success &&
비밀번호가 변경되었습니다
} setFormData({...formData, currentPassword: e.target.value})} required /> setFormData({...formData, newPassword: e.target.value})} required /> setFormData({...formData, confirmPassword: e.target.value})} required />
); }

🗣️ 실무에서 이렇게 말하세요

💬 보안 감사 피드백에서
"비밀번호 변경 API에 CSRF 보호가 없네요. 공격자가 악성 페이지에서 피해자의 비밀번호를 변경할 수 있습니다. Django라면 @csrf_protect 데코레이터 추가하고, 프론트에서 X-CSRFToken 헤더 전송하도록 수정해주세요."
💬 SPA 아키텍처 설계 시
"React 앱에서 세션 쿠키 인증 쓰면 CSRF 토큰 필수입니다. 앱 초기화 시점에 /api/csrf-token 호출해서 쿠키 받고, 모든 POST/PUT/DELETE 요청에 X-CSRFToken 헤더 넣으세요. JWT 토큰 인증이면 CSRF는 불필요합니다."
💬 쿠키 설정 논의에서
"세션 쿠키에 SameSite=Lax 설정하세요. Chrome 80 이후 기본값이긴 한데 명시적으로 설정하는 게 좋습니다. 결제처럼 민감한 작업은 SameSite=Strict와 CSRF 토큰을 함께 사용하세요."

⚠️ 주의사항 & 베스트 프랙티스

GET 요청으로 상태 변경

GET 요청은 CSRF 토큰 검증을 건너뛰는 경우가 많습니다. 계정 삭제, 송금, 설정 변경 같은 상태 변경은 반드시 POST/PUT/DELETE를 사용하고 CSRF 토큰을 검증하세요.

CSRF 토큰을 URL에 노출

CSRF 토큰을 쿼리 파라미터로 전송하면 Referer 헤더나 브라우저 히스토리에 노출됩니다. 항상 POST 바디나 커스텀 헤더(X-CSRFToken)로 전송하세요.

SameSite만 믿고 CSRF 토큰 생략

SameSite 쿠키는 구형 브라우저에서 지원되지 않고, 서브도메인 공격에 취약할 수 있습니다. 방어 심층화를 위해 CSRF 토큰과 함께 사용하세요.

CSRF 방어 베스트 프랙티스

프레임워크의 내장 CSRF 보호를 활용하고, 민감한 작업에는 재인증(현재 비밀번호 확인)을 요구하세요. Origin/Referer 헤더 검증을 추가하고, 세션 쿠키에 SameSite=Lax 이상을 설정하세요.

🔗 관련 용어

📚 더 배우기