ThinkPHP 5 全局 htmlspecialchars 过滤器悄悄吃掉 JSON 参数
后台某个列表接口支持高级过滤:前端把筛选条件序列化成 JSON 字符串,URL encode 后追加到查询参数。本地测试通过,URL 粘贴到浏览器地址栏返回的 JSON 也完全合法——偏偏到了 PHP 这头,json_decode() 始终返回 null,接口静默降级为"返回全量数据",没有任何报错。
最诡异的地方:var_dump 参数值,肉眼一眼扫过去"好像没问题",要仔细盯着才发现双引号全变成了 "。
现象
- 前端:
JSON.stringify(filter)+encodeURIComponent→ 拼入 URL - 接口:
json_decode($this->request->param('filter'), true)→ 返回null json_last_error_msg()报 "Syntax error"- 同一段 JSON 字符串在浏览器 Console、Postman 里
JSON.parse均正常 var_dump打出来的参数值:{"v":1,"g":[...]}
根因
FastAdmin 的基类控制器 application/api/controller/Api.php 在 _initialize() 里注册了全局请求过滤器:
$this->request->filter('trim,strip_tags,htmlspecialchars');这个过滤器在每次 $this->request->param() / get() / post() 被调用时自动执行,把输入值过一遍 htmlspecialchars。正常的 HTML 文本参数(昵称、备注)可以安全地防 XSS,但 JSON 字符串里合法的 " 全被编码成 "——变成了非法 JSON,json_decode 当然拿到 null。
_initialize() 的过滤器不是"可选项",是强制对所有入参执行的。没有异常,没有警告,json_decode 静默返回 null,接口降级为返回全量记录——这是最危险的静默失败:功能看起来"正常",实际上过滤条件完全无效。
同样受影响的值类型:Base64 以外的任何含 " < > & ' 的字段,包括富文本、签名 URL、XML 片段。
正解
方案 A(推荐):在控制器内,读参数后立即用 htmlspecialchars_decode 反转编码,再交给 json_decode。改动范围最小,不影响其他参数的 XSS 防护。
private function parseJsonParam(string $key): ?array
{
$raw = $this->request->param($key, '');
if ($raw === '') {
return null;
}
// _initialize() 的 htmlspecialchars 把 " 变成 ",先解回来
$decoded = htmlspecialchars_decode($raw, ENT_QUOTES); // ENT_QUOTES 同时处理 " 和 '
$data = json_decode($decoded, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \think\Exception('INVALID_JSON_PARAM: ' . json_last_error_msg());
}
return $data;
}注意 ENT_QUOTES 不能省:它同时还原 '(单引号),否则含单引号的 JSON 值仍然损坏。
方案 B:子控制器覆盖 _initialize(),去掉 htmlspecialchars,只保留 trim,strip_tags。适合整个控制器都大量接收 JSON 参数的场景,但需要在输出层(Content-Type: application/json + 视图模板)自行承接 XSS 防护责任。
方案 C:前端改用 btoa 把 JSON Base64 编码后传输,后端 base64_decode 还原。Base64 字符集里没有 " <,过滤器无法损坏。代价是 URL 可读性丧失,调试麻烦。
修复后的完整流程:
public function list(): void
{
$raw = $this->request->param('filter', '');
$decoded = htmlspecialchars_decode($raw, ENT_QUOTES);
$filter = json_decode($decoded, true);
if ($raw !== '' && json_last_error() !== JSON_ERROR_NONE) {
$this->error('INVALID_FILTER');
}
$this->success('ok', $this->service->getList($filter));
}最好封装成 $this->jsonParam($key) 工具方法,所有接收 JSON 参数的端点统一调用,从源头杜绝漏网。
CI 防护
加一行 grep 到 pre-commit 或 CI lint,自动标记危险写法:
grep -rn "json_decode.*request->param" application/ \
| grep -v htmlspecialchars_decode匹配到即报 warning:你在对经过 htmlspecialchars 编码的值调用 json_decode。
一句话外卖
FastAdmin/TP5 的全局请求过滤器在调用栈最底层悄悄改写了参数值,任何直接做
json_decode(request->param(...))的地方都需要先htmlspecialchars_decode——否则过滤条件静默失效,接口返回全量数据,还没有任何报错。