Web Security

CSRF(일부XSS포함) 방지책 (ReactJS + Flask)

꼰대코더 2025. 12. 2. 16:01

🛡️ Flask API 서버: HttpOnly와 CSRF 토큰으로 XSS/CSRF 동시에 방어하기

React와 같은 프론트엔드와 연동되는 Flask API 서버에서 가장 흔한 두 가지 웹 보안 위협, XSS(Cross-Site Scripting)와 CSRF(Cross-Site Request Forgery)를 어떻게 효과적으로 방어하는지 코드와 함께 설명합니다.

우리가 살펴본 Flask 코드는 'Double Submit Cookie' 또는 'Synchronizer Token Pattern'을 기반으로 하며, 강력한 쿠키 옵션을 활용하여 보안을 구축합니다.

1. 🔑 XSS 방어의 핵심: HttpOnly 쿠키

XSS 공격의 주요 목적은 사용자 브라우저에 삽입된 악성 JavaScript를 통해 세션 쿠키를 탈취하는 것입니다. Flask는 httponly=True 옵션으로 이를 원천 차단합니다.

코드 속성 설명 방어 원리
httponly=True 세션 ID가 담긴 쿠키를 설정할 때 사용됩니다. 이 옵션이 설정되면, JavaScript 코드(XSS 스크립트 포함)는 쿠키 값에 접근하거나 읽을 수 없습니다. 악성 스크립트가 실행되어도 세션 ID를 탈취할 수 없어 XSS 공격의 가장 치명적인 피해를 막습니다.
secure=True 쿠키는 HTTPS 연결에서만 전송됩니다. 중간자 공격(MITM)을 통해 쿠키가 네트워크 상에서 노출되는 것을 방지합니다. 배포 환경에서는 필수입니다.

2. 🛡️ CSRF 방어의 핵심: CSRF 토큰 검증

CSRF 공격은 사용자가 로그인된 상태에서 외부 악성 사이트가 사용자의 권한을 이용해 서버에 요청을 보내는 것을 목표로 합니다. Flask 코드에서는 데코레이터두 가지 쿠키를 사용하여 이를 방어합니다.

A. 두 종류의 토큰 발행 (/api/login 엔드포인트)

로그인 성공 시, 서버는 두 가지 쿠키를 발행합니다.

쿠키 이름 session_id XSRF-TOKEN
HttpOnly? ✅ 예 (httponly=True) ❌ 아니오
목적 서버에서 사용자를 식별 클라이언트(React)가 읽어 요청 헤더에 담을 토큰
방어 대상 XSS (세션 탈취) CSRF (요청 위조)

B. csrf_protect 데코레이터 검증 (/api/logout 및 POST/PUT 엔드포인트)

상태를 변경하는 모든 API 요청은 @csrf_protect 데코레이터를 거쳐야 합니다.

검증 단계 설명
1. 서버 저장 토큰 확인 서버는 현재 세션 ID(session_id)에 연결된 진짜 CSRF 토큰을 메모리(또는 DB)에서 가져옵니다.
2. 클라이언트 전송 토큰 확인 클라이언트(React)가 HTTP 헤더(X-CSRF-TOKEN)에 담아 보낸 토큰을 받습니다. (클라이언트는 XSRF-TOKEN 쿠키 값을 읽어 헤더에 넣어 보냄)
3. 토큰 일치 확인 헤더 토큰서버 저장 토큰이 정확히 일치하는지 확인합니다. 일치하지 않으면 403 Forbidden 에러로 응답하여 위조된 요청을 차단합니다.

C. 보조 방어막: SameSite 속성

samesite='Lax' 옵션은 상태 변경 요청(POST 등)에 대해 크로스 사이트 환경에서 쿠키가 전송되는 것을 제한하여 CSRF 방어의 보조적인 역할을 수행합니다.

3. 🚨 최종 결론: 안전한 API 서버 구축

이 Flask 코드는 세션 관리의 보안 모범 사례를 따르고 있습니다. XSS 방어를 위해 세션 ID는 JavaScript가 접근할 수 없도록 격리하고, CSRF 방어를 위해 요청마다 일회성 토큰을 검증함으로써 안전하고 견고한 API 서버 환경을 구축합니다.

