表名不能参数化,因数据库驱动仅支持占位符代入值而非标识符;必须用硬编码白名单校验,统一小写比对并加引号防保留字,动态拼接表名、排序字段、JOIN表等SQL骨架部分均需独立白名单防护。

表名拼进 SQL 无法参数化,必须用硬编码白名单校验,任何转义、正则过滤或动态元数据查询都等于没防。
为什么表名不能走参数化查询
数据库驱动(如 PostgreSQL 的 pg、MySQL 的 mysql2、SQLAlchemy)根本不支持把表名作为参数绑定。占位符 $1 或 ? 只能代入值,不能代入标识符(表名、列名、ORDER BY 字段)。你写 SELECT * FROM $1 WHERE id = $2,会直接报错或被当成字符串字面量处理——根本不会解析成表引用。
攻击者只要控制 table 参数为 users; DROP TABLE logs--,后端若直接拼接,就变成两条语句执行;或填 (SELECT password FROM admins) 触发子查询,完全绕过“值级”防护。
白名单必须硬编码且大小写敏感
白名单不能从数据库 information_schema 动态查出所有表名再放行——那等于把 DBA 权限交出去。也不能靠配置中心远程加载可变数组,得是代码里写死的、上线即冻结的集合。
常见错误:MySQL 默认不区分表名大小写,但 PostgreSQL 区分;如果白名单写 "Users" 而用户传 users,校验就失败;反之若白名单全小写,而 DB 实际建表是 OrderHistory,就会误拒合法请求。
实操建议:
- 统一用小写定义白名单(
ALLOWED_TABLES = {"users", "orders", "products"}),再对输入做.lower()后比对 - 若业务强依赖大小写(如某些 legacy 系统),白名单条目必须与 DB 中真实表名完全一致
- 拒绝一切带点号、中括号、引号的输入(如
public.users、[users]),这些不是标准表名,是构造痕迹
导出接口中表名校验的典型实现
以 Flask + raw psycopg2 为例(ORM 如 SQLAlchemy 也无法绕过此限制):
ALLOWED_TABLES = {"users", "orders", "products", "addresses"}
<p>table_param = request.args.get("table", "")
if not table_param or table_param.lower() not in ALLOWED_TABLES:
return jsonify({"error": "invalid table name"}), 400</p><h1>安全拼接</h1><p>sql = f'SELECT * FROM "{table_param.lower()}" LIMIT 1000' # 加双引号防关键字冲突
cur.execute(sql)
注意:"{}" 是 PostgreSQL 标准标识符引号;MySQL 应用反引号 `{}`;SQLite 用方括号 [{}]。不要省略引号——否则遇到 order 这类保留字直接报错。
更稳妥的做法是把引号逻辑封装进校验函数,避免每处手写。
容易被忽略的关联风险点
表名可控往往不是孤立问题。导出功能常连带暴露其他结构参数:排序字段(sort=created_at DESC)、分组字段(group_by=category)、JOIN 表(join=logs)。这些全属于 SQL 骨架,同样无法参数化,必须各自配独立白名单。
比如 sort 白名单不能复用 ALLOWED_TABLES,得另设 ALLOWED_SORT_FIELDS = {"created_at", "updated_at", "score"},且要校验整个值("created_at DESC" 是一个完整 token,不能只拆前面字段)。
最危险的是“动态 JOIN”:若允许 join_table=payments 并拼进 JOIN payments ON ...,那 payments; TRUNCATE users-- 就又成了入口。这种地方白名单漏一条,整套防护就归零。

















