CSRF 防禦完整指南

多層防禦策略與 Django 最佳實踐

⚠️ 免責聲明 本文內容僅供教育與學習用途。請勿將文中技術用於任何未經授權的系統或惡意目的。


📚 本篇重點

  • 🎯 掌握 CSRF Token 的完整實作
  • 🔒 理解 SameSite Cookie 的配置
  • 🛡️ 學習多層防禦策略
  • 💻 Django 實戰最佳實踐

閱讀時間: 約 20 分鐘 難度: ⭐⭐⭐ 中高階


1️⃣ 防禦層級架構

┌─────────────────────────────────────────────┐
│  Layer 1: 使用正確的 HTTP 方法                │
│  - GET: 只用於讀取                           │
│  - POST/PUT/DELETE: 用於狀態改變             │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  Layer 2: CSRF Token ⭐ 核心防禦              │
│  - Synchronizer Token Pattern              │
│  - Double Submit Cookie                    │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  Layer 3: SameSite Cookie                   │
│  - Lax 或 Strict                            │
│  - 阻止跨站 Cookie 發送                      │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  Layer 4: 驗證 Origin/Referer Header        │
│  - 檢查請求來源                              │
│  - 白名單驗證                                │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  Layer 5: 額外驗證(敏感操作)                  │
│  - 重新輸入密碼                              │
│  - OTP/2FA                                  │
│  - CAPTCHA                                  │
└─────────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────────┐
│  Layer 6: 監控與告警                         │
│  - 記錄失敗的 CSRF 驗證                      │
│  - 異常檢測                                  │
└─────────────────────────────────────────────┘

2️⃣ Layer 1: 使用正確的 HTTP 方法

原則:GET 只用於讀取

# ❌ 錯誤:使用 GET 執行狀態改變
@app.route('/delete-account')
def delete_account():
    user_id = request.args.get('user_id')
    delete_user(user_id)  # 危險!
    return "帳號已刪除"

# URL: http://example.com/delete-account?user_id=123
# 攻擊者可以用 <img src="..."> 觸發!

# ✅ 正確:使用 POST/DELETE
@app.route('/delete-account', methods=['POST'])
def delete_account():
    user_id = request.form.get('user_id')
    delete_user(user_id)
    return "帳號已刪除"

Django 範例

# views.py
from django.views.decorators.http import require_http_methods
from django.http import HttpResponse, HttpResponseNotAllowed

# ❌ 錯誤:允許 GET
def transfer_money(request):
    if request.method == 'GET':
        amount = request.GET.get('amount')
        to = request.GET.get('to')
        # 執行轉帳 - 危險!

# ✅ 正確:只允許 POST
@require_http_methods(["POST"])
def transfer_money(request):
    amount = request.POST.get('amount')
    to = request.POST.get('to')
    # 執行轉帳
    return HttpResponse("轉帳成功")

# ✅ 更好:使用 Class-Based View
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect

class TransferView(View):
    @method_decorator(csrf_protect)
    def post(self, request):
        amount = request.POST.get('amount')
        to = request.POST.get('to')
        # 執行轉帳
        return HttpResponse("轉帳成功")

    def get(self, request):
        # GET 只用於顯示表單
        return render(request, 'transfer_form.html')

3️⃣ Layer 2: CSRF Token 實作

方法 1: Synchronizer Token Pattern (Django 預設)

原理:

1. 伺服器生成隨機 Token
2. Token 同時儲存在:
   - Session (伺服器端)
   - Cookie (客戶端)
3. 表單中包含 Token
4. 提交時驗證:Session Token == 表單 Token

Django 完整配置:

# settings.py

# 1. 啟用 CSRF 中間件(預設已啟用)
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',  # ✅ CSRF 中間件
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# 2. CSRF Cookie 設定
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_AGE = 31449600  # 1 年
CSRF_COOKIE_DOMAIN = None
CSRF_COOKIE_PATH = '/'
CSRF_COOKIE_SECURE = True  # 生產環境必須 True (HTTPS)
CSRF_COOKIE_HTTPONLY = False  # 必須 False,讓 JavaScript 能讀取
CSRF_COOKIE_SAMESITE = 'Lax'  # 或 'Strict'

# 3. CSRF 失敗處理
CSRF_FAILURE_VIEW = 'myapp.views.csrf_failure'