💡 다음 단계: 이 Flask 서버와 연동하기 위해 React 클라이언트에서 XSRF-TOKEN 쿠키 값을 읽어 X-CSRF-TOKEN 헤더에 자동으로 추가하는 axios 인터셉터 구현이 필요합니다.

 
from flask import Flask, request, jsonify, make_response
from flask_cors import CORS
import secrets
from functools import wraps
import os

# =========================================================
# 💡 [핵심 보안] HttpOnly + SameSite + CSRF 토큰 패턴을 사용한 방어
# =========================================================

# 실제 환경에서는 데이터베이스를 사용해야 하지만, 여기서는 간단한 딕셔너리로 대체합니다.
# 세션 구조: {session_id: {'user_id': str, 'csrf_token': str}}
ACTIVE_SESSIONS = {}
USER_DB = {"user1": "pass123"}

app = Flask(__name__)

# ⭐ 1. 상수 정의
FRONTEND_URL = "http://localhost:3000"
SESSION_COOKIE_NAME = 'session_id'
CSRF_COOKIE_NAME = 'XSRF-TOKEN' # 클라이언트(React)가 읽을 토큰 쿠키 이름
CSRF_HEADER_NAME = 'X-CSRF-TOKEN' # 클라이언트가 토큰을 담아 보낼 헤더 이름

# CORS 설정: 프론트엔드 도메인 지정 및 쿠키 전송 허용
CORS(app, resources={r"/*": {"origins": FRONTEND_URL, "supports_credentials": True}})

def generate_safe_token():
    """암호학적으로 안전한 무작위 토큰을 생성합니다."""
    return secrets.token_urlsafe(32)

# ---------------------------------------------------------
# ⭐ 2. CSRF 보호 데코레이터
# ---------------------------------------------------------

