在PL/pgSQL里写动态SQL,很多开发者最先想到的防护就是 quote_ident()。但如果你觉得只要套上这个函数就万事大吉,那可真是踩进了一个大坑。
先说结论:动态拼接表名或字段名时,quote_ident() 只能算是最低限度的防御,绝对不够用。它只负责把你传进来的字符串用双引号包起来,再把里面的反斜杠转义一下。但问题是,它从来不问“你这字符串到底是不是一个合法的标识符”。来看一个典型例子:你传进来一个 'users; DROP TABLE accounts; --',quote_ident() 会把它转换成 "users; DROP TABLE accounts; --"。你看,双引号是包上了,可这句话放到 EXECUTE 'SELECT * FROM ' || quote_ident(user_input) 里,它仍然是语法上合法的标识符——数据库不会报错,也不会拦截后面的恶意操作。等于说,你给了攻击者一本正经构造恶意字符串的通行证。
那真正安全的做法是什么?很简单:先白名单校验,再调 quote_ident()。举个例子,你可以只允许输入由字母、数字、下划线组成,长度不超过64个字符,并且不能以数字开头:
IF user_input !~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$' THEN
RAISE EXCEPTION 'invalid identifier: %', user_input;
END IF;
- 正则白名单必须放在
EXECUTE之前执行,不能指望quote_ident()来兜底。 - 处理表名或字段名时,千万不要用
quote_literal()——它给你加的是单引号,一执行就语法报错。 - 也别直接把用户输入扔进
format()的%I占位符里,除非你已经做了白名单过滤。
动态查询中传参必须用 USING,不能拼进 SQL 字符串
这条坑埋得特别深,也特别容易被忽略。你可能会觉得自己已经用 quote_ident() 安全地处理了表名,WHERE 条件里的值随便拼一下也没事——但就是这“随便拼一下”,可能直接把注入入口暴露给攻击者。
-- 危险!拼接值 = 注入入口 EXECUTE 'SELECT * FROM ' || quote_ident(tbl) || ' WHERE id = ' || user_id;
业内公认的正确写法,是把查询值作为参数传给 EXECUTE ... USING:
EXECUTE 'SELECT * FROM ' || quote_ident(tbl) || ' WHERE id = $1' USING user_id;
USING后面的变量,数据库引擎会当作纯粹的数据绑定,完全脱离 SQL 解析上下文——这等于从根上断了注入的路。- 支持多个参数,比如
USING val1, val2, val3,对应 SQL 里的$1,$2,$3。 - 有一点必须牢记:
USING里不能传表名、字段名、ORDER BY 子句——这些还是得走白名单 +quote_ident()的组合拳。
COPY 命令在函数里绝对禁止拼接路径
这大概是最容易被忽视的高危操作了。在 PL/pgSQL 函数里写 EXECUTE 'COPY users FROM ''' || filename || '''',等于直接给攻击者开了一扇任意文件读取的大门。比如攻击者可以调用 load_csv('/etc/passwd'),直接把系统密码文件泄露出去。
生产环境下,唯一靠谱的方案是:彻底放弃在服务端用 COPY,改为由应用层流式导入(比如 Python 的 cursor.copy_expert())。如果实在绕不开,必须满足以下三个条件:
- 路径必须硬编码,比如
'/var/lib/postgresql/import/users.csv',绝对不要允许任何变量参与路径拼接。 - 数据库角色必须撤销
pg_read_server_files和pg_write_server_files权限。 - 函数本身用
SECURITY DEFINER并限制为只读角色,只对特定目录有访问权限。
为什么不用 format() 的 %L 或 %I 就容易出事
format() 用起来确实方便,但它的占位符行为完全依赖你传入的参数类型和上下文。比如 %L 对空字符串、NULL、特殊字符的处理,远不如 quote_literal() 稳定;%I 虽然等价于 quote_ident(),但它同样不会主动拒绝非法输入——它只是尽力转义,而不是校验。
更麻烦的是,在嵌套调用或复杂表达式里,format() 很容易漏掉某个占位符,导致未格式化的原始输入直接混进最终 SQL。所以建议这样处理:
- 优先拆解逻辑:白名单校验 →
quote_ident()/quote_literal()→EXECUTE ... USING,步步到位。 - 避免在同一个
format()调用里同时处理标识符和值。 - 测试时一定要覆盖边界输入:空字符串、单引号、分号、反斜杠、Unicode 控制字符——一个都不能少。
说到底,写对一行 EXECUTE 并不难,难的是确保函数里的每一条分支路径——IF 分支、异常处理块、循环体里的每一次拼接——都经过相同的安全检查。这才是真正的安全底线。