# 4. 受信任的來源(可選)
CSRF_TRUSTED_ORIGINS = [
    'https://example.com',
    'https://www.example.com',
]

# 5. CSRF Header 名稱(用於 AJAX)
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'

Template 使用

<!-- 方法 1:在 <form> 中使用 {% csrf_token %} (最常用) -->
<form method="post" action="/transfer/">
    {% csrf_token %}
    <input type="number" name="amount" placeholder="金額">
    <input type="text" name="to" placeholder="收款人">
    <button type="submit">轉帳</button>
</form>

<!-- 生成的 HTML -->
<form method="post" action="/transfer/">
    <input type="hidden" name="csrfmiddlewaretoken"
           value="8a7d9f2e3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p8q">
    <input type="number" name="amount" placeholder="金額">
    <input type="text" name="to" placeholder="收款人">
    <button type="submit">轉帳</button>
</form>

AJAX 請求中使用 CSRF Token

方法 1:從 Cookie 讀取 Token

// 讀取 Cookie 中的 CSRF Token
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;
}

const csrftoken = getCookie('csrftoken');

// 使用 fetch
fetch('/api/transfer/', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrftoken  // 添加 CSRF Token
    },
    body: JSON.stringify({
        amount: 1000,
        to: 'recipient'
    })
})
.then(response => response.json())
.then(data => console.log(data));

// 使用 XMLHttpRequest
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/transfer/', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('X-CSRFToken', csrftoken);  // 添加 CSRF Token
xhr.send(JSON.stringify({
    amount: 1000,
    to: 'recipient'
}));

方法 2:從 DOM 讀取 Token

<!-- 在 template 中輸出 Token -->
<meta name="csrf-token" content="{{ csrf_token }}">
// 從 meta tag 讀取
const csrftoken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

fetch('/api/transfer/', {
    method: 'POST',
    headers: {
        'X-CSRFToken': csrftoken
    },
    body: JSON.stringify({amount: 1000, to: 'recipient'})
});

方法 3:使用 jQuery (自動設置)

// jQuery 全局配置
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;
}

const csrftoken = getCookie('csrftoken');

// 全局設置:所有 AJAX 請求自動包含 CSRF Token
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!this.crossDomain) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        }
    }
});

// 現在所有 AJAX 請求都會自動帶上 CSRF Token
$.post('/api/transfer/', {
    amount: 1000,
    to: 'recipient'
}, function(data) {
    console.log(data);
});

原理:

1. 生成隨機 Token
2. Token 同時設置在:
   - Cookie
   - 請求參數(表單或 Header)
3. 驗證:Cookie Token == 參數 Token

優點: 不需要伺服器端 Session

Python 實作 (Flask 範例):

from flask import Flask, request, make_response
import secrets

app = Flask(__name__)

def generate_csrf_token():
    """生成 CSRF Token"""
    return secrets.token_hex(32)

def verify_csrf_token():
    """驗證 CSRF Token"""
    # 從 Cookie 讀取
    cookie_token = request.cookies.get('csrf_token')

    # 從表單或 Header 讀取
    form_token = request.form.get('csrf_token') or \
                 request.headers.get('X-CSRF-Token')

    # 驗證
    if not cookie_token or not form_token:
        return False

    return cookie_token == form_token

@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
    if request.method == 'POST':
        # 驗證 CSRF Token
        if not verify_csrf_token():
            return "CSRF Token 驗證失敗", 403

        # 執行轉帳
        amount = request.form.get('amount')
        to = request.form.get('to')
        # ...
        return "轉帳成功"

    # GET: 顯示表單
    response = make_response(render_template('transfer.html'))

    # 設置 CSRF Token Cookie
    if 'csrf_token' not in request.cookies:
        csrf_token = generate_csrf_token()
        response.set_cookie(
            'csrf_token',
            csrf_token,
            httponly=False,  # JavaScript 需要讀取
            secure=True,     # HTTPS only
            samesite='Lax'
        )

    return response
<!-- transfer.html -->
<form method="post">
    <!-- 從 Cookie 讀取 Token 並放入表單 -->
    <input type="hidden" name="csrf_token" id="csrf_token">
    <input type="number" name="amount" placeholder="金額">
    <input type="text" name="to" placeholder="收款人">
    <button type="submit">轉帳</button>
</form>

<script>
// 從 Cookie 讀取 Token
function getCookie(name) {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
}

// 設置到表單
document.getElementById('csrf_token').value = getCookie('csrf_token');
</script>

