首页
/ PHP 输入过滤器:基于 SAPI 钩子实现全站安全策略的完整指南(php-src)

PHP 输入过滤器:基于 SAPI 钩子实现全站安全策略的完整指南(php-src)

2026-09-05 13:43:33作者:邬祺芯Juliet

本文基于 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 等超全局变量之前,插入一个可定制的检查/清洗环节。文档给出两条路线:

  1. 使用标准实现 ext/filter:对绝大多数开发者足够,它提供 filter_input()filter_var()filter_var_array()filter_input_array() 等面向 PHP 层的验证/过滤函数族;
  2. 编写自定义扩展:实现 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));

从实现可以看出两条硬性约束:

  1. 时机约束if (SG(sapi_started) && EG(current_execute_data)) return FAILURE;——只能在请求尚未启动时(即扩展的 PHP_MINIT_FUNCTION 阶段)注册,请求运行中途注册会被拒绝;
  2. 单槽位约束:它直接覆盖 sapi_module.input_filtersapi_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 中共有四处调用点:

  1. 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 ..." 警告并停止解析。

  2. application/x-www-form-urlencoded 表单main/php_variables.cadd_post_var() 第 381-383 行,对每个 key=value 对(已解码)调用 sapi_module.input_filter(PARSE_POST, ...),返回 1 才注册;外层 add_post_vars() 同样检查 max_input_vars

  3. multipart/form-data 表单变量main/rfc1867.c 第 868 行,multipart 解析器对普通表单字段(非文件字段)逐个调用 sapi_module.input_filter(PARSE_POST, param, &value, value_len, &new_val_len);文件上传字段不走此回调。

  4. 字符串数据(PARSE_STRINGmain/SAPI.csapi_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 做了四件事:
    1. arg 惰性创建对应来源的私有数组(ALLOC_ZVAL + array_init);
    2. 把原始值 *val 深拷贝为 new_var,以 RAW_ + 变量名的形式通过 php_register_variable_ex() 存入私有数组;
    3. 原地调用 php_strip_tags() 清洗 *val,并更新 *new_val_len 为清洗后长度;
    4. 返回 1,让引擎把清洗后的值照常注册进 $_GET/$_POST/$_COOKIE
  • my_get_raw():按来源常量 + 变量名从私有数组取回未清洗的原值;查不到返回 false

4.2 阅读示例时的三点提醒

结合当前仓库(PHP 8.x)的 API 现状,移植该示例时有三点需要注意:

  1. zval API 已演进:示例使用 ALLOC_ZVAL/INIT_PZVAL/zval **tmp/zval_copy_ctor 等旧式接口,文档中“从源码结构看”它面向的是较早版本。在当前 php-src 上编写新扩展,应改用单指针 zval *ZVAL_* 宏,并以仓库提供的 ext/skeleton 模板为起点,或直接用 ext_skel.php 生成骨架;
  2. 示例中的两处笔误my_get_raw()PARSE_COOKIE 分支取的是 post_array(应为 cookie_array);且 zend_hash_find(..., var_len+5, ...) 的键长应为 var_len——因为存入私有数组的键已经是完整的 RAW_ 前缀名,而 my_get_raw 的语义是“传原名取原值”;
  3. 私有数组只挂了第一个变量:示例中 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.defaultfilter.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 == NULLPARSE_STRING 路径,并在 RSHUTDOWN 中释放私有状态;
  • 优先评估 ext/filter 是否已满足需求;只有需要站点级强制策略或私有数据通道时,才按第 4 节的骨架实现自定义扩展。
登录后查看全文
热门项目推荐
相关项目推荐