PHP 输入过滤器:基于 SAPI 钩子实现全站安全策略的完整指南(php-src)
本文基于 php-src 仓库中的 docs-old/input-filter.md,系统讲解 PHP 输入过滤器(Input Filter)的设计目标、SAPI 钩子的注册与调用机制,以及如何编写一个自定义的输入过滤扩展。读完后,你将掌握 sapi_register_input_filter() 与 SAPI_INPUT_FILTER_FUNC() 的完整用法,理解过滤器在 GET/POST/Cookie/环境字符串四条数据链路上的确切调用时机与参数语义,并能对照 ext/filter 标准实现与 main/php_variables.c 源码,为自己的站点落地一套“原始值留底 + 清洗值注册”的企业级输入安全方案。
1. 为什么需要输入过滤器:XSS 与统一安全策略
XSS(跨站脚本)攻击越来越普遍,且往往很难彻底防范。文档开宗明义:只要你接收用户数据并最终以某种形式把它展示回给其他用户,就可能暴露于 XSS 攻击之下。
PHP 的输入过滤器支持(Input Filter support)的目标,是为公司级或站点级的统一安全策略提供一个可强制执行的框架——即在用户数据从 HTTP 层进入 $_GET、$_POST、$_COOKIE 等超全局变量之前,插入一个可定制的检查/清洗环节。文档给出两条路线:
- 使用标准实现
ext/filter:对绝大多数开发者足够,它提供filter_input()、filter_var()、filter_var_array()、filter_input_array()等面向 PHP 层的验证/过滤函数族; - 编写自定义扩展:实现 SAPI 输入过滤器钩子,把任意安全策略(白名单校验、敏感词过滤、数据留底、审计日志等)下沉到 C 层,在每个变量注册进超全局数组之前拦截。
从源码看,ext/filter 正是路线 1 的落地方式:它在模块 MINIT 阶段调用 sapi_register_input_filter(php_sapi_filter, php_sapi_filter_init)(见 ext/filter/filter.c 第 163 行),把自己的 SAPI 过滤回调挂到 SAPI 模块上,从而获得在数据注册前介入的能力。
2. SAPI 钩子的核心 API:签名、注册与生命周期
2.1 钩子函数签名
所有输入过滤器回调都由 main/SAPI.h 中的宏统一声明(第 316 行):
#define SAPI_INPUT_FILTER_FUNC(input_filter) \
unsigned int input_filter(int arg, const char *var, char **val, size_t val_len, size_t *new_val_len)
各参数的确切语义,结合 main/php_variables.c 中默认实现 php_default_input_filter(第 453-458 行)可以看得很清楚:
SAPI_API SAPI_INPUT_FILTER_FUNC(php_default_input_filter)
{
/* TODO: check .ini setting here and apply user-defined input filter */
if(new_val_len) *new_val_len = val_len;
return 1;
}
| 参数 | 含义 |
|---|---|
int arg |
数据来源,取值为 PARSE_GET / PARSE_POST / PARSE_COOKIE / PARSE_STRING 之一,定义于 main/php_variables.h(第 22-25 行,分别为 1/0/2/3) |
const char *var |
变量名(已完成 URL 解码) |
char **val |
指向“指向变量值缓冲区”的指针,回调可直接原地改写内容 |
size_t val_len |
当前值的长度 |
size_t *new_val_len |
输出参数:回调修改后的新长度。注意:当 arg == PARSE_STRING 时该指针可能为 NULL(见下文 SAPI.c 的调用),因此写前必须判空 |
返回值语义(文档明确约定):
- 返回 1:PHP 照常把(你修改后的)变量注册进对应的超全局数组;
- 返回 0:PHP 放弃注册该变量——适用于你已把变量以别的名字注册过(例如注册到自定义数组或带
RAW_前缀的原始副本),或者干脆决定丢弃该变量(比如检测到恶意载荷)。
默认实现 php_default_input_filter 什么都不改、原样返回 1,说明“无自定义过滤器”时数据是透传的——这也是为什么钩子机制不会影响未定制环境的既有行为。
2.2 注册入口与约束
注册通过 main/SAPI.h 第 216 行声明、main/SAPI.c 第 979-987 行实现的 API 完成:
SAPI_API zend_result sapi_register_input_filter(
unsigned int (*input_filter)(int arg, const char *var, char **val, size_t val_len, size_t *new_val_len),
unsigned int (*input_filter_init)(void));
从实现可以看出两条硬性约束:
- 时机约束:
if (SG(sapi_started) && EG(current_execute_data)) return FAILURE;——只能在请求尚未启动时(即扩展的PHP_MINIT_FUNCTION阶段)注册,请求运行中途注册会被拒绝; - 单槽位约束:它直接覆盖
sapi_module.input_filter与sapi_module.input_filter_init两个函数指针(第 984-985 行),即同一进程中后注册的扩展会顶掉先注册的过滤器。这意味着“输入过滤器”是请求级单例钩子,适合由安全团队统一管理,而不是多扩展各自叠加。
第二个参数 input_filter_init 是一个可选的请求级初始化回调,会在每次请求初始化阶段被引擎调用(见 SAPI.c 第 392-393 行与第 454-455 行的两处 if (sapi_module.input_filter_init) sapi_module.input_init(); 调用点),适合做每请求状态的重置。
3. 引擎在哪些位置调用过滤器:四条调用链
理解回调参数语义的前提,是搞清楚引擎在什么时机、以什么参数调用它。php-src 中共有四处调用点:
-
GET / Cookie / 环境字符串(
treat_data路径):php_default_treat_data()在 main/php_variables.c(第 460-588 行)中按分隔符切分原始查询串/Cookie 头,先对值执行php_url_decode()(Cookie 用php_raw_url_decode()),随后在第 577-579 行:if (sapi_module.input_filter(arg, var, &val, val_len, &new_val_len)) { php_register_variable_safe(var, val, new_val_len, &array); }注意:回调拿到的是已 URL 解码的变量名与值;返回值 0 时变量直接不注册。该路径同时受
max_input_vars(第 553-557 行)约束,超限会发出 "Input variables exceeded ..." 警告并停止解析。 -
application/x-www-form-urlencoded表单:main/php_variables.c 的add_post_var()第 381-383 行,对每个key=value对(已解码)调用sapi_module.input_filter(PARSE_POST, ...),返回 1 才注册;外层add_post_vars()同样检查max_input_vars。 -
multipart/form-data表单变量:main/rfc1867.c 第 868 行,multipart 解析器对普通表单字段(非文件字段)逐个调用sapi_module.input_filter(PARSE_POST, param, &value, value_len, &new_val_len);文件上传字段不走此回调。 -
字符串数据(
PARSE_STRING):main/SAPI.c 的sapi_getenv()第 1033-1035 行——每次获取环境变量值时若过滤器已注册,会以PARSE_STRING调用一次,且传入的new_val_len为 NULL:if (sapi_module.input_filter) { sapi_module.input_filter(PARSE_STRING, name, &value, strlen(value), NULL); }这正是过滤器实现中必须
if (new_val_len) *new_val_len = ...判空的原因。
另外,标准入口 php_content_types.c 在初始化时会注册默认的透传过滤器(sapi_register_input_filter(php_default_input_filter, NULL),见 main/php_content_types.c 第 44 行),自定义扩展在 MINIT 中再次注册即可覆盖它。
4. 完整示例:留底原始值 + strip_tags 清洗
下面完整继承 input-filter.md 中的参考实现。该示例的策略是:$_GET/$_POST/$_COOKIE 只保存经过 strip_tags() 清洗后的数据,同时把未处理的原始值存入扩展自己维护的私有数组,并提供 my_get_raw() 函数供受信任的代码取回原值。
ZEND_BEGIN_MODULE_GLOBALS(my_input_filter)
zval *post_array;
zval *get_array;
zval *cookie_array;
ZEND_END_MODULE_GLOBALS(my_input_filter)
#ifdef ZTS
#define IF_G(v) TSRMG(my_input_filter_globals_id, zend_my_input_filter_globals *, v)
#else
#define IF_G(v) (my_input_filter_globals.v)
#endif
ZEND_DECLARE_MODULE_GLOBALS(my_input_filter)
zend_function_entry my_input_filter_functions[] = {
PHP_FE(my_get_raw, NULL)
{NULL, NULL, NULL}
};
zend_module_entry my_input_filter_module_entry = {
STANDARD_MODULE_HEADER,
"my_input_filter",
my_input_filter_functions,
PHP_MINIT(my_input_filter),
PHP_MSHUTDOWN(my_input_filter),
NULL,
PHP_RSHUTDOWN(my_input_filter),
PHP_MINFO(my_input_filter),
"0.1",
STANDARD_MODULE_PROPERTIES
};
PHP_MINIT_FUNCTION(my_input_filter)
{
ZEND_INIT_MODULE_GLOBALS(my_input_filter, php_my_input_filter_init_globals, NULL);
REGISTER_LONG_CONSTANT("POST", PARSE_POST, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("GET", PARSE_GET, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("COOKIE", PARSE_COOKIE, CONST_PERSISTENT);
sapi_register_input_filter(my_sapi_input_filter);
return SUCCESS;
}
PHP_RSHUTDOWN_FUNCTION(my_input_filter)
{
if(IF_G(get_array)) {
zval_ptr_dtor(&IF_G(get_array));
IF_G(get_array) = NULL;
}
if(IF_G(post_array)) {
zval_ptr_dtor(&IF_G(post_array));
IF_G(post_array) = NULL;
}
if(IF_G(cookie_array)) {
zval_ptr_dtor(&IF_G(cookie_array));
IF_G(cookie_array) = NULL;
}
return SUCCESS;
}
PHP_MINFO_FUNCTION(my_input_filter)
{
php_info_print_table_start();
php_info_print_table_row( 2, "My Input Filter Support", "enabled" );
php_info_print_table_end();
}
/* The filter handler. If you return 1 from it, then PHP also registers the
* (modified) variable. Returning 0 prevents PHP from registering the variable;
* you can use this if your filter already registers the variable under a
* different name, or if you just don't want the variable registered at all. */
SAPI_INPUT_FILTER_FUNC(my_sapi_input_filter)
{
zval new_var;
zval *array_ptr = NULL;
char *raw_var;
int var_len;
assert(*val != NULL);
switch(arg) {
case PARSE_GET:
if(!IF_G(get_array)) {
ALLOC_ZVAL(array_ptr);
array_init(array_ptr);
INIT_PZVAL(array_ptr);
}
IF_G(get_array) = array_ptr;
break;
case PARSE_POST:
if(!IF_G(post_array)) {
ALLOC_ZVAL(array_ptr);
array_init(array_ptr);
INIT_PZVAL(array_ptr);
}
IF_G(post_array) = array_ptr;
break;
case PARSE_COOKIE:
if(!IF_G(cookie_array)) {
ALLOC_ZVAL(array_ptr);
array_init(array_ptr);
INIT_PZVAL(array_ptr);
}
IF_G(cookie_array) = array_ptr;
break;
}
Z_STRLEN(new_var) = val_len;
Z_STRVAL(new_var) = estrndup(*val, val_len);
Z_TYPE(new_var) = IS_STRING;
var_len = strlen(var);
raw_var = emalloc(var_len+5); /* RAW_ and a \0 */
strcpy(raw_var, "RAW_");
strlcat(raw_var,var,var_len+5);
php_register_variable_ex(raw_var, &new_var, array_ptr);
php_strip_tags(*val, val_len, NULL, NULL, 0);
*new_val_len = strlen(*val);
return 1;
}
PHP_FUNCTION(my_get_raw)
{
long arg;
char *var;
int var_len;
zval **tmp;
zval *array_ptr = NULL;
if(zend_parse_parameters(2, "ls", &arg, &var, &var_len) == FAILURE) {
return;
}
switch(arg) {
case PARSE_GET:
array_ptr = IF_G(get_array);
break;
case PARSE_POST:
array_ptr = IF_G(post_array);
break;
case PARSE_COOKIE:
array_ptr = IF_G(post_array);
break;
}
if(!array_ptr) {
RETURN_FALSE;
}
if(zend_hash_find(HASH_OF(array_ptr), var, var_len+5, (void **)&tmp) == SUCCESS) {
*return_value = **tmp;
zval_copy_ctor(return_value);
} else {
RETVAL_FALSE;
}
}
4.1 逐段解读
- 模块全局与线程安全:
ZEND_BEGIN_MODULE_GLOBALS/IF_G()宏处理了 ZTS(多线程)与 NTS 两种构建下全局变量的访问差异;私有数组get_array/post_array/cookie_array用于承载RAW_原始副本。 - MINIT 注册流程:先
ZEND_INIT_MODULE_GLOBALS()初始化全局,再把PARSE_POST/GET/COOKIE暴露为 PHP 常量(这样 PHP 侧可以写my_get_raw(GET, 'name')而不是魔法数字 0/1/2),最后调用sapi_register_input_filter(my_sapi_input_filter)挂上钩子——必须在 MINIT 中完成,理由见第 2.2 节的时机约束。 - RSHUTDOWN 清理:每请求结束时释放三个私有数组,防止跨请求泄漏。
- 过滤器主体
my_sapi_input_filter做了四件事:- 按
arg惰性创建对应来源的私有数组(ALLOC_ZVAL+array_init); - 把原始值
*val深拷贝为new_var,以RAW_+ 变量名的形式通过php_register_variable_ex()存入私有数组; - 原地调用
php_strip_tags()清洗*val,并更新*new_val_len为清洗后长度; - 返回 1,让引擎把清洗后的值照常注册进
$_GET/$_POST/$_COOKIE。
- 按
my_get_raw():按来源常量 + 变量名从私有数组取回未清洗的原值;查不到返回false。
4.2 阅读示例时的三点提醒
结合当前仓库(PHP 8.x)的 API 现状,移植该示例时有三点需要注意:
- zval API 已演进:示例使用
ALLOC_ZVAL/INIT_PZVAL/zval **tmp/zval_copy_ctor等旧式接口,文档中“从源码结构看”它面向的是较早版本。在当前 php-src 上编写新扩展,应改用单指针zval *与ZVAL_*宏,并以仓库提供的 ext/skeleton 模板为起点,或直接用ext_skel.php生成骨架; - 示例中的两处笔误:
my_get_raw()的PARSE_COOKIE分支取的是post_array(应为cookie_array);且zend_hash_find(..., var_len+5, ...)的键长应为var_len——因为存入私有数组的键已经是完整的RAW_前缀名,而my_get_raw的语义是“传原名取原值”; - 私有数组只挂了第一个变量:示例中
array_ptr仅在该来源首次回调时非 NULL,后续变量的php_register_variable_ex()传入NULL数组即注册到全局符号表。若需要严格隔离所有RAW_副本,应确保每次回调都把当前私有数组指针传入。这些细节不影响理解钩子机制,但照抄到生产代码前需修正。
5. 与 ext/filter 标准实现的关系与分工
对于“只想按字段做校验/过滤”的场景,不需要自己写 C 扩展:ext/filter 在 PHP 层提供 filter_input(常量, 变量名, FILTER_*操作)、filter_var()、filter_var_array()、filter_input_array() 等函数(如 ext/filter/filter.c 中的 filter_input_array 实现),支持 FILTER_SANITIZE_*/FILTER_VALIDATE_* 一整套内置过滤器与验证器,配合 filter.default、filter.flags 两个 INI 项使用,覆盖绝大多数常规需求。
而本文的 SAPI 钩子方案解决的是 ext/filter 无法覆盖的问题:在变量进入超全局数组之前、对每一条输入无差别地统一处置——例如强制全站脱敏、把原始值转入只有受信任代码可访问的私有副本、命中黑名单时直接丢弃变量(返回 0)、或对接企业级审计系统。两者是互补关系:ext/filter 本身也是通过 sapi_register_input_filter()(ext/filter/filter.c 第 163 行)接入同一钩子位的,这从侧面印证了该钩子是 PHP 输入处理管线的统一介入点。
6. 小结与适用边界
- 输入过滤器是 SAPI 层的请求级单例钩子,注册于扩展 MINIT,覆盖 GET、Cookie、两类表单(urlencoded 与 multipart 字段)以及
sapi_getenv()的字符串数据四条链路,对应源码调用点分别位于 main/php_variables.c(第 577-579 行)、main/php_variables.c(第 381-383 行)、main/rfc1867.c(第 868 行)与 main/SAPI.c(第 1033-1035 行); - 回调可原地修改值、可借
new_val_len报告新长度、可用返回值 0 阻止变量注册,三者组合出“清洗、留底、丢弃”三种基本策略; - 编写自定义过滤器扩展时,务必以 MINIT 时机注册、处理好
new_val_len == NULL的PARSE_STRING路径,并在 RSHUTDOWN 中释放私有状态; - 优先评估
ext/filter是否已满足需求;只有需要站点级强制策略或私有数据通道时,才按第 4 节的骨架实现自定义扩展。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00