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' }),
});
});SameSite Cookie 属性
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)双重提交 Cookie 模式
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 防护