Skip to content

输出编码

输出编码是防御 XSS 攻击的核心手段。根据数据输出的上下文(HTML 正文、HTML 属性、JavaScript、CSS、URL),选择正确的编码方式,确保用户输入的数据不会被浏览器解释为代码执行。PHP 提供了 htmlspecialchars()json_encode() 等内置函数来实现各种上下文的输出编码。

前置知识

阅读本节前,建议先了解:安全总则XSS 防护

基础概念

输出上下文分类

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');
// &lt;script&gt;alert(&quot;XSS &amp; Test&quot;)&lt;/script&gt;

编码标志(flags 参数)

php
<?php

declare(strict_types=1);

$input = "'Single' & \"Double\" <> Test";

// ENT_COMPAT(默认旧行为):只编码双引号
echo htmlspecialchars($input, ENT_COMPAT, 'UTF-8');
// 'Single' &amp; &quot;Double&quot; &lt;&gt; Test

// ENT_QUOTES:编码单引号和双引号(推荐)
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
// &#039;Single&#039; &amp; &quot;Double&quot; &lt;&gt; Test

// ENT_NOQUOTES:不编码任何引号
echo htmlspecialchars($input, ENT_NOQUOTES, 'UTF-8');
// 'Single' &amp; "Double" &lt;&gt; 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 = '&lt;script&gt;';
echo htmlspecialchars($alreadyEncoded, ENT_QUOTES, 'UTF-8');         // &amp;lt;script&amp;gt;(双重编码)
echo htmlspecialchars($alreadyEncoded, ENT_QUOTES, 'UTF-8', false);   // &lt;script&gt;(不双重编码)

字符编码的重要性

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%80

CSS 上下文编码

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']) ?>

下一节

继续学习:密码处理

参考链接