# Django settings.py

# Session Cookie
SESSION_COOKIE_SAMESITE = 'Lax'  # 推薦
SESSION_COOKIE_SECURE = True     # HTTPS only

# CSRF Cookie
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SECURE = True

三種模式比較

# Strict: 最嚴格
SESSION_COOKIE_SAMESITE = 'Strict'
# 行為:
# - 從 evil.com 點連結到 bank.com → Cookie 不發送
# - 需要重新登入
# 適用:銀行等高安全性網站

# Lax: 推薦(平衡安全性與用戶體驗)
SESSION_COOKIE_SAMESITE = 'Lax'
# 行為:
# - 從 evil.com 點 <a> 連結到 bank.com → Cookie 發送 ✅
# - 從 evil.com 提交 <form> 到 bank.com → Cookie 不發送 ✅
# 適用:大多數網站

# None: 允許跨站(必須配合 Secure)
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True  # 必須
# 行為:
# - 所有跨站請求都發送 Cookie
# 適用:需要第三方 Cookie 的場景(嵌入式 iframe 等)

測試 SameSite 配置

# views.py
from django.http import HttpResponse

def test_samesite(request):
    """測試 SameSite Cookie"""
    response = HttpResponse("測試 SameSite Cookie")

    # 設置測試 Cookie
    response.set_cookie(
        'test_strict',
        'value',
        samesite='Strict',
        secure=True
    )
    response.set_cookie(
        'test_lax',
        'value',
        samesite='Lax',
        secure=True
    )
    response.set_cookie(
        'test_none',
        'value',
        samesite='None',
        secure=True
    )

    return response

5️⃣ Layer 4: 驗證 Origin/Referer Header

自定義 CSRF 中間件

# middleware/csrf_middleware.py
from django.http import HttpResponseForbidden
from django.conf import settings
import re

class StrictCSRFMiddleware:
    """
    嚴格的 CSRF 中間件
    額外驗證 Origin 和 Referer Header
    """

    def __init__(self, get_response):
        self.get_response = get_response

        # 允許的域名
        self.allowed_origins = getattr(
            settings,
            'CSRF_ALLOWED_ORIGINS',
            ['https://example.com', 'https://www.example.com']
        )

    def __call__(self, request):
        # 只檢查狀態改變的方法
        if request.method in ['POST', 'PUT', 'DELETE', 'PATCH']:
            if not self.verify_origin(request):
                return HttpResponseForbidden('Invalid origin or referer')

        response = self.get_response(request)
        return response

    def verify_origin(self, request):
        """驗證 Origin 或 Referer Header"""

        # 1. 優先檢查 Origin Header
        origin = request.META.get('HTTP_ORIGIN')
        if origin:
            return self.is_allowed_origin(origin)

        # 2. 如果沒有 Origin,檢查 Referer
        referer = request.META.get('HTTP_REFERER')
        if referer:
            return self.is_allowed_origin(referer)

        # 3. 兩者都沒有 → 拒絕(可選,看安全需求)
        # return False  # 嚴格模式
        return True  # 寬鬆模式

    def is_allowed_origin(self, url):
        """檢查 URL 是否在白名單中"""
        for allowed in self.allowed_origins:
            if url.startswith(allowed):
                return True
        return False
# settings.py
MIDDLEWARE = [
    # ...
    'django.middleware.csrf.CsrfViewMiddleware',
    'myapp.middleware.csrf_middleware.StrictCSRFMiddleware',  # 添加自定義中間件
    # ...
]

# 配置允許的來源
CSRF_ALLOWED_ORIGINS = [
    'https://example.com',
    'https://www.example.com',
    'https://subdomain.example.com',
]

在 View 中驗證

# views.py
from django.http import HttpResponseForbidden
from urllib.parse import urlparse

def verify_referer(request):
    """在 View 中驗證 Referer"""
    referer = request.META.get('HTTP_REFERER', '')

    if not referer:
        return HttpResponseForbidden('Missing referer')

    # 解析 Referer URL
    parsed = urlparse(referer)

    # 檢查域名
    allowed_hosts = ['example.com', 'www.example.com']
    if parsed.hostname not in allowed_hosts:
        return HttpResponseForbidden('Invalid referer')

    return None  # 驗證通過

