Skip to content

CSRF 防护

CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种利用已认证用户的身份,在用户不知情的情况下执行非预期操作的攻击。PHP 应用中的表单提交、AJAX 请求和 API 调用都可能受到 CSRF 攻击。本节将讲解 CSRF 攻击原理和多种防护实现方式。

前置知识

阅读本节前,建议先了解:表单提交与验证Session 安全

基础概念

CSRF 攻击原理

攻击者网站                              目标网站(受害者已登录)
    |                                       |
    | 1. 受害者访问攻击者页面               |
    | 2. 页面包含隐藏表单(自动提交)        |
    | 3. 浏览器自动携带 Cookie -----------> |
    | 4. 服务器认为是用户操作 -> 攻击成功    |

CSRF 攻击条件

条件说明
用户已登录目标网站浏览器持有有效的 Session/Cookie
攻击者构造恶意请求包含目标 URL 和必要参数
用户访问攻击者页面浏览器自动发送带 Cookie 的请求
目标网站无 CSRF 防护仅依赖 Cookie 验证身份

CSRF 的危害

CSRF 可以让攻击者以受害者身份执行任何已授权操作,如转账、修改密码、修改邮箱、发布内容、删除数据等。所有状态改变的请求都需要 CSRF 防护

CSRF Token 防护

基本实现

php
<?php

declare(strict_types=1);

/**
 * 基于 Session 的 CSRF Token 生成与验证
 */
session_start();

/**
 * 生成 CSRF Token
 */
function generateCsrfToken(): string
{
    $token = bin2hex(random_bytes(32));
    $_SESSION['csrf_token'] = $token;
    $_SESSION['csrf_token_time'] = time();
    return $token;
}

/**
 * 验证 CSRF Token
 */
function validateCsrfToken(string $token): bool
{
    if (!isset($_SESSION['csrf_token'])) {
        return false;
    }

    if (!hash_equals($_SESSION['csrf_token'], $token)) {
        return false;
    }

    // 检查 Token 是否过期(默认 1 小时)
    $maxAge = 3600;
    if (time() - ($_SESSION['csrf_token_time'] ?? 0) > $maxAge) {
        unset($_SESSION['csrf_token'], $_SESSION['csrf_token_time']);
        return false;
    }

    // 验证后删除 Token(一次性使用)
    unset($_SESSION['csrf_token'], $_SESSION['csrf_token_time']);
    return true;
}

/**
 * 生成隐藏的 CSRF 表单字段
 */
function csrfField(): string
{
    $token = generateCsrfToken();
    return '<input type="hidden" name="_csrf_token" value="'
        . htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">';
}

// === 处理表单提交 ===
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $submittedToken = $_POST['_csrf_token'] ?? '';

    if (!validateCsrfToken($submittedToken)) {
        http_response_code(403);
        die('CSRF 验证失败,请重新提交表单');
    }

    echo "表单提交成功!";
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>CSRF 防护示例</title>
</head>
<body>
    <form method="POST" action="">
        <?= csrfField() ?>
        <input type="text" name="username" placeholder="用户名" required>
        <button type="submit">提交</button>
    </form>
</body>
</html>

AJAX 请求的 CSRF 防护

php
<?php

declare(strict_types=1);

session_start();

// API 端点:返回 CSRF Token
function sendCsrfToken(): never
{
    $token = generateCsrfToken();
    header('Content-Type: application/json');
    echo json_encode([
        'csrf_token' => $token,
        'csrf_header' => 'X-CSRF-Token',
    ]);
    exit;
}

// AJAX POST 请求验证
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';

    if (!validateCsrfToken($token)) {
        http_response_code(403);
        echo json_encode(['error' => 'CSRF token invalid']);
        exit;
    }

    echo json_encode(['success' => true]);
}

JavaScript 端获取 Token 并携带:

javascript
// 1. 获取 Token
fetch('/api/csrf-token')
    .then(res => res.json())
    .then(data => {
        // 2. 在后续请求中携带
        fetch('/api/update', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': data.csrf_token,
            },
            body: JSON.stringify({ name: 'Alice' }),
        });
    });
php
<?php

declare(strict_types=1);

// SameSite Cookie 防御 CSRF(PHP 7.3+)
setcookie('session_id', $sessionId, [
    'expires' => time() + 3600,
    'path' => '/',
    'domain' => 'example.com',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Strict', // 或 'Lax'
]);

// php.ini 配置
// session.cookie_samesite = Strict

// SameSite 属性值
// Strict:  完全禁止跨站携带 Cookie(最安全)
// Lax:    允许安全的顶级导航 GET 请求携带 Cookie(推荐)
// None:   允许跨站携带 Cookie(需配合 Secure)
php
<?php

declare(strict_types=1);

