多租户系统不增加sql注入概率,但注入后可绕过租户隔离扫全库;根本风险在于tenant_id过滤非强制——手动拼接、空值失效、缓存/异步/视图/原生sql等路径均可绕过,唯rls或数据库级强制策略能兜底。

多租户系统本身不增加SQL注入概率,但一旦发生注入,后果更严重——因为单条恶意语句可能绕过租户隔离,直接扫出全库数据。关键不在“更容易被注入”,而在“注入后更难被拦截”。
租户ID过滤逻辑被SQL注入绕过的典型路径
应用层手动拼接 tenant_id 条件时,若没走参数化,就等于给攻击者开了后门:
- 开发者写
"WHERE tenant_id = '" + tenantId + "' AND status = 'PAID'",攻击者传入tenant_01' OR '1'='1,结果变成WHERE tenant_id = 'tenant_01' OR '1'='1' AND status = 'PAID'—— 全表扫描触发 - MyBatis 的
<if test="tenantId != null">AND tenant_id = #{tenantId}</if>中,#{tenantId}虽安全,但若上层调用时传入了空或默认值(如"%"或"1=1"),条件直接失效 - 使用
@Query("SELECT * FROM orders WHERE status = :status")这类 JPQL,根本没提tenant_id,框架不会自动补,租户上下文形同虚设
为什么 WHERE tenant_id = ? 本身还不够安全
参数化只是基础,真正危险的是“条件是否强制存在”。以下情况都会让 tenant_id = ? 失效:
- 查询走缓存未校验租户:Redis 缓存键是
orders:status:PAID,没带tenant_id前缀,A 租户查到 B 租户缓存的数据 - 子线程丢失上下文:主线程设了
ThreadLocal<string> CURRENT_TENANT</string>,但CompletableFuture.supplyAsync()启的线程没继承,tenant_id变成null,最终生成的 SQL 没WHERE条件 - 数据库视图里硬编码
CURRENT_SETTING('app.tenant_id'):PostgreSQL 视图编译时就固化表达式,运行时不重求值,CURRENT_SETTING返回空或报错,导致过滤被跳过
强制绑定必须在数据库执行前完成
靠 ORM 层钩子(如 GORM BeforeFind)或拦截器,仍属应用层,可被自定义 @Query、原生 SQL、存储过程绕过。真正兜底的方式,是让数据库自己拒绝非法查询:
- PostgreSQL 启用 RLS(Row Level Security):
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.tenant_id', true)::uuid); - MySQL 无原生 RLS,需改用
SECURITY DEFINER函数封装查询,视图只调函数,不写WHERE;函数内用CONCAT拼接并预编译,避免用户输入进 SQL 字符串 - 所有连接池(如 HikariCP、PgBouncer)必须开启
connection-init-sql或server_reset_query,确保每次取连接时执行SET app.tenant_id = 'xxx';
最容易被忽略的验证点:你真的在每条 SQL 执行前检查了 tenant_id 吗
很多团队只测了主流程,漏掉了这些死角:
- 定时任务(如 Quartz Job)跑在独立线程,
ThreadLocal未初始化,tenant_id是空字符串,WHERE tenant_id = ''匹配不到任何行——但有些数据库(如旧版 MySQL)会把空字符串转成0,反而匹配到tenant_id = 0的测试数据 - 审计日志表(
audit_log)常被豁免租户过滤,因为它要跨租户查,但若没单独建 schema 或用独立连接池,就可能被反向推导出其他租户行为 - 数据库备份脚本、ETL 工具直连生产库,绕过所有应用层过滤逻辑,如果备份账号有
SELECT ANY TABLE权限,租户隔离彻底失效
真正的强制绑定不是“写了 tenant_id”,而是“没有 tenant_id 就不让这条 SQL 执行”。这要求从连接初始化、SQL 解析、执行拦截到权限控制,全部环节咬合,缺一不可。










