ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP 中 POST 数据的 HTML 实体编码问题解析与修复

PHP 中 POST 数据的 HTML 实体编码问题解析与修复 前言这个问题的典型症状有三种而且看起来互不相干页面标题上显示成amp;lt;bamp;gt;这么一串东西用户搜「AB」搜不到任何结果因为库里存的是Aamp;B导出 CSV 给运营几百条商品名全是quot;和nbsp;。三种症状同一个根因把 HTML 实体编码当成了「数据清洗」在入口处就对$_POST做了转义。这个做法在十多年前的教程里很常见但它混淆了两件本质不同的事——存储数据和渲染输出。HTML 实体不是数据的属性而是「某个字符在 HTML 这个上下文里该怎么表示」的问题。把上下文相关的表示形式写进数据库等于把渲染层的细节焊死在数据层之后再想还原就得靠猜。还有一个更隐蔽的变体有人在入口处没转义但在出口处转义了两次比如模板引擎已经自动转义代码里又手动htmlspecialchars()了一遍。症状一模一样。本文先把$_POST的真实语义讲清楚再给出一套「原样入库、出口转义」的修复方案最后附一段用来清理历史脏数据的脚本。文中的行为差异以PHP 8.1为分界线——htmlspecialchars()的默认参数在这一版发生了变化很多老代码升上来之后行为会变。一、先把语义定下来$_POST里没有 HTML 实体一个必须先破除的误解PHP 从不会给$_POST的值做 HTML 实体编码。它只做的事情是URL 解码。浏览器提交表单时有两种编码方式Content-Type浏览器怎么编码值PHP 怎么还原application/x-www-form-urlencoded把编码成%3C、编码成%26URL 解码$_POST拿到原始字符multipart/form-data按分块原样传输不做百分号编码按分块读取$_POST拿到原始字符两条路的结果完全一致$_POST里永远是用户输入的原始字符。下面这段代码在 CLI 下就能复现这个过程parse_str()做的事情和 PHP 内部填充$_POST时是一致的?php declare(strict_types1); // post_semantics.php —— 需要 PHP 8.1 // 用户在表单里输入了 scriptalert(1)/script另一个字段输入了 ab。 // 浏览器实际发出的请求体是下面这样注意 %3C、%26 这些百分号编码 $body name%3Cscript%3Ealert(1)%3C%2Fscript%3Enotea%26braw%26lt%3Bb%26gt%3B; parse_str($body, $post); // 等价于 PHP 内部填充 $_POST 的过程 var_dump($post[name]); // string(19) scriptalert(1)/script var_dump($post[note]); // string(3) ab var_dump($post[raw]); // string(11) lt;bgt; // 关键结论 // 1. PHP 还原出了原文的 —— 没有做任何 HTML 转义。 // 2. 第三个字段里的 lt;bgt; 之所以长这样 // 是因为「用户真的在输入框里打了这 8 个字符」PHP 只是原样奉还。 // 它不是 PHP 编码出来的也无法和用户手打的其它内容区分开。第 3 条结论正是「入口转义」这个方案注定失败的原因一旦你在入口把变成lt;就和用户手打的lt;混在一起了再也分不出哪个是哪个。这就是为什么后面清理历史数据只能靠启发式判断、做不到百分之百准确。二、正确的心智模型原样入库出口转义把数据流转分成三段每段只做一件事阶段该做什么不该做什么输入$_POST校验类型、长度、格式原样存入数据库不做 HTML 转义不做strip_tags()存储数据库保存用户输入的原始字符不保存任何上下文相关的表示形式输出HTML在即将拼接进 HTML 的那一刻调用htmlspecialchars()不要假设上游已经转义过了按这个模型一张对照表可以把所有症状解释清楚用户实际输入$_POST拿到的值若在入口转义库里存的出口再转义后页面显示bhi/bbhi/blt;bgt;hilt;/bgt;字面量lt;bgt;hilt;/bgt;错ababaamp;b字面量aamp;b错itsitsit#039;s字面量it#039;s错正确的做法bhi/bbhi/bbhi/blt;bgt;hilt;/bgt;浏览器渲染成加粗的hi对「入口转义」的坏处不只是显示难看它还会污染所有非 HTML 的出口搜索用户搜AB匹配不到库里存的Aamp;B如果搜索时也对关键词转义一次两边都转反而能匹配上但一旦有一方没转就再也搜不到了——这种「两边必须同时转义才能工作」的耦合极其脆弱。API返回给移动端或第三方系统的 JSON 里带着amp;对方不做 HTML 渲染只能看到一串实体。导出CSV、Excel、短信、推送通知里全是quot;。长度与索引一个字符变成lt;四个字符字段长度、唯一索引、去重逻辑的判断全都会偏。所以修复的方向很明确把入口处的转义删掉把转义统一收敛到出口。用下面这个辅助函数作为项目里唯一的输出出口?php declare(strict_types1); // escape.php —— 需要 PHP 8.1 /** * HTML 文本/属性上下文的输出转义。项目里所有输出 HTML 的地方都走这个函数。 * * - ENT_QUOTES单引号和双引号都转义这样它也适用于单引号包裹的属性值 * - ENT_SUBSTITUTE遇到非法 UTF-8 字节时替换成 UFFFD而不是返回空字符串 * - 第三个参数显式传 UTF-8绝不依赖 default_charset */ function e(?string $raw): string { return htmlspecialchars($raw ?? , ENT_QUOTES | ENT_SUBSTITUTE, UTF-8); } // 应用示例 $title bhi/b 你好; echo $title, \n; // 原始值永远保持原样存库用这个 echo e($title), \n; // 输出到 HTML 用这个三个参数都必须显式写出来的理由下一节展开。三、PHP 8.1 改了htmlspecialchars()的默认 flags这是升级到 PHP 8.1 之后最容易被忽视的行为变化之一版本htmlspecialchars($s)的隐含等价写法PHP 8.0 及以前htmlspecialchars($s, ENT_COMPAT, ...)PHP 8.1 起htmlspecialchars($s, ENT_QUOTES \两个变化带来的实际影响ENT_COMPAT→ENT_QUOTES单引号现在也会被转义成#039;。如果你的前端 JS 里用拼接字符串、或者拿服务端输出的值去做字符串比较两边就对不上了。反过来安全性其实是提升的——以前只在双引号属性里安全单引号属性class...是漏洞。新增ENT_SUBSTITUTE遇到非法 UTF-8 字节序列时以前返回空字符串页面上一整段内容凭空消失极难排查现在替换成UFFFD并保留其余内容。这是修复但也意味着以前看起来空的地方现在会出现替换字符需要关注一下数据源编码。结论永远显式写出 flags 和编码不要依赖默认值。这样代码在任何版本上行为一致也省得升级时回头猜。顺带厘清两个容易混的函数htmlspecialchars()只转 这五个字符绝大多数场景用这个。htmlentities()会把所有「有对应 HTML 实体」的字符都转掉中文等非 ASCII 字符在某些编码下也会被转成实体几乎总是用错工具。反向的html_entity_decode($s, ENT_QUOTES | ENT_HTML5, UTF-8)用来解码而htmlspecialchars_decode()只处理那五个字符处理不了nbsp;、copy;这类命名实体。四、修复历史数据里的双重转义入口转义的代码跑了几年库里已经堆了一批被转义过的数据。修复分两步顺序不能反。第一步把代码里的入口转义删掉、把出口转义加上。先改代码再洗数据——否则洗完的数据会被旧代码再次转义进去白忙一场。第二步清洗历史数据。做法是「只解码一层」html_entity_decode()一次只把实体还原成字符不会对已还原的内容反复解码所以天然就是「解一层」。?php declare(strict_types1); // fix_double_encoded.php —— 需要 PHP 8.1 // 用法: php fix_double_encoded.php --dry-run 先预览确认无误再去掉 --dry-run /** * 启发式判断字符串里出现 HTML 实体就认为它可能是被转义过的。 * * 注意这只是启发式 —— 用户完全可能真的在输入框里打了 amp; 这 5 个字符 * 这种数据会被误判。所以下面必须支持 dry-run并且执行前一定要备份。 */ function looksEncoded(string $s): bool { return preg_match(/(amp|lt|gt|quot|apos|nbsp|#\d|#x[0-9a-f]);/i, $s) 1; } /** 只解码一层 */ function decodeOnce(string $s): string { return html_entity_decode($s, ENT_QUOTES | ENT_HTML5, UTF-8); } // 假设这是从数据库读出来的几行数据 $rows [ [id 1, title 如何在 PHP 中使用 lt;scriptgt; 标签], [id 2, title Aamp;B 公司的接口文档], [id 3, title 正常的标题不含实体], [id 4, title 用户真的手打了 amp; 这五个字符], // 会被误判 ]; $dryRun in_array(--dry-run, $argv, true); foreach ($rows as $row) { $old $row[title]; if (!looksEncoded($old)) { printf([%d] 跳过不含实体\n, $row[id]); continue; } $new decodeOnce($old); printf([%d] %s\n - %s%s\n, $row[id], $old, $new, $dryRun ? 仅预览 : ); if (!$dryRun) { // 真实环境里替换成准备好的 UPDATE 语句并放在事务里按主键批量执行 // UPDATE articles SET title :title WHERE id :id } } echo $dryRun ? \n预览模式没有写入任何数据\n : \n已写入\n;输出里的第 4 行就是误判案例——脚本会把它也解成用户真的手打了 这五个字符。这是这套方案无法彻底解决的问题只能靠抽样人工核对、或者对已知数据源做白名单来降低风险。这也从侧面说明了入口转义为什么是个坏主意它生产了一批无法与用户真实输入区分开的数据。常见坑点1. 在入口处对$_POST做htmlspecialchars()再入库❌$data[title] htmlspecialchars($_POST[title]);然后存库——HTML 渲染细节被写进了数据库。 ✅ 原样入库只在拼进 HTML 的那一刻调用e()。这个规则只有一条例外明确要存富文本 HTML 时用白名单 HTML 过滤器如 HTMLPurifier 这类库而不是htmlspecialchars()。2. 以为htmlspecialchars()是安全万能的❌ 把 URL 放进href时也只用e()a href? e($url) ?——e()不会拦下javascript:alert(1)因为它只转义那五个字符冒号不在其中。 ✅ 按上下文选择转义方式HTML 文本/属性用htmlspecialchars()URL 参数用rawurlencode()整条 URL 还要校验协议白名单只允许http/https要嵌进script的数据用json_encode($v, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)。3. 不指定编码依赖default_charset❌htmlspecialchars($s)不给第三个参数。default_charset从 PHP 5.6 起默认是 UTF-8但老项目的 ini 里可能还留着别的值一旦不是 UTF-8中文会被当成非法序列处理。 ✅ 永远写全三个参数htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, UTF-8)。注意ENT_SUBSTITUTE是 PHP 5.4 引入的配合它才能避免非法字节导致整段内容变成空字符串。4. 对本来就含 HTML 的字符串再转义一次❌ 模板引擎已经自动转义了代码里又写{{ e($row[html]) }}页面上出现amp;lt;这种双重编码。 ✅ 先确认数据的来源和已经过的处理环节只转一次。如果拿到的确实是一段已经成形的 HTML用htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, UTF-8, false)关闭double_encode——但更推荐的做法是不要在库里存 HTML。5. 无条件stripslashes()处理$_POST❌ 老代码里写惯了$_POST array_map(stripslashes, $_POST)—— 这是在magic_quotes_gpc时代留下的防御。magic_quotes_gpc早在PHP 5.4 就被移除get_magic_quotes_gpc()也在PHP 8.0 被移除现在这个调用只会白白删掉用户真实输入的反斜杠比如 Windows 路径C:\tmp变成C:tmp。 ✅ 把这段代码整个删掉改成按需校验。6. 用strip_tags()当作安全措施❌ 存库前strip_tags($_POST[content])以为这样就安全了。它不处理属性注入img srcx onerror...这类在过滤后仍可能残留危险片段还会无声地破坏用户合法的内容比如代码分享网站里的示例代码。 ✅ 安全靠输出转义保证而不是输入删除。需要保留部分 HTML 时用白名单过滤器并明确允许的标签和属性。7. 在application/json请求里从$_POST取数据❌ 前端用fetch(..., {headers: {Content-Type: application/json}, body: JSON.stringify(data)})提交后端读$_POST[name]却永远是空的。$_POST只对application/x-www-form-urlencoded和multipart/form-data填充。 ✅ 读原始请求体$payload json_decode((string) file_get_contents(php://input), true, 512, JSON_THROW_ON_ERROR);。这条路拿到的同样是没有做过任何 HTML 转义的原始值出口转义照样不能省。8. 忽略参数名的变形❌ 表单字段名叫user.name后端写$_POST[user.name]取不到值。PHP 会把请求里的点号和空格都替换成下划线实际能取到的键是user_name。 ✅ 表单字段名统一用下划线命名需要嵌套结构时用nameuser[name]这种数组语法PHP 会自动组装成$_POST[user][name]。总结阶段正确做法常见错误输入校验格式、长度原样保留在入口htmlspecialchars()存储保存用户输入的原始字符把lt;、amp;写进数据库输出拼进 HTML 那一刻调用e()依赖上游已经转过义转义参数ENT_QUOTES \ENT_SUBSTITUTE 显式UTF-8上下文URL、script用各自的方式所有场景都只用htmlspecialchars()历史数据先改代码再逐层解码 备份顺序颠倒洗完又被转义回去「POST 数据的 HTML 实体编码问题」之所以反复出现是因为它表面上像是个字符串处理技巧实质上是个分层问题$_POST给你的是原始数据HTML 实体是渲染层的表示形式两者不该在数据库里相遇。记住两句话就够了——存进去的是用户打的字吐出来的时候才决定它在 HTML 里长什么样。再补一句htmlspecialchars()的默认行为在 PHP 8.1 变过所以这三个参数永远显式写全。
返回列表