def sensitive_operation(request):
    """敏感操作:額外驗證 Referer"""
    if request.method == 'POST':
        # 驗證 Referer
        error = verify_referer(request)
        if error:
            return error

        # 執行操作
        # ...
        return HttpResponse("操作成功")

    return render(request, 'form.html')

6️⃣ Layer 5: 額外驗證

敏感操作需要重新驗證密碼

# views.py
from django.contrib.auth import authenticate
from django.contrib import messages
from django.shortcuts import render, redirect

def delete_account(request):
    """刪除帳號:需要重新輸入密碼"""
    if request.method == 'POST':
        password = request.POST.get('password')

        # 驗證密碼
        user = authenticate(
            username=request.user.username,
            password=password
        )

        if user is None:
            messages.error(request, '密碼錯誤')
            return render(request, 'delete_account.html')

        # 刪除帳號
        request.user.delete()
        messages.success(request, '帳號已刪除')
        return redirect('home')

    return render(request, 'delete_account.html')
<!-- delete_account.html -->
<form method="post">
    {% csrf_token %}
    <h2>⚠️ 刪除帳號</h2>
    <p>此操作無法撤銷!請輸入密碼確認:</p>

    <input type="password" name="password" required placeholder="請輸入密碼">
    <button type="submit" style="background: red;">確認刪除帳號</button>
</form>

使用 OTP/2FA

# views.py
import pyotp
from django.contrib.auth.decorators import login_required

@login_required
def transfer_money(request):
    """轉帳:需要 OTP 驗證"""
    if request.method == 'POST':
        amount = request.POST.get('amount')
        to = request.POST.get('to')
        otp = request.POST.get('otp')

        # 驗證 OTP
        user_secret = request.user.profile.otp_secret
        totp = pyotp.TOTP(user_secret)

        if not totp.verify(otp):
            messages.error(request, 'OTP 驗證碼錯誤')
            return render(request, 'transfer.html')

        # 執行轉帳
        do_transfer(request.user, amount, to)
        messages.success(request, '轉帳成功')
        return redirect('dashboard')

    return render(request, 'transfer.html')
<!-- transfer.html -->
<form method="post">
    {% csrf_token %}
    <h2>轉帳</h2>

    <label>金額</label>
    <input type="number" name="amount" required>

    <label>收款人</label>
    <input type="text" name="to" required>

    <label>OTP 驗證碼</label>
    <input type="text" name="otp" required placeholder="請輸入 6 位數驗證碼">

    <button type="submit">確認轉帳</button>
</form>

7️⃣ Layer 6: 監控與告警

記錄 CSRF 失敗

# middleware/csrf_logging.py
import logging
from django.utils.deprecation import MiddlewareMixin

logger = logging.getLogger('security.csrf')

class CSRFLoggingMiddleware(MiddlewareMixin):
    """記錄 CSRF 驗證失敗"""

    def process_view(self, request, view_func, view_args, view_kwargs):
        # 這個方法在 CsrfViewMiddleware 之後執行
        return None

    def process_response(self, request, response):
        # 檢查是否是 CSRF 失敗
        if response.status_code == 403:
            # 檢查是否是 CSRF 錯誤
            if hasattr(request, 'csrf_processing_done'):
                # 記錄詳細資訊
                logger.warning(
                    'CSRF verification failed',
                    extra={
                        'user': getattr(request, 'user', None),
                        'ip': self.get_client_ip(request),
                        'path': request.path,
                        'method': request.method,
                        'referer': request.META.get('HTTP_REFERER', ''),
                        'user_agent': request.META.get('HTTP_USER_AGENT', ''),
                    }
                )

        return response

    def get_client_ip(self, request):
        """獲取客戶端 IP"""
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0]
        else:
            ip = request.META.get('REMOTE_ADDR')
        return ip
# settings.py
MIDDLEWARE = [
    # ...
    'django.middleware.csrf.CsrfViewMiddleware',
    'myapp.middleware.csrf_logging.CSRFLoggingMiddleware',  # 添加
]

# 配置日誌
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'WARNING',
            'class': 'logging.FileHandler',
            'filename': 'logs/csrf_failures.log',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'security.csrf': {
            'handlers': ['file'],
            'level': 'WARNING',
            'propagate': False,
        },
    },
}

自定義 CSRF 失敗頁面

# views.py
def csrf_failure(request, reason=""):
    """自定義 CSRF 失敗頁面"""
    return render(request, 'csrf_failure.html', {
        'reason': reason
    }, status=403)
