防范jsonb动态查询sql注入的核心是:键名等标识符必须用sql.identifier,值必须参数绑定,二者不可混用;错误拼接如f"data->>'{key}'"会导致注入。

动态拼接 JSONB 字段查询极易引入 SQL 注入,核心防线是:标识符(键名、别名、路径)必须用 sql.Identifier 构造,值必须走参数绑定(%s 或 %(key)s),二者绝不能混用。
JSON 键名是运行时值,不是 SQL 结构
常见错误是把用户传入的键名(如 "user_name")直接拼进 data->'xxx' 里。这等于把不可信输入当 SQL 字面量用,一旦传入 "user_name' OR '1'='1" 就触发注入。
- ✅ 正确:键名走参数化 ——
data->>%(key_name)s,然后execute(..., {"key_name": "user_name"}) - ❌ 错误:字符串拼接 ——
f"data->>'{key_name}'"或"data->>{}".format(key_name) - ⚠️ 注意:
->和->>后面的单引号内容属于 SQL 字符串字面量,必须由数据库驱动按参数规则处理,不能靠 Python 字符串操作“模拟”
别名和路径数组必须用 sql.Identifier 安全包裹
当需要动态生成列别名(如 data->>'email' AS "email")或使用 #>> 路径数组(如 data #>> %(path)s)时,别名和路径本身是 SQL 标识符,需白名单校验 + sql.Identifier 包裹。
- 别名必须只含字母、数字、下划线,且不以数字开头;建议提前过滤:
re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", alias) - 路径数组如
{address, city}是文本,但作为整体是 SQL 结构,应构造为sql.SQL("data #>> ").concat(sql.Identifier(path_str))—— 不,等等,sql.Identifier只适用于单个标识符;路径数组得用sql.Literal配合白名单验证,例如只允许["user", "profile", "theme"]这类固定组合 - 绝对不要用
f"data #>> '{{{','.join(path_parts)}}}'",Unicode 控制字符或嵌套引号可轻易绕过肉眼判断
ORDER BY / GROUP BY 动态字段必须双校验
JSONB 查询常需按提取字段排序,比如 ORDER BY data->>'status'。但 ORDER BY ? 在 PostgreSQL 中不被支持,必须拼结构 —— 这正是最危险的环节。
- 白名单硬编码所有合法组合:
Set.of("data->>'status'", "data->>'created_at' DESC", "data#>>'{stats,hits}'::int DESC") - 正则二次过滤:强制匹配
^data(->>|#>>)(?:\{[^}]+\}|'[a-zA-Z_]+')(\s+(ASC|DESC))?$,拒绝任何空白、括号逃逸、分号 - 禁止拼接后直接执行:
cur.execute(f"SELECT ... ORDER BY {sort_expr}")是高危行为,哪怕你刚用re.sub清过空格
IN 查询和数组操作必须用 ANY() + 参数数组
遇到 {"tags": ["a", "b"]} 并想查 data->'tags' ?| ARRAY['a'] 或做 IN 匹配时,切忌手动拼字符串列表。
- ✅ 正确:
data->'tags' ?| ARRAY[%(tag_list)s],然后传{"tag_list": ["a", "b"]}—— psycopg3 自动转为ARRAY['a','b'] - ❌ 错误:
"data->'tags' ?| ARRAY[" + ",".join(f"'{t}'" for t in tags) + "]",引号逃逸、SQL 注入、类型错乱全发生 - ⚠️ 注意:
?|要求右侧是text[],若传字符串列表,psycopg3 默认会适配;但若用原生 JDBC,需显式setArray(),不能setString()
最易被忽略的一点:所有校验必须在参数绑定前完成。你无法靠 to_jsonb(%(val)s) 拦截恶意键名 —— 它只管值的内容,不管键名是否在 SQL 中被当作结构解析。动态部分一旦进入 SQL 字符串,就不再受类型系统约束。