/**
 * 双重提交 Cookie 模式
 * 不依赖 Session,适合 API/无状态应用
 */
function generateCsrfCookie(): string
{
    $token = bin2hex(random_bytes(32));
    setcookie('csrf_token', $token, [
        'expires' => time() + 3600,
        'path' => '/',
        'secure' => true,
        'samesite' => 'Strict',
    ]);
    return $token;
}

function validateCsrfDoubleSubmit(): bool
{
    $cookieToken = $_COOKIE['csrf_token'] ?? '';
    $bodyToken = $_POST['_csrf_token']
        ?? $_SERVER['HTTP_X_CSRF_TOKEN']
        ?? '';

    if ($cookieToken === '' || $bodyToken === '') {
        return false;
    }

    return hash_equals($cookieToken, $bodyToken);
}

实战示例:CSRF 中间件

php
<?php

declare(strict_types=1);

class CsrfMiddleware
{
    private readonly string $tokenName;
    private readonly string $headerName;
    private readonly int $tokenLifetime;

    public function __construct(
        string $tokenName = '_csrf_token',
        string $headerName = 'X-CSRF-Token',
        int $tokenLifetime = 3600,
    ) {
        $this->tokenName = $tokenName;
        $this->headerName = $headerName;
        $this->tokenLifetime = $tokenLifetime;
    }

    public function generateToken(): string
    {
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }

        $token = bin2hex(random_bytes(32));
        $_SESSION[$this->tokenName] = $token;
        $_SESSION[$this->tokenName . '_time'] = time();
        return $token;
    }

    public function validate(): bool
    {
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }

        // 仅对状态改变的请求方法验证
        if (in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD', 'OPTIONS'], true)) {
            return true;
        }

        $headerKey = 'HTTP_' . strtoupper(str_replace('-', '_', $this->headerName));
        $token = $_POST[$this->tokenName] ?? $_SERVER[$headerKey] ?? '';

        return $this->verifyToken($token);
    }

    private function verifyToken(string $token): bool
    {
        $storedToken = $_SESSION[$this->tokenName] ?? '';

        if ($storedToken === '' || $token === '') {
            return false;
        }

        if (!hash_equals($storedToken, $token)) {
            return false;
        }

        $created = $_SESSION[$this->tokenName . '_time'] ?? 0;
        if (time() - $created > $this->tokenLifetime) {
            $this->clearToken();
            return false;
        }

        return true;
    }

    public function validateOnce(): bool
    {
        if ($this->validate()) {
            $this->clearToken();
            return true;
        }
        return false;
    }

    public function clearToken(): void
    {
        unset($_SESSION[$this->tokenName], $_SESSION[$this->tokenName . '_time']);
    }

    public function field(): string
    {
        $token = $this->generateToken();
        $safeToken = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
        return '<input type="hidden" name="' . $this->tokenName
            . '" value="' . $safeToken . '">';
    }

    public function metaTag(): string
    {
        $token = $this->generateToken();
        $safeToken = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
        return '<meta name="csrf-token" content="' . $safeToken . '">';
    }
}

// 使用
$csrf = new CsrfMiddleware();

// GET 请求:渲染表单时嵌入 Token
echo $csrf->field();
echo $csrf->metaTag();

// POST 请求:验证 Token
if (!$csrf->validateOnce()) {
    http_response_code(403);
    echo json_encode(['error' => 'CSRF token validation failed']);
    exit;
}

注意事项

1. Token 的安全性

php
<?php

// 正确:使用 random_bytes(加密安全随机数)
$token = bin2hex(random_bytes(32)); // 256 位熵

// 错误:使用 mt_rand(不安全)
// $token = md5(mt_rand()); // 可预测

// 验证时使用 hash_equals 防止时序攻击
if (hash_equals($stored, $provided)) { ... }

2. 哪些请求需要防护

php
<?php

// GET/HEAD/OPTIONS 是安全方法,通常不需要 CSRF Token
// POST/PUT/PATCH/DELETE 会改变状态,必须防护
$safeMethods = ['GET', 'HEAD', 'OPTIONS'];
if (!in_array($_SERVER['REQUEST_METHOD'], $safeMethods, true)) {
    // 需要验证 CSRF Token
}

最佳实践

1. 多层防御

php
<?php

// 第一层:SameSite Cookie(浏览器级防护)
setcookie('session_id', $sid, [
    'samesite' => 'Lax', 'secure' => true, 'httponly' => true,
]);

// 第二层:CSRF Token(应用级防护)
$csrf->validateOnce();

// 第三层:验证 Origin / Referer 头
$origin = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '';
$allowed = ['https://www.example.com', 'https://app.example.com'];
if (!in_array($origin, $allowed, true)) {
    http_response_code(403);
    exit;
}

下一节

继续学习:XSS 防护

参考链接