<!-- templates/csrf_failure.html -->
<!DOCTYPE html>
<html>
<head>
    <title>安全驗證失敗</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 100px auto;
            padding: 20px;
            text-align: center;
        }
        .error-box {
            background: #fff3cd;
            border: 2px solid #ffc107;
            border-radius: 8px;
            padding: 30px;
        }
        h1 { color: #856404; }
        p { color: #856404; }
        .button {
            background: #007bff;
            color: white;
            padding: 10px 20px;
            text-decoration: none;
            border-radius: 4px;
            display: inline-block;
            margin-top: 20px;
        }
    </style>
</head>
<body>
    <div class="error-box">
        <h1>🔒 安全驗證失敗</h1>
        <p>您的請求未通過安全驗證。</p>
        <p>可能的原因:</p>
        <ul style="text-align: left;">
            <li>頁面已過期,請重新整理</li>
            <li>瀏覽器 Cookie 被禁用</li>
            <li>您可能遭受了 CSRF 攻擊</li>
        </ul>
        <p><strong>原因:</strong> {{ reason }}</p>
        <a href="/" class="button">返回首頁</a>
    </div>
</body>
</html>
# settings.py
CSRF_FAILURE_VIEW = 'myapp.views.csrf_failure'

8️⃣ API 與 AJAX 的 CSRF 防護

RESTful API 的 CSRF 考量

情境分析:

情境 1: Web 應用 AJAX 調用自己的 API
→ 需要 CSRF 保護 ✅

情境 2: 移動 App 調用 API
→ 不需要 CSRF 保護(使用 Token 認證)

情境 3: 第三方 App 調用 API
→ 不需要 CSRF 保護(使用 OAuth/API Key)

方法 1: 豁免特定 API(使用 Token 認證)

# views.py
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
import json

@csrf_exempt  # 豁免 CSRF 檢查
def api_endpoint(request):
    """
    使用 Token 認證的 API
    不需要 CSRF 保護
    """
    # 驗證 API Token
    auth_header = request.META.get('HTTP_AUTHORIZATION', '')
    if not auth_header.startswith('Bearer '):
        return JsonResponse({'error': 'Missing token'}, status=401)

    token = auth_header[7:]  # 移除 "Bearer "

    # 驗證 Token
    if not verify_api_token(token):
        return JsonResponse({'error': 'Invalid token'}, status=401)

    # 處理請求
    data = json.loads(request.body)
    # ...
    return JsonResponse({'status': 'success'})

方法 2: 條件性 CSRF 保護

# middleware/conditional_csrf.py
from django.utils.deprecation import MiddlewareMixin

class ConditionalCSRFMiddleware(MiddlewareMixin):
    """
    條件性 CSRF 保護
    - Web 表單:需要 CSRF Token
    - API(有 Token 認證):豁免 CSRF
    """

    def process_view(self, request, view_func, view_args, view_kwargs):
        # API 路徑豁免 CSRF
        if request.path.startswith('/api/'):
            # 檢查是否有 API Token
            if 'HTTP_AUTHORIZATION' in request.META:
                # 有 Token → 豁免 CSRF
                setattr(request, '_dont_enforce_csrf_checks', True)

        return None
# settings.py
MIDDLEWARE = [
    # ...
    'myapp.middleware.conditional_csrf.ConditionalCSRFMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    # ...
]

方法 3: Django REST Framework 配置

# settings.py
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',  # 需要 CSRF
        'rest_framework.authentication.TokenAuthentication',    # 不需要 CSRF
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

# views.py (DRF)
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import TokenAuthentication

class TransferAPI(APIView):
    """
    使用 Token 認證的 API
    DRF 會自動處理 CSRF
    """
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]

    def post(self, request):
        amount = request.data.get('amount')
        to = request.data.get('to')

        # 執行轉帳
        # ...

        return Response({'status': 'success'})

9️⃣ 完整的 Django 項目範例

讓我們整合所有最佳實踐:

# settings.py
"""
Django CSRF 完整安全配置
"""

# 基礎設定
DEBUG = False
SECRET_KEY = os.environ.get('SECRET_KEY')
ALLOWED_HOSTS = ['example.com', 'www.example.com']

# 中間件(順序很重要!)
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',  # CSRF 保護
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# ==================== CSRF 設定 ====================
# CSRF Cookie 設定
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_AGE = 31449600  # 1 年
CSRF_COOKIE_SECURE = True   # HTTPS only
CSRF_COOKIE_HTTPONLY = False  # JavaScript 需要讀取
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_USE_SESSIONS = False  # False = 使用 Cookie, True = 使用 Session

# CSRF 受信任來源
CSRF_TRUSTED_ORIGINS = [
    'https://example.com',
    'https://www.example.com',
]

# CSRF Header 名稱(AJAX 使用)
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'

# CSRF 失敗處理
CSRF_FAILURE_VIEW = 'myapp.views.csrf_failure'

# ==================== Session/Cookie 設定 ====================
# Session Cookie
SESSION_COOKIE_NAME = 'sessionid'
SESSION_COOKIE_AGE = 86400  # 24 小時
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'

# Session 設定
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
SESSION_SAVE_EVERY_REQUEST = False

# ==================== 安全 Headers ====================
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = 'DENY'

# ==================== 日誌設定 ====================
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {name} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'WARNING',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': 'logs/security.log',
            'maxBytes': 1024 * 1024 * 10,  # 10 MB
            'backupCount': 5,
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'security': {
            'handlers': ['file'],
            'level': 'WARNING',
            'propagate': False,
        },
    },
}
# views.py
"""
安全的 Views 範例
"""
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.contrib import messages
from django.http import HttpResponse, JsonResponse
import json

