跨租户SQL逻辑注入本质是租户上下文丢失或污染,而非单纯SQL注入问题;tenant_id必须从可信中间件上下文提取并统一注入,严禁来自未校验的$_GET、$_HEADER或JWT字段,所有查询须显式绑定tenant_id参数且由框架自动注入。

PHP多租户系统中跨租户SQL逻辑注入,本质不是“防住了SQL注入”就能解决的问题——它发生在应用层租户上下文丢失或被污染时,哪怕所有查询都用了PDO预处理,照样越权读取其他租户数据。
tenant_id 必须从可信上下文提取,绝不能来自 $_GET、$_HEADER 或 JWT payload 的未校验字段
常见错误是直接取 X-Tenant-ID 头或 $_GET['tenant_id'] 拼进 WHERE 条件,攻击者伪造该值即可切换租户上下文。JWT 场景下,必须先校验签名有效性,再从 payload 提取 tenant_id,且需与请求头中的 X-Tenant-ID 严格比对(防 token 复用)。
- 正确做法:在中间件里完成认证后,把
tenant_id存入$_SERVER['TENANT_ID']或全局$app->tenantId,后续所有 DAO 层只从此处读取 - 禁止在 Model 或 Repository 构造函数里接受
$tenantId参数并缓存——这会让异步任务或 CLI 脚本误用上一个请求的值 - 用
filter_var($id, FILTER_VALIDATE_UUID)校验格式,但不代替上下文可信性判断
所有 SQL 查询必须显式包含 tenant_id = ?,且参数由框架统一注入
MyBatis 或 Laravel Eloquent 的全局 scope 很容易被 whereRaw()、DB::select() 或原生查询绕过。PHP 中最危险的是 PDO::query() 和 mysqli_query() 直接执行字符串。
- 禁用
DB::raw("tenant_id = '{$_GET['tid']}'")这类写法,哪怕加了转义也不行 - 所有
WHERE子句必须含AND tenant_id = ?,且该参数由中间件/服务容器自动绑定,DAO 方法签名里不暴露$tenantId参数 - 使用
PDO::prepare()时,确保tenant_id是第 2 个及以上参数,避免和业务参数顺序混淆导致漏绑 - 检查日志:在慢查询日志或 DB 驱动 hook 中 grep
WHERE.*tenant_id,没匹配到就是漏了
Schema 切换(如 PostgreSQL SET search_path)必须配合 RLS + GUC 权限控制
PHP 里用 PDO::exec("SET search_path TO tenant_a") 后,若后续语句没写 tenant_a.users 前缀,攻击者在 ORDER BY 注入 ; SET search_path TO tenant_b 就能污染整个连接。
立即学习“PHP免费学习笔记(深入)”;
- 数据库角色必须
REVOKE SET ON PARAMETER app.tenant_id FROM app_user,禁用客户端任意设置会话变量 - RLS 策略中避免直接用
current_setting('app.tenant_id'),改用 LEAKPROOF 函数封装,例如get_current_tenant_id()内部做 UUID 校验和类型强制转换 - PHP 连接池(如 Swoole MySQL 协程客户端)必须在每次
connect()后重置search_path,不能依赖上一次连接状态
异步任务、CLI 脚本和定时器必须显式传递 tenant_id
PHP-FPM 请求生命周期结束后,$_SERVER 或全局变量里的 tenant_id 就失效了。Swoole Worker、Gearman Job 或 php artisan schedule:run 默认不继承任何租户上下文。
- 所有
dispatch(new ProcessTenantData($tenantId))类任务,$tenantId必须作为构造参数传入,不能靠闭包捕获或从配置读取 - CLI 命令加
--tenant-id=xxx强制参数,无此参数则拒绝执行 - Logstash 或 Monolog 的 MDC(如
Logger::setExtra('tenant_id', $id))也要同步注入,否则审计日志无法反查越权源头
真正卡住的地方,从来不是“有没有写 WHERE tenant_id”,而是每次 PDO::execute() 调用前,那个 tenant_id 值是否还活着、是否被污染、是否在当前线程/协程/进程里真实有效。



















