输出编码
输出编码是防御 XSS 攻击的核心手段。根据数据输出的上下文(HTML 正文、HTML 属性、JavaScript、CSS、URL),选择正确的编码方式,确保用户输入的数据不会被浏览器解释为代码执行。PHP 提供了 htmlspecialchars()、json_encode() 等内置函数来实现各种上下文的输出编码。
基础概念
输出上下文分类
PHP 输出的数据最终会在浏览器的不同上下文中显示。每种上下文有自己的语法规则,需要对应的编码策略:
| 上下文 | 语法 | 编码函数 | 编码目标 |
|---|---|---|---|
| HTML 正文 | <div>...</div> | htmlspecialchars() | < > & " |
| HTML 属性 | <div attr="..."> | htmlspecialchars() | < > & " |
| JavaScript | <script>var x = ... | json_encode() | 特殊字符 |
| URL | <a href="..."> | rawurlencode() | 保留/不安全字符 |
| CSS | <style>... | 白名单验证 | 非字母数字字符 |
| HTML 注释 | <!-- ... --> | 移除 -- | -- |
编码原则
编码必须在输出时执行,而不是存储时执行。数据应以原始形式存储在数据库中,在输出到 HTML 时根据具体上下文进行编码。
htmlspecialchars() 详解
函数签名与参数
php
<?php
declare(strict_types=1);
// 函数签名
// htmlspecialchars(
// string $string,
// int $flags = ENT_QUOTES | ENT_SUBSTITUTE,
// ?string $encoding = null,
// bool $double_encode = true
// ): string
// 基本用法
$input = '<script>alert("XSS & Test")</script>';
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
// <script>alert("XSS & Test")</script>编码标志(flags 参数)
php
<?php
declare(strict_types=1);
$input = "'Single' & \"Double\" <> Test";
// ENT_COMPAT(默认旧行为):只编码双引号
echo htmlspecialchars($input, ENT_COMPAT, 'UTF-8');
// 'Single' & "Double" <> Test
// ENT_QUOTES:编码单引号和双引号(推荐)
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
// 'Single' & "Double" <> Test
// ENT_NOQUOTES:不编码任何引号
echo htmlspecialchars($input, ENT_NOQUOTES, 'UTF-8');
// 'Single' & "Double" <> Test
// ENT_SUBSTITUTE:替换无效 UTF-8 序列为 Unicode 替代字符(推荐)
$badInput = "\xFF\xFE test";
echo htmlspecialchars($badInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// test(替代字符)
// ENT_DISALLOWED:移除无效的 Unicode 代码点
// ENT_HTML401:HTML 4.01 规范
// ENT_HTML5:HTML5 规范
// ENT_XML1:XML 1 规范
// ENT_XHTML:XHTML 规范
// double_encode 参数
$alreadyEncoded = '<script>';
echo htmlspecialchars($alreadyEncoded, ENT_QUOTES, 'UTF-8'); // &lt;script&gt;(双重编码)
echo htmlspecialchars($alreadyEncoded, ENT_QUOTES, 'UTF-8', false); // <script>(不双重编码)字符编码的重要性
php
<?php
declare(strict_types=1);
// 必须显式指定字符编码
// 如果不指定,PHP 5.4 之前默认 ISO-8859-1
// PHP 5.4+ 默认 UTF-8,但显式指定更安全
// 正确:始终指定编码
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
// 检测并设置默认编码
ini_set('default_charset', 'UTF-8');
// 在 HTML 头部声明编码
header('Content-Type: text/html; charset=utf-8');HTML 正文编码
php
<?php
declare(strict_types=1);
$userInput = $_GET['name'] ?? '';
// 正确:在 HTML 正文中输出
echo '<h1>Welcome, ' . htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') . '</h1>';
echo '<p>Your message: ' . htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') . '</p>';
// 短标签输出(确保 short_open_tag = Off)
echo '<p>' . htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') . '</p>';HTML 属性编码
php
<?php
declare(strict_types=1);
$value = $_GET['value'] ?? '';
$title = $_GET['title'] ?? '';
// 正确:属性值使用 ENT_QUOTES 编码
echo '<input type="text" value="' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '">';
echo '<a title="' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '" href="/home">Link</a>';
// data-* 属性
echo '<div data-info="' . htmlspecialchars($jsonData, ENT_QUOTES, 'UTF-8') . '">';
// 注意:使用 ENT_QUOTES 以同时编码单引号和双引号
echo "<input type='text' value='" . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . "'>";JavaScript 上下文编码
php
<?php
declare(strict_types=1);
// === 危险:直接嵌入 PHP 变量到 JS ===
$jsVar = $_GET['data'] ?? '';
// echo "var data = '{$jsVar}';"; // XSS! 用户输入: ';alert(1)//
// === 正确方式一:json_encode ===
$phpData = ['name' => 'Alice', 'age' => 30, 'msg' => 'Hello "World"'];
echo 'var data = ' . json_encode($phpData,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) . ';';
// var data = {"name":"Alice","age":30,"msg":"Hello \"World\""}
// === 正确方式二:通过 data 属性传递 ===
?>
<div id="app"
data-config="<?= htmlspecialchars(json_encode($config), ENT_QUOTES, 'UTF-8') ?>">
</div>
<script>
const config = JSON.parse(document.getElementById('app').dataset.config);
</script>
<?php
// === 正确方式三:使用 PHP 生成 JS 文件 ===
// 设置正确的 Content-Type
header('Content-Type: application/javascript; charset=utf-8');
$data = ['apiUrl' => '/api/v1', 'csrfToken' => $csrfToken];
echo 'const APP_CONFIG = ' . json_encode($data, JSON_UNESCAPED_SLASHES) . ';';json_encode 标志说明
php
<?php
declare(strict_types=1);
// JSON 编码安全标志
$flags = JSON_HEX_TAG // 编码 < 和 > 为 \u003C 和 \u003E
| JSON_HEX_AMP // 编码 & 为 \u0026
| JSON_HEX_APOS // 编码 ' 为 \u0027
| JSON_HEX_QUOT // 编码 " 为 \u0022
| JSON_UNESCAPED_UNICODE // 不转义 Unicode 字符
| JSON_THROW_ON_ERROR; // 错误时抛出异常
$input = '<script>alert("XSS")</script>';
echo json_encode($input, $flags);
// "\u003Cscript\u003Ealert(\"XSS\")\u003C\/script\u003E"URL 上下文编码
php
<?php
declare(strict_types=1);
// === HTML href 属性中的 URL ===
$redirect = $_GET['redirect'] ?? '/home';
// 步骤一:验证 URL(白名单协议)
if (!preg_match('~^https?://~i', $redirect) && !str_starts_with($redirect, '/')) {
$redirect = '/home';
}
// 步骤二:防止 javascript: 协议
if (preg_match('/^\s*javascript:/i', $redirect)) {
$redirect = '/home';
}
// 步骤三:HTML 属性编码
echo '<a href="' . htmlspecialchars($redirect, ENT_QUOTES, 'UTF-8') . '">Continue</a>';
// === 构建 URL 查询参数 ===
$params = [
'q' => 'php 教程',
'page' => 1,
'lang' => 'zh-CN',
];
$url = '/search?' . http_build_query($params);
// /search?q=php+%E6%95%99%E7%A8%8B&page=1&lang=zh-CN
// === rawurlencode vs urlencode ===
// rawurlencode: 空格编码为 %20(RFC 3986)
// urlencode: 空格编码为 +(application/x-www-form-urlencoded)
$pathSegment = rawurlencode('PHP 教程/基础');
$url = "https://example.com/{$pathSegment}";
// https://example.com/PHP%20%E6%95%99%E7%A8%8B%2F%E5%9F%BA%E7%A1%80CSS 上下文编码
php
<?php
declare(strict_types=1);
// CSS 注入防护:白名单验证
$color = $_GET['color'] ?? '#000000';
$fontSize = $_GET['size'] ?? '14';
// 白名单验证颜色
$allowedColors = [
'#000000', '#ffffff', '#333333', '#666666', '#999999',
'#ff0000', '#00ff00', '#0000ff', '#ff6600',
];
if (!in_array(strtolower($color), $allowedColors, true)) {
$color = '#000000';
}
if (!preg_match('/^\d+(px|em|rem|%|vh|vw)$/', $fontSize)) {
$fontSize = '14px';
}
echo '<style>';
echo 'body { color: ' . htmlspecialchars($color, ENT_QUOTES, 'UTF-8') . '; }';
echo 'h1 { font-size: ' . htmlspecialchars($fontSize, ENT_QUOTES, 'UTF-8') . '; }';
echo '</style>';实战示例:安全的输出辅助类
php
<?php
declare(strict_types=1);
/**
* 安全输出辅助类
*/
class Esc
{
private static ?string $encoding = null;
public static function setEncoding(string $encoding): void
{
self::$encoding = $encoding;
}
private static function getEncoding(): string
{
return self::$encoding ?? ini_get('default_charset') ?: 'UTF-8';
}
private static function flags(): int
{
return ENT_QUOTES | ENT_SUBSTITUTE;
}
/** HTML 正文转义 */
public static function html(string $string): string
{
return htmlspecialchars($string, self::flags(), self::getEncoding());
}
/** HTML 属性转义 */
public static function attr(string $string): string
{
return htmlspecialchars($string, self::flags(), self::getEncoding());
}
/** JavaScript 转义(用于嵌入 JS 变量)*/
public static function js(string $value): string
{
return json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/** JSON 安全编码 */
public static function json(mixed $data): string
{
return json_encode($data,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
| JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);
}
/** URL 路径编码 */
public static function url(string $string): string
{
return rawurlencode($string);
}
/** CSS 值安全验证(白名单方式)*/
public static function css(string $string): string
{
// 仅保留字母、数字和部分安全字符
return preg_replace('/[^a-zA-Z0-9#\-_.%\s]/', '', $string);
}
/** HTML 注释安全 */
public static function comment(string $string): string
{
return str_replace('--', '- - ', $string);
}
}
// === 使用示例 ===
$name = $_GET['name'] ?? 'World';
$jsData = ['key' => 'value<script>'];
// HTML 正文
echo '<h1>' . Esc::html($name) . '</h1>';
// HTML 属性
echo '<input value="' . Esc::attr($name) . '">';
// JavaScript
echo '<script>var data = ' . Esc::js($name) . ';</script>';
// JSON 数据属性
echo '<div data-config="' . Esc::attr(Esc::json($jsData)) . '">';
// URL
echo '<a href="' . Esc::attr('/search?' . http_build_query(['q' => $name])) . '">';注意事项
1. 编码时机
php
<?php
// 正确:存储原始数据,输出时编码
$db->insert(['title' => $userInput]); // 存储原始数据
echo Esc::html($row['title']); // 输出时编码
// 错误:存储编码后的数据
$db->insert(['title' => htmlspecialchars($userInput)]); // 错误!
// 如果后续在 JSON API 中使用,会出现双重编码2. 设置默认字符集
php
<?php
// php.ini
// default_charset = "UTF-8"
// 或在运行时
ini_set('default_charset', 'UTF-8');
// HTML 头部声明
// <meta charset="UTF-8">
// PHP 头部
// header('Content-Type: text/html; charset=utf-8');
// 三者都应设置,确保一致性最佳实践
1. 创建 Esc 工具类的全局函数
php
<?php
// functions.php - 全局辅助函数
if (!function_exists('e')) {
function e(string $string): string
{
return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
// 模板中使用
// <?= e($user['name']) ?>下一节
继续学习:密码处理