@login_required
@require_http_methods(["GET", "POST"])
def transfer_money(request):
    """
    轉帳功能
    ✅ 使用 POST
    ✅ CSRF Token 保護
    ✅ 需要登入
    ✅ 額外密碼驗證
    """
    if request.method == 'POST':
        amount = request.POST.get('amount')
        to = request.POST.get('to')
        password = request.POST.get('password')

        # 額外驗證密碼
        if not request.user.check_password(password):
            messages.error(request, '密碼錯誤')
            return render(request, 'transfer.html')

        # 執行轉帳
        try:
            do_transfer(request.user, amount, to)
            messages.success(request, f'已轉帳 ${amount}{to}')
            return redirect('dashboard')
        except Exception as e:
            messages.error(request, f'轉帳失敗: {str(e)}')

    return render(request, 'transfer.html')

@login_required
@require_http_methods(["POST"])
def delete_account(request):
    """
    刪除帳號
    ✅ 只允許 POST
    ✅ CSRF Token 保護
    ✅ 需要登入
    ✅ 需要重新輸入密碼
    """
    password = request.POST.get('password')

    if not request.user.check_password(password):
        messages.error(request, '密碼錯誤')
        return redirect('settings')

    # 記錄日誌
    import logging
    logger = logging.getLogger('security')
    logger.warning(f'Account deleted: {request.user.username}')

    # 刪除帳號
    request.user.delete()
    messages.success(request, '帳號已刪除')
    return redirect('home')

def csrf_failure(request, reason=""):
    """自定義 CSRF 失敗頁面"""
    import logging
    logger = logging.getLogger('security.csrf')
    logger.warning(
        f'CSRF failure: {reason}',
        extra={
            'user': request.user.username if request.user.is_authenticated else 'anonymous',
            'ip': get_client_ip(request),
            'path': request.path,
        }
    )

    return render(request, 'csrf_failure.html', {
        'reason': reason
    }, status=403)

def get_client_ip(request):
    """獲取客戶端 IP"""
    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
    if x_forwarded_for:
        ip = x_forwarded_for.split(',')[0]
    else:
        ip = request.META.get('REMOTE_ADDR')
    return ip
<!-- templates/transfer.html -->
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- CSRF Token for AJAX -->
    <meta name="csrf-token" content="{{ csrf_token }}">
    <title>轉帳</title>
    <style>
        * { box-sizing: border-box; }
        body {
            font-family: Arial, sans-serif;
            max-width: 500px;
            margin: 50px auto;
            padding: 20px;
        }
        .form-group {
            margin-bottom: 15px;
        }
        label {
            display: block;
            font-weight: bold;
            margin-bottom: 5px;
        }
        input {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            background: #007bff;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 4px;
            cursor: pointer;
            width: 100%;
            font-size: 16px;
        }
        button:hover {
            background: #0056b3;
        }
        .messages {
            padding: 10px;
            margin-bottom: 20px;
            border-radius: 4px;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
        }
        .success {
            background: #d4edda;
            color: #155724;
        }
    </style>
