本文目录导读:

在 PHP 中进行 XSS(跨站脚本攻击)防御,核心原则是:永远不要信任用户输入,验证(输入验证)和输出编码是两道关键防线。
其中输出编码是防御 XSS 的最终且最有效的手段,而输入验证是第一道防线,用于过滤或拒绝不合法的数据。
下面详细讲解在 PHP 的验证阶段如何做 XSS 防御。
核心原则:区分“验证”与“清洗”
-
输入验证(Validation): 检查数据是否符合预期格式(如:是否是邮箱、数字、纯文本)。推荐做法是“白名单”验证:如果数据不符合预期格式,就拒绝它,而不是试图“修复”它。
- 错误做法: 试图移除
<script>标签或onerror属性,这种方式很容易被绕过(例如使用大小写、编码、或利用 HTML5 新特性)。 - 正确做法: 如果字段应该是纯数字,就检查
is_numeric(),如果不是就返回错误,而不是去清理数字里的 HTML。
- 错误做法: 试图移除
-
输入清洗(Sanitization): 移除或修改数据中不安全的字符,在某些场景下(如富文本输入)是必须的,但风险较高。不要对 HTML 标签执行“黑名单”过滤,而应该使用HTML Purifier等成熟的库进行“白名单”过滤。
输入验证(拒绝非法数据)
这是最安全的做法,对于大多数表单字段,你应该验证它们是否属于允许的类型。
验证数据类型
这是最直接的方式,如果期望是整数,就用 intval() 或 filter_var(..., FILTER_VALIDATE_INT)。
<?php
// 用户期望输入一个用户ID,应该是整数
$userIdRaw = $_GET['id'] ?? '';
// 验证:必须是整数且大于0
$userId = filter_var($userIdRaw, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1]
]);
if ($userId === false) {
die('无效的用户ID');
}
// $userId 是 int(123),可以直接用于数据库查询
echo "用户ID: " . $userId;
?>
验证字符串格式(白名单模式)
如果允许的字符串非常有限(如:用户名只能包含字母和数字),使用正则表达式进行匹配。
<?php
$username = $_POST['username'] ?? '';
// 白名单验证:只允许字母、数字、下划线、点(2-20字符)
if (preg_match('/^[a-zA-Z0-9_.]{2,20}$/', $username)) {
echo "用户名有效: " . htmlspecialchars($username, ENT_QUOTES, 'UTF-8');
} else {
die('用户名包含非法字符');
}
?>
注意: 即使通过了验证,在输出时依然使用了 htmlspecialchars(),因为输入“有效”不代表它“安全”地在 HTML 上下文中输出,用户名 John 是完全合法的,但如果你在 src 属性或 <script> 标签内直接输出它,仍然可能产生问题。
验证特定格式(邮箱、URL等)
PHP 的 filter_var 函数可以验证多种预定义的格式。
<?php
$email = $_POST['email'] ?? '';
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo "邮箱有效: " . htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
} else {
die('邮箱格式不正确');
}
$url = $_POST['website'] ?? '';
if (filter_var($url, FILTER_VALIDATE_URL)) {
// 注意:即使是有效 URL,也可能是 javascript:alert(1)
// 需要进一步验证协议
$parsedUrl = parse_url($url);
$allowedSchemes = ['http', 'https'];
if (!isset($parsedUrl['scheme']) || !in_array($parsedUrl['scheme'], $allowedSchemes)) {
die('只允许 http 或 https 协议');
}
echo "URL有效: " . htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
} else {
die('URL格式不正确');
}
?>
输入清洗(移除不安全部分)
当你必须接受用户输入的 HTML(论坛帖子、用户简介、富文本编辑器内容)时,验证通常不足以防范 XSS,你需要在验证后、存储前,对数据进行清洗。
使用 HTML Purifier 库
这是 PHP 社区最推荐且经过广泛测试的 HTML 清洗方案,它通过白名单的方式,只允许你指定的标签和属性。
<?php
require_once 'HTMLPurifier.auto.php'; // 假设已安装
$config = HTMLPurifier_Config::createDefault();
// 白名单配置:只允许这些标签
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
// 允许的链接协议
$config->set('URI.AllowedSchemes', ['http', 'https']);
$purifier = new HTMLPurifier($config);
$dirtyHtml = $_POST['content'] ?? '';
$cleanHtml = $purifier->purify($dirtyHtml); // 移除所有不在白名单中的标签和属性
// 存储 $cleanHtml 到数据库
// 输出时:虽然已经是安全的,但为了保险,在HTML上下文中输出时,通常直接用 echo。
// 因为 HTML Purifier 已经将内容转换为纯正的HTML,不需要再 htmlspecialchars。
echo $cleanHtml;
?>
使用 strip_tags()
这是 PHP 内置函数,但功能有限,它只能移除字符串中的 HTML 和 PHP 标签。
<?php
$raw = "<script>alert('XSS')</script><p>这是正文</p>";
$clean = strip_tags($raw); // 输出: "alert('XSS')这是正文"
echo htmlspecialchars($clean, ENT_QUOTES, 'UTF-8'); // 最后输出编码
?>
缺点:
- 不能保留任何允许的标签。
- 它不能移除标签内的属性。
strip_tags('<a href="#" onclick="alert(1)">点我</a>')会返回<a href="#" onclick="alert(1)">点我</a>,攻击仍然存在。 strip_tags只适用于“你完全不想要任何HTML标签”的场景。
输出编码(最终防线,必须做)
无论你做了多么严格的验证或清洗,在向浏览器输出数据时,都必须使用正确的编码函数。 验证和清洗可能会出错或被绕过,但输出编码是最后一道无法绕过的防线(假设你正确使用)。
HTML 上下文(最常见)
使用 htmlspecialchars() 或 htmlentities()。
<?php // 假设 $userName 来自数据库(已经过验证或清洗) echo "欢迎您,<span>" . htmlspecialchars($userName, ENT_QUOTES, 'UTF-8') . "</span>"; ?>
ENT_QUOTES:同时转义单引号和双引号。'UTF-8':指定字符编码。
URL 上下文
使用 urlencode()。
<?php $searchTerm = "安全/测试?"; echo '<a href="?q=' . urlencode($searchTerm) . '">搜索</a>'; // 输出: <a href="?q=%E5%AE%89%E5%85%A8%2F%E6%B5%8B%E8%AF%95%3F">搜索</a> ?>
JavaScript 上下文(极其危险,尽量避免)
避免在 <script> 标签内拼接用户数据,如果必须,使用 json_encode()。
<?php
$userData = [
'name' => "John'; alert(1)//"
];
echo '<script>';
echo 'var userData = ' . json_encode($userData, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';';
echo '</script>';
// json_encode 会处理转义,输出安全的数据
?>
CSS 上下文(非常少见且危险)
严格避免将用户输入放到CSS中(style 属性中的 background-image: url()),如果必须,使用 addslashes() 和正则验证。
实战示例:完整的注册表单验证
<?php
// 假设这是用户注册的验证逻辑
$errors = [];
$username = '';
$email = '';
$bio = ''; // 简介,允许少量标记
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 1. 获取原始输入
$rawUsername = $_POST['username'] ?? '';
$rawEmail = $_POST['email'] ?? '';
$rawBio = $_POST['bio'] ?? ''; // 只允许文本,不允许HTML
// 2. 输入验证(拒绝非法数据)
// 用户名:白名单,只允许字母、数字、下划线、点
if (preg_match('/^[a-zA-Z0-9_.]{4,20}$/', $rawUsername)) {
$username = $rawUsername; // 已验证安全,可以存储
} else {
$errors[] = '用户名必须是4-20个字母、数字或下划线';
}
// 邮箱:使用 filter_var 验证格式
$email = filter_var($rawEmail, FILTER_VALIDATE_EMAIL);
if ($email === false) {
$errors[] = '请输入有效的邮箱地址';
}
// 简介:我们期望纯文本,所以直接拒绝任何HTML标签(清洗)
// 但也可以使用验证:如果包含标签就拒绝
if ($rawBio !== strip_tags($rawBio)) {
$errors[] = '简介中不允许包含HTML标签';
} else {
$bio = $rawBio; // 已验证不含标签,但输出时仍需编码
}
// 3. 如果没有错误,存储到数据库
if (empty($errors)) {
// 使用预处理语句写入数据库(防止SQL注入)
// ... 连接数据库并插入
// 注意:存储时,不要对数据做额外的转义,用预处理语句即可
// 存储 $username, $email, $bio 到数据库
// 跳转到成功页面
header('Location: success.php');
exit;
}
}
?>
<!-- HTML 输出 -->
<!DOCTYPE html>
<html>
<head>注册</title>
</head>
<body>
<?php if (!empty($errors)): ?>
<div style="color:red;">
<?php foreach ($errors as $error): ?>
<!-- 输出错误信息时也要编码 -->
<p><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></p>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post">
<!-- 回填已输入的值(用户友好) -->
用户名: <input type="text" name="username" value="<?php echo htmlspecialchars($rawUsername ?? '', ENT_QUOTES, 'UTF-8'); ?>"><br>
邮箱: <input type="text" name="email" value="<?php echo htmlspecialchars($rawEmail ?? '', ENT_QUOTES, 'UTF-8'); ?>"><br>
简介: <textarea name="bio"><?php echo htmlspecialchars($rawBio ?? '', ENT_QUOTES, 'UTF-8'); ?></textarea><br>
<button type="submit">注册</button>
</form>
</body>
</html>
验证阶段的 XSS 防御清单
-
输入验证:
- 使用 白名单 方式,拒绝所有不符合格式的数据。
- 确保数据类型正确(整数、浮点数、字符串)。
- 对于字符串,使用正则表达式限制字符集(
a-z,0-9等)。 - 对于邮箱、URL等,使用
filter_var()。
-
输入清洗(仅在必须接受HTML时使用):
- 不要 编写自己的、基于黑名单的 HTML 过滤函数。
- 使用 HTML Purifier 库,配置白名单。
- 如果只是纯文本,用
strip_tags()完全移除标签(注意它的限制)。
-
输出编码(终极铁律):
- 在 HTML 上下文中,使用
htmlspecialchars($string, ENT_QUOTES, 'UTF-8')。 - 在 JSON/JavaScript 上下文中,使用
json_encode()。 - 在 URL 上下文中,使用
urlencode()。
- 在 HTML 上下文中,使用
最核心的一句话:在 PHP 验证阶段,你应该“拒绝不安全的”,而不是“尝试清除不安全的”,永远不要忘记在输出时进行编码。