def csrf_protect(f):
    """CSRF 토큰 검증 로직을 수행하는 데코레이터"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        session_id = request.cookies.get(SESSION_COOKIE_NAME)
        csrf_header_token = request.headers.get(CSRF_HEADER_NAME)

        # 1. 세션 존재 및 토큰 유효성 검사
        if not session_id or session_id not in ACTIVE_SESSIONS:
            return jsonify({"message": "인증되지 않았습니다 (세션 없음)."}), 401

        session_data = ACTIVE_SESSIONS[session_id]
        expected_csrf_token = session_data.get('csrf_token')

        # 2. 헤더 토큰과 서버 저장 토큰 비교
        if not csrf_header_token or csrf_header_token != expected_csrf_token:
            return jsonify({"message": "CSRF 토큰 검증 실패: 유효하지 않거나 누락됨."}), 403

        # 검증 성공 시 함수 실행
        return f(*args, **kwargs)
    return decorated_function

## 🔑 로그인 및 세션/토큰 생성 엔드포인트
@app.route('/api/login', methods=['POST'])
def login():
    data = request.json
    username = data.get('username')
    password = data.get('password')

    if USER_DB.get(username) != password:
        return jsonify({"message": "로그인 실패"}), 401

    # 세션 ID 및 CSRF 토큰 생성
    new_session_id = generate_safe_token()
    new_csrf_token = generate_safe_token()
   
    # 3. 세션 정보 저장 (세션 ID와 CSRF 토큰을 연결)
    ACTIVE_SESSIONS[new_session_id] = {
        'user_id': username,
        'csrf_token': new_csrf_token
    }

    response = make_response(jsonify({"message": "로그인 성공!", "username": username}), 200)

    # 4. HttpOnly 세션 쿠키 설정 (XSS 방어)
    response.set_cookie(
        SESSION_COOKIE_NAME,
        new_session_id,
        max_age=60 * 60 * 24 * 7,
        secure=True,
        httponly=True,           # JS 접근 차단 (XSS 방어)
        samesite='Lax'           # CSRF 방어 보조 수단
    )
   
    # 5. 일반 CSRF 토큰 쿠키 설정 (JS가 읽어야 하므로 HttpOnly=False)
    response.set_cookie(
        CSRF_COOKIE_NAME,
        new_csrf_token,
        max_age=60 * 60 * 24 * 7,
        secure=True,
        # httponly=False (기본값) : JS가 읽도록 허용
        samesite='Lax'
    )

    return response

## 🔒 보호된 라우트 (GET 요청은 CSRF 방어 필요 없음)
@app.route('/api/profile', methods=['GET'])
def profile():
    session_id = request.cookies.get(SESSION_COOKIE_NAME)

    if not session_id or session_id not in ACTIVE_SESSIONS:
        return jsonify({"message": "인증되지 않은 사용자입니다."}), 401

    user_id = ACTIVE_SESSIONS[session_id]['user_id']
   
    return jsonify({
        "message": "환영합니다!",
        "user_id": user_id,
        "data": "보안이 필요한 사용자 프로필 정보"
    })

## 🚪 로그아웃 (상태 변경 요청이므로 CSRF 보호 적용)
@app.route('/api/logout', methods=['POST'])
@csrf_protect # ⭐ CSRF 토큰 검증 데코레이터 적용
def logout():
    session_id = request.cookies.get(SESSION_COOKIE_NAME)
   
    # 세션 데이터베이스에서 세션 ID 무효화/삭제
    if session_id in ACTIVE_SESSIONS:
        del ACTIVE_SESSIONS[session_id]

    # 쿠키 삭제 (세션 쿠키와 CSRF 토큰 쿠키 모두 삭제)
    response = make_response(jsonify({"message": "로그아웃 완료"}), 200)
   
    # 세션 쿠키 삭제 (HttpOnly)
    response.set_cookie(SESSION_COOKIE_NAME, '', max_age=0, secure=True, httponly=True, samesite='Lax')
   
    # CSRF 토큰 쿠키 삭제 (Non-HttpOnly)
    response.set_cookie(CSRF_COOKIE_NAME, '', max_age=0, secure=True, samesite='Lax')
   
    return response

# ---------------------------------------------------------
# 💡 참고: POST/PUT/DELETE 등 모든 상태 변경 라우트에 @csrf_protect를 적용해야 합니다.
# 예: @app.route('/api/update', methods=['POST'])
# ---------------------------------------------------------

if __name__ == '__main__':
    app.run(port=5000)
 

 

💻 React 클라이언트 연동: CSRF 토큰을 자동으로 처리하여 보안 완성하기

위에서 Flask 백엔드를 HttpOnly 쿠키와 CSRF 토큰 검증 시스템으로 안전하게 구축했습니다. 이제 이 서버와 통신하는 React 클라이언트에서 보안 정책을 어떻게 준수하고 CSRF 토큰을 처리하는지 자세히 알아보겠습니다.

React 코드의 핵심은 axios 라이브러리의 인터셉터를 사용하여 CSRF 토큰을 자동으로 읽어 HTTP 헤더에 담아 전송하는 데 있습니다.

1. ⚙️ React 코드 개요

이 React 컴포넌트는 로그인 시 서버로부터 session_id (HttpOnly)와 XSRF-TOKEN (일반 쿠키)을 받고, 상태 변경 요청(로그아웃 등) 시 CSRF 토큰을 헤더에 첨부하는 역할을 합니다.

2. 🔑 핵심 포인트: React 클라이언트의 보안 연동

클라이언트 코드에서 가장 중요한 역할은 Flask 서버의 CSRF 방어 시스템을 무너뜨리지 않고 정상적으로 요청을 완료하는 것입니다.

A. withCredentials: true 설정

코드 withCredentials: true
목적 크로스 도메인 요청 시 쿠키 전송 허용
원리 React 앱(예: localhost:3000)이 Flask 서버(예: localhost:5000)로 API 요청을 보낼 때, 브라우저가 **인증 정보(쿠키)**를 요청에 자동으로 첨부하도록 지시합니다. 이 설정이 없으면 Flask 서버가 쿠키를 받지 못해 인증이 실패합니다.

B. session_id와 XSRF-TOKEN 처리 방식

React 클라이언트는 서버로부터 받은 두 종류의 쿠키를 다르게 처리합니다.

쿠키 이름 특징 React의 처리
session_id HttpOnly 접근 불가. XSS 방어를 위해 JavaScript가 읽을 수 없으며, 브라우저가 서버에 자동으로 전송만 해줍니다.
XSRF-TOKEN Non-HttpOnly 접근 가능. CSRF 방어를 위해 JavaScript가 쿠키 값을 읽어 요청 헤더에 수동으로 첨부합니다.

C. ⭐ 핵심 로직: Axios 요청 인터셉터

클라이언트 코드의 심장부입니다. 상태를 변경하는 모든 요청에 대해 CSRF 토큰을 자동으로 헤더에 넣어 서버가 검증할 수 있도록 합니다.

  1. 쿠키 읽기: getCookie('XSRF-TOKEN') 함수를 사용하여 일반 쿠키인 XSRF-TOKEN 값을 읽어옵니다.
  2. 메서드 필터링: POST, PUT, DELETE, PATCH 등 상태를 변경하는 요청에 대해서만 토큰 첨부 로직을 실행합니다. (GET 요청은 CSRF 공격 대상이 아니므로 제외)
  3. 헤더 설정: 읽어온 토큰 값을 Flask 서버가 기대하는 헤더 이름인 **X-CSRF-TOKEN**에 넣어 요청을 전송합니다.

CSRF 방어 성공: 외부 악성 사이트가 보낸 요청은 XSRF-TOKEN 쿠키를 읽을 수 없기 때문에 헤더에 올바른 X-CSRF-TOKEN 값을 담지 못하며, Flask 서버는 이를 감지하고 403 에러로 차단합니다.

이로써 프론트엔드와 백엔드가 모두 보안 모범 사례를 따르는 완벽한 인증 시스템이 완성됩니다.

import React, { useState, useEffect } from 'react';
import axios from 'axios';

// =========================================================
// 💡 클라이언트 측 CSRF 토큰 처리 로직 (HttpOnly 쿠키는 접근 불가)
// =========================================================

// CSRF 토큰 관련 상수 (Flask 서버 설정과 일치해야 함)
const CSRF_COOKIE_NAME = 'XSRF-TOKEN';
const CSRF_HEADER_NAME = 'X-CSRF-TOKEN';
const FLASK_API_URL = 'http://localhost:5000/api'; // Flask 서버 주소

// ---------------------------------------------------------
// 1. 쿠키에서 특정 값을 읽어오는 헬퍼 함수
// ---------------------------------------------------------
const getCookie = (name) => {
  if (typeof document === 'undefined') return null; // 서버 환경 체크
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
  return null;
};

// ---------------------------------------------------------
// 2. Axios 인스턴스 생성 및 인터셉터 설정
// ---------------------------------------------------------

const api = axios.create({
  baseURL: FLASK_API_URL,
  withCredentials: true, // ⭐ 쿠키를 포함하여 요청하도록 브라우저에 지시 (CORS 설정 필수)
});

// ⭐ 요청 인터셉터: 모든 요청이 서버로 가기 전에 CSRF 토큰을 헤더에 추가
api.interceptors.request.use(config => {
  // POST, PUT, DELETE, PATCH 등 상태를 변경하는 메서드에만 적용
  const isMutatingMethod = ['post', 'put', 'delete', 'patch'].includes(config.method);
 
  if (isMutatingMethod) {
    const csrfToken = getCookie(CSRF_COOKIE_NAME);
   
    if (csrfToken) {
      // 💡 Flask 서버가 요구하는 헤더 이름에 토큰 값을 설정
      config.headers[CSRF_HEADER_NAME] = csrfToken;
    } else {
      // 토큰이 없는 경우 (예: 세션 만료, 로그인 전) 경고
      console.warn('CSRF 토큰을 찾을 수 없습니다. 로그인 상태를 확인하세요.');
    }
  }

  return config;
}, error => {
  return Promise.reject(error);
});

// ---------------------------------------------------------
// 3. React 컴포넌트
// ---------------------------------------------------------

const App = () => {
  const [username, setUsername] = useState('user1');
  const [password, setPassword] = useState('pass123');
  const [message, setMessage] = useState('로그인 정보(user1/pass123)를 입력하세요.');

  // Tailwind CSS를 사용한 기본 스타일
  const buttonClass = "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition duration-150 shadow-md";
  const inputClass = "p-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500";

  const handleLogin = async (e) => {
    e.preventDefault();
    setMessage('로그인 시도 중...');
   
    try {
      const response = await api.post('/login', { username, password });
     
      // 로그인 성공 후 브라우저가 HttpOnly 세션 쿠키와 XSRF-TOKEN 쿠키를 저장합니다.
      setMessage(`로그인 성공! ${response.data.username}님 환영합니다. 이제 쿠키가 설정되었습니다.`);
     
    } catch (error) {
      setMessage(`로그인 실패: ${error.response?.data?.message || '서버 오류'}`);
    }
  };
 
  const fetchProfile = async () => {
    setMessage('프로필 정보 요청 중...');
    try {
      // GET 요청은 CSRF 토큰을 보내지 않아도 됩니다. (브라우저가 session_id 쿠키만 자동으로 첨부)
      const response = await api.get('/profile');
      setMessage(`프로필 로드 성공: ${response.data.data}`);
    } catch (error) {
      setMessage(`프로필 로드 실패: ${error.response?.data?.message || '인증 실패'}`);
    }
  };

  const handleLogout = async () => {
    setMessage('로그아웃 요청 중...');
    try {
      // ⭐ POST 요청이므로 인터셉터가 X-CSRF-TOKEN 헤더를 자동으로 첨부합니다.
      // Flask 서버는 이 헤더의 토큰을 검증합니다.
      await api.post('/logout');
      setMessage('로그아웃 완료. 모든 쿠키가 삭제되었습니다.');
    } catch (error) {
      // CSRF 토큰이 없거나 유효하지 않으면 여기서 403 Forbidden 에러가 발생합니다.
      setMessage(`로그아웃 실패: ${error.response?.data?.message || 'CSRF 검증 오류'}`);
    }
  };

  return (
    <div className="p-8 max-w-lg mx-auto bg-white shadow-xl rounded-xl mt-10 font-sans">
      <h2 className="text-3xl font-bold mb-6 text-gray-800 border-b pb-2">
        React 보안 세션 클라이언트
      </h2>
      <p className="text-sm text-gray-600 mb-6">
        클라이언트는 HttpOnly 세션(XSS 방어) CSRF 토큰(CSRF 방어) 모두 처리합니다.
      </p>

      {/* 로그인 폼 */}
      <form onSubmit={handleLogin} className="flex flex-col space-y-4 mb-8">
        <input
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          placeholder="사용자 이름 (user1)"
          className={inputClass}
        />
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="비밀번호 (pass123)"
          className={inputClass}
        />
        <button type="submit" className={buttonClass}>
          로그인 (세션 토큰 생성)
        </button>
      </form>

      {/* 동작 버튼 */}
      <div className="flex flex-col space-y-3">
        <button onClick={fetchProfile} className={`${buttonClass} bg-green-500 hover:bg-green-600`}>
          GET: 프로필 정보 보기 (인증 확인)
        </button>
        <button onClick={handleLogout} className={`${buttonClass} bg-red-500 hover:bg-red-600`}>
          POST: 로그아웃 요청 (CSRF 토큰 자동 첨부)
        </button>
      </div>

      {/* 상태 메시지 */}
      <div className="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
        <h3 className="font-semibold text-lg mb-2 text-gray-700">현재 상태</h3>
        <p className="text-gray-900 break-words">{message}</p>
        <p className="text-xs text-gray-500 mt-2">
            **주의:** Flask 서버(http://localhost:5000) 먼저 실행해야 합니다.
        </p>
      </div>
    </div>
  );
};

export default App;

 

'Web Security' 카테고리의 다른 글

XSS 방지책 (Express.js)  (0) 2025.12.02
XSS 방지책 (Flask)  (0) 2025.12.02
XSS 방지책 (ReactJS)  (0) 2025.12.02
CSRF (Cross-Site Request Forgery) attack  (0) 2025.12.02
크로스 사이트 스크립팅 (Cross-Site Scripting, XSS)  (0) 2025.11.30