</head>
<body>
    <h1>💸 轉帳</h1>

    {% if messages %}
    {% for message in messages %}
    <div class="messages {{ message.tags }}">
        {{ message }}
    </div>
    {% endfor %}
    {% endif %}

    <form method="post">
        {% csrf_token %}

        <div class="form-group">
            <label for="amount">轉帳金額</label>
            <input type="number" id="amount" name="amount" required min="1" placeholder="請輸入金額">
        </div>

        <div class="form-group">
            <label for="to">收款帳號</label>
            <input type="text" id="to" name="to" required placeholder="請輸入收款帳號">
        </div>

        <div class="form-group">
            <label for="password">確認密碼</label>
            <input type="password" id="password" name="password" required placeholder="請輸入您的密碼">
        </div>

        <button type="submit">確認轉帳</button>
    </form>

    <script>
    // AJAX 轉帳範例(可選)
    /*
    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;
    }

    document.querySelector('form').addEventListener('submit', function(e) {
        e.preventDefault();

        const csrftoken = getCookie('csrftoken');
        const formData = new FormData(this);

        fetch('/transfer/', {
            method: 'POST',
            headers: {
                'X-CSRFToken': csrftoken
            },
            body: formData
        })
        .then(response => response.json())
        .then(data => {
            alert(data.message);
        });
    });
    */
    </script>
</body>
</html>

🔟 安全檢查清單

開發階段

  • 所有狀態改變操作使用 POST/PUT/DELETE,不使用 GET
  • 所有表單包含 {% csrf_token %}
  • AJAX 請求包含 CSRF Token (Header 或參數)
  • 設置 CSRF_COOKIE_SECURE = True (生產環境)
  • 設置 SESSION_COOKIE_SAMESITE = 'Lax'
  • 敏感操作需要額外驗證(密碼/OTP)
  • API 使用 Token 認證,豁免 CSRF

配置檢查

# settings.py 檢查清單

# ✅ 中間件包含 CsrfViewMiddleware
'django.middleware.csrf.CsrfViewMiddleware' in MIDDLEWARE

# ✅ CSRF Cookie 安全設定
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = False  # JavaScript 需要讀取
CSRF_COOKIE_SAMESITE = 'Lax'

# ✅ Session Cookie 安全設定
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'

# ✅ 配置受信任來源
CSRF_TRUSTED_ORIGINS = ['https://example.com']

# ✅ 配置失敗處理
CSRF_FAILURE_VIEW = 'myapp.views.csrf_failure'

測試檢查

# 1. 測試沒有 CSRF Token 的請求
curl -X POST https://example.com/transfer/ \
     -d "amount=1000&to=attacker" \
     -b "sessionid=..."

# 預期:403 Forbidden

# 2. 測試錯誤的 CSRF Token
curl -X POST https://example.com/transfer/ \
     -d "amount=1000&to=attacker&csrfmiddlewaretoken=wrong_token" \
     -b "sessionid=...;csrftoken=correct_token"

# 預期:403 Forbidden

# 3. 測試 SameSite Cookie
# 在不同域名下嘗試發送請求

面試常見問題

Q1: 如何在 AJAX 請求中正確使用 CSRF Token?

參考答案:

有 3 種主要方法:

方法 1:從 Cookie 讀取(推薦)

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;
}

const csrftoken = getCookie('csrftoken');

fetch('/api/endpoint/', {
    method: 'POST',
    headers: {
        'X-CSRFToken': csrftoken  // 添加到 Header
    },
    body: JSON.stringify({...})
});

方法 2:從 DOM 讀取

<!-- Template -->
<meta name="csrf-token" content="{{ csrf_token }}">
const csrftoken = document.querySelector('meta[name="csrf-token"]').content;

方法 3:從表單元素讀取

const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;

關鍵點:

  • CSRF Token 必須在 HTTP Header 中(X-CSRFToken)
  • 或在請求體的參數中(csrfmiddlewaretoken)
  • Django 會驗證 Cookie Token == Header/參數 Token

參考答案:

不能! 原因:

  1. 瀏覽器兼容性問題:
- IE 11 不支援 SameSite
- 舊版 Safari、Chrome 也不支援
- 不能依賴所有用戶都用新瀏覽器
  1. 同站攻擊無法防禦:
如果攻擊者在你的網站上有 XSS 漏洞:
- SameSite 無效(因為是「同站」)
- 仍然需要 CSRF Token 保護
  1. 子域名問題:
SameSite 基於「站點」(site),不是「源」(origin)

example.com 和 evil.example.com 被視為同站
→ 子域名攻擊仍可能成功
  1. GET 請求仍有風險(Lax 模式):
SameSite=Lax 允許 GET 跨站請求

如果開發者錯誤地用 GET 做狀態改變:
GET /delete-account?confirm=yes
→ 仍可被攻擊!

最佳實踐:

SameSite Cookie + CSRF Token = 多層防禦 ✅

SameSite:第一層防禦
CSRF Token:核心防禦
額外驗證:敏感操作的最後防線

配置建議:

# 兩者都要設置!
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'

# 仍然需要在表單中使用
{% csrf_token %}

Q3: 如何處理移動 App 或第三方 API 的 CSRF 保護?

參考答案:

原則: 移動 App 和第三方 API 不需要 CSRF 保護,應使用 Token 認證

原因:

CSRF 攻擊的前提:
1. 瀏覽器自動發送 Cookie
2. 攻擊者誘騙用戶點擊惡意連結

移動 App:
- 不使用 Cookie 認證
- 使用 Token 儲存在 App 中
- 攻擊者無法獲取 Token
→ 不需要 CSRF 保護

實作方式:

1. Token 認證(推薦):

# views.py
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.response import Response

class TransferAPI(APIView):
    authentication_classes = [TokenAuthentication]  # Token 認證
    permission_classes = [IsAuthenticated]
    # 不需要 CSRF 檢查!

    def post(self, request):
        amount = request.data.get('amount')
        to = request.data.get('to')
        # 執行轉帳
        return Response({'status': 'success'})

2. OAuth 2.0:

from oauth2_provider.contrib.rest_framework import OAuth2Authentication

class TransferAPI(APIView):
    authentication_classes = [OAuth2Authentication]
    # 不需要 CSRF 檢查!

3. JWT (JSON Web Token):

from rest_framework_simplejwt.authentication import JWTAuthentication

class TransferAPI(APIView):
    authentication_classes = [JWTAuthentication]
    # 不需要 CSRF 檢查!

移動 App 使用方式:

// iOS (Swift)
let token = "user_api_token_here"
let url = URL(string: "https://api.example.com/transfer")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")

let body = ["amount": 1000, "to": "recipient"]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)

URLSession.shared.dataTask(with: request) { data, response, error in
    // 處理響應
}.resume()

豁免 CSRF 的方式:

# 方法 1:使用裝飾器
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def api_endpoint(request):
    # 驗證 Token
    # 處理請求
    pass

# 方法 2:在中間件中條件性豁免
# (見前面的 ConditionalCSRFMiddleware 範例)

# 方法 3:使用 DRF(自動處理)
# DRF 會自動處理不同認證方式的 CSRF

安全建議:

  • ✅ 使用 HTTPS(防止 Token 被竊取)
  • ✅ Token 應有過期時間
  • ✅ 實施 Rate Limiting
  • ✅ 記錄 API 訪問日誌
  • ❌ 不要在 URL 中傳遞 Token(會被記錄)
  • ❌ 不要在 Cookie 中儲存 Token(會自動發送)

重點回顧

核心概念

  1. 多層防禦:

    • Layer 1: 正確的 HTTP 方法
    • Layer 2: CSRF Token(核心)
    • Layer 3: SameSite Cookie
    • Layer 4: Referer/Origin 驗證
    • Layer 5: 額外驗證(敏感操作)
    • Layer 6: 監控與告警
  2. CSRF Token 實作:

    • Synchronizer Token Pattern(Django 預設)
    • Double Submit Cookie Pattern
    • 在表單中:{% csrf_token %}
    • 在 AJAX 中:X-CSRFToken Header
  3. SameSite Cookie:

    • Strict:最嚴格,所有跨站請求都阻擋
    • Lax:推薦,阻擋跨站 POST/PUT/DELETE
    • None:允許跨站(必須配合 Secure)
  4. API 認證:

    • 移動 App/第三方 API 使用 Token 認證
    • 不需要 CSRF 保護
    • 使用 HTTPS + Token 過期 + Rate Limiting

延伸閱讀


🔗 系列導航


📝 本文完成日期: 2025-01-15 🔖 標籤: #WebSecurity #CSRF #Django #Python #防禦策略 #面試準備

0%