几周前接到一个不算新但相当磨人的任务:把一套历史报表系统里的SQL质量压下来。这套系统是早期架构,SQL基本都在应用层拼,条件、排序字段、分页参数全部用字符串加法一层层垒上去,每天慢查询日志里能捞出一堆上千字符的“巨型面条”,其中一部分还带着注入口子。更麻烦的是,应用层由不同语言、不同团队维护,想靠改代码来统一规范,周期长、扯皮多。
我的选择是在PostgreSQL里做一套“净化SQL”的PL/pgSQL函数库:统一接收动态SQL,去注释、压缩空白、校验表名字段名、对参数做安全转义,再把执行轨迹写进审计表。这样应用层不用大改,数据库层就把丑的、危险的、不可控的SQL全部收拾一遍。这篇文章就是这套函数从设计、实现到排坑的完整记录,代码可以直接抄,坑也帮你踩过了。
1. 项目背景:为什么要把“净化SQL”做进数据库
1.1 老系统里那些越写越离谱的SQL
先看看遗留系统里常见的几种坏味道。最典型的是这种:
String sql = "select * from users where 1=1"; if (name != null) { sql += " and user_name = '" + name + "'"; } if (status != null) { sql += " and status = '" + status + "'"; } sql += " order by " + orderField + " limit " + pageSize;这段代码几乎把所有能犯的错都犯了:参数直接拼字符串,给注入留了门;排序字段不校验,直接拼列名;每次请求生成的SQL文本都不同,数据库无法复用执行计划,只能反复硬解析。我曾见过一个接口峰值2000 QPS,数据库CPU 70%都耗在parse和plan上。
还有一类是动态列名。业务需要根据配置生成不同维度的汇总查询,代码里用循环把列名列表拼进去,完全没有校验列名是否真实存在,更没考虑列名是否含特殊字符。结果就是:某个配置写错一个字母,SQL执行直接报错,前端看到的是500,后端日志里只有一句含糊的“syntax error”。
这些问题的共性在于:SQL的生成完全依赖应用层程序员的自律。而人总会偷懒、会犯错,一旦业务逻辑复杂到一定程度,拼出来的SQL就是不可控的。
1.2 为什么用PL/pgSQL而不是在应用层改
面对这种现状,团队里争论过几次。有人主张把老代码全部改成参数化查询,有人主张引入中间件对SQL做拦截改写。最终我坚持在数据库层用PL/pgSQL做净化函数,理由很务实:
覆盖所有应用层入口。老系统有Java接口、Python脚本、定时任务、临时手工查询,来源五花八门。统一改应用代码工作量巨大,但数据库层只需要把动态SQL执行入口替换成函数,所有来源都能被同一个净化逻辑收口。
数据库是最后一道防线。无论应用层怎么拼SQL,最终都要发到数据库来执行。数据库层做校验和转义,等于在河道的最后一道闸口设卡,前面有多少泥沙都不怕。
PL/pgSQL执行自定义逻辑足够方便。写函数、做循环、处理异常、调用系统表,都是数据库原生的能力,不需要额外部署服务,也不存在跨语言调用开销。
当然,这不是说应用层参数化不重要。恰恰相反,长期的解法是应用层全部改为预编译绑定参数。但在存量系统还没改完之前,数据库层的净化函数是性价比最高的过渡方案。
1.3 需求清单和函数职责边界
项目一开始,我先列了一份需求清单,明确这套净化库要做什么、不做什么。
| 功能模块 | 对应函数 | 解决的问题 |
|---|---|---|
| 文本净化 | sanitize_code | 去注释、压缩空白、清理不可见字符 |
| 标识符校验 | normalize_identifier | 白名单校验表名、列名,防结构注入 |
| 参数安全转义 | safe_build_sql | 用quote_literal处理动态参数值 |
| 动态SQL执行封装 | safe_execute_query、safe_execute_table | 统一SQL执行入口,强制走净化流程 |
| 审计与追踪 | write_audit_log、query_audit_log表 | 记录净化后的SQL和执行耗时,便于复盘 |
边界也写得很清楚:净化函数不替代业务改写,不解析完整语法树,不做查询优化器的活。它只负责把SQL文本里危险的、脏的、不可控的部分处理成一个相对安全、规范、可审计的形式。理解这个边界很重要,否则容易把函数设计成一个大而全的“SQL解释器”,那就掉进另一个坑了。
2. 总体设计:一条净化管线跑通所有SQL
2.1 五段式净化管线
我把整个净化过程拆成五个阶段,每个阶段都是独立函数,便于单测和排查。
- 接收阶段:函数拿到原始SQL文本,统一按text类型处理。
- 文本净化阶段:去掉行注释、块注释,把注释替换为单个空格,压缩引号外的连续空白。
- 长度控制阶段:对超长SQL做截断或拒绝,防止异常输入拖垮状态机。
- 结构校验阶段:对动态出现的表名、列名做白名单校验,拒绝特殊字符标识符。
- 执行与审计阶段:把净化后的SQL交给EXECUTE执行,同时记录到审计表。
顺序是有讲究的。必须先做文本净化再做结构校验,原因很简单:如果SQL里有注释,恶意内容可能藏在注释里绕过后续的文本检查。比如select * from users where id = 1 /* comment */ or 1=1,先把注释清掉,后面的结构才能看清楚。
2.2 为什么用状态机而不是正则做去注释
写去注释功能时,最容易想到的方案是正则表达式。我最初也试过两条正则:
-- 去行注释 regexp_replace(raw_sql, '--[^\n]*', '', 'g'); -- 去块注释 regexp_replace(raw_sql, '/\*.*?\*/', '', 'gs');看起来能用,但上线前就翻车了。问题出在SQL字符串字面量上。看这个输入:
select 'a--b' as str, '/* not comment */' as str2这两条正则会把单引号内部的--和/* */也删掉,结果SQL语义完全变了。更麻烦的是,SQL里还有转义引号''、双引号标识符"some""col"、E字符串等一堆边界情况。正则不是不能写,但针对SQL这种带状态的语言,写正确状态解析的正则复杂度极高,而且一不小心就会出现灾难性回溯,性能完全不可控。
所以最终我直接用PL/pgSQL写了一个轻量状态机,逐字符扫描一次,维护四个状态:是否在单引号字符串内、是否在双引号标识符内、是否在行注释内、是否在块注释内。这个方案是严格线性扫描,最坏情况O(n)且不可能回溯,性能和安全性都可控。
2.3 幂等设计与可观测性
净化函数必须是幂等的:同一个SQL文本无论净化多少次,结果都不变。这个要求不是洁癖,而是回归测试的基础。只有确定“净化一次”和“净化两次”结果相同,才能在测试里断言输出。
可观测性同样重要。每一个执行入口都至少输出一条NOTICE日志,记录净化前的原始SQL和净化后的SQL。生产环境可以关掉NOTICE,但排查问题时打开,能清晰看到SQL在哪个环节被改成了什么样。我见过太多系统出了SQL问题后,谁都不知道数据库里实际执行的最终语句是什么,有了这层日志,至少能追溯。
3. 核心实现:整套函数库的代码与关键点
3.1 文本净化函数:sanitize_code
这是整套库的地基。完整代码实现如下:
CREATE OR REPLACE FUNCTION sanitize_code(p_sql text) RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE i int := 1; n int := length(p_sql); c text; next_c text; buf text[] := '{}'::text[]; in_single boolean := false; in_double boolean := false; BEGIN IF p_sql IS NULL OR p_sql = '' THEN RETURN p_sql; END IF; WHILE i <= n LOOP c := substr(p_sql, i, 1); next_c := CASE WHEN i < n THEN substr(p_sql, i + 1, 1) ELSE '' END; -- 行注释:不在字符串内时,-- 到行尾全部替换为空格 IF NOT in_single AND NOT in_double AND c = '-' AND next_c = '-' THEN buf := array_append(buf, ' '); i := i + 2; WHILE i <= n AND substr(p_sql, i, 1) <> E'\n' LOOP i := i + 1; END LOOP; CONTINUE; END IF; -- 块注释:不在字符串内时,/* ... */ 整体替换为空格 IF NOT in_single AND NOT in_double AND c = '/' AND next_c = '*' THEN buf := array_append(buf, ' '); i := i + 2; WHILE i <= n AND NOT (substr(p_sql, i, 1) = '*' AND substr(p_sql, i + 1, 1) = '/') LOOP i := i + 1; END LOOP; IF i <= n THEN i := i + 2; END IF; CONTINUE; END IF; -- 单引号字符串 IF NOT in_double AND c = '''' THEN IF in_single THEN IF next_c = '''' THEN -- 转义的单引号 '',保留两个引号并跳过 buf := array_append(buf, c); buf := array_append(buf, next_c); i := i + 2; CONTINUE; ELSE in_single := false; END IF; ELSE in_single := true; END IF; buf := array_append(buf, c); i := i + 1; CONTINUE; END IF; -- 双引号标识符 IF NOT in_single AND c = '"' THEN IF in_double THEN IF next_c = '"' THEN buf := array_append(buf, c); buf := array_append(buf, next_c); i := i + 2; CONTINUE; ELSE in_double := false; END IF; ELSE in_double := true; END IF; buf := array_append(buf, c); i := i + 1; CONTINUE; END IF; -- 引号外空白压缩为单个空格 IF NOT in_single AND NOT in_double AND c ~ '\s' THEN IF array_length(buf, 1) IS NULL OR buf[array_length(buf, 1)] <> ' ' THEN buf := array_append(buf, ' '); END IF; WHILE i <= n AND substr(p_sql, i, 1) ~ '\s' LOOP i := i + 1; END LOOP; CONTINUE; END IF; buf := array_append(buf, c); i := i + 1; END LOOP; RETURN array_to_string(buf, ''); END; $$;几个关键细节值得展开说。
为什么用text[]数组而不是字符串拼接。PL/pgSQL里text是不可变类型,直接在循环里result := result || c每执行一次都会创建一个新字符串对象,几千字符的SQL会制造大量中间对象。数组append只在内存里增加一个元素,最后一次性array_to_string,性能差距在长SQL下非常明显。
为什么注释要替换成空格而不是直接删除。看这个例子:SELECT 1--comment\n+2。如果直接把注释删掉,会变成SELECT 1+2,数字被连在一起,语义完全变了。替换成空格则变成SELECT 1 +2,语义保持正确。这是一个非常隐蔽的坑,很多初版净化工具都会栽在这里。
字符串转义的处理。SQL里单引号字符串内部用两个连续单引号表示转义的单引号,比如'it''s'。如果状态机遇到第一个'就重置状态,it之后那个''会被误判为“字符串结束再开始”,后面的s就被当成裸字符处理了。所以必须判断:在字符串内部遇到'时,还要看下一个字符是否也是',如果是,一起保留并跳过,保持字符串状态不变。
3.2 标识符白名单校验:normalize_identifier
文本净化解决的是注释和空白问题,但SQL注入还有一种方式是结构注入,也就是把表名、列名这些标识符替换成攻击代码。例如动态拼接排序字段时,用户传了id; drop table users;--,如果直接拼进去就成了两条SQL。
我的方案是白名单正则校验:
CREATE OR REPLACE FUNCTION normalize_identifier(p_ident text) RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$ BEGIN IF p_ident IS NULL OR p_ident = '' THEN RAISE EXCEPTION 'identifier cannot be null or empty'; END IF; IF p_ident !~ '^[A-Za-z_][A-Za-z0-9_]*$' THEN RAISE EXCEPTION 'invalid identifier found in dynamic SQL: %', p_ident; END IF; RETURN p_ident; END; $$;这个白名单非常严格,只允许字母、数字、下划线,而且必须以字母或下划线开头。这意味着动态传入的表名、列名不能带引号、不能带点号、不能有特殊字符。好处是攻击面被压缩到极小,劣势是如果业务里确实有user-name这种带特殊字符的列名,这套函数就校验不过了。
我的处理是在文档里注明:支持标准命名规则的表和列优先使用白名单模式;如果业务确实需要特殊标识符,就把校验模式切换成基于quote_ident的转义模式,由DBA审核后开启。实际项目中,绝大多数业务表设计都符合常规命名,白名单模式够用。
3.3 参数安全转义:safe_build_sql
遗留SQL常见的结构是模板里带$1、$2这样的占位符,应用层用字符串替换把实际参数填进去。这种方式本身有风险,因为参数值可能包含单引号。我们不能要求短期内应用层全部改造为预编译,那就只能把替换动作收口到数据库函数里,统一用quote_literal对参数做安全转义。
CREATE OR REPLACE FUNCTION safe_build_sql( p_template text, VARIADIC p_args text[] ) RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE v_sql text := p_template; i int; BEGIN IF p_args IS NOT NULL THEN FOR i IN 1 .. array_length(p_args, 1) LOOP v_sql := replace( v_sql, '$' || i, quote_literal(COALESCE(p_args[i], 'NULL')) ); END LOOP; END IF; RETURN v_sql; END; $$;这里有个容易踩的坑:quote_literal(NULL)返回的是NULL,不是字符串'NULL'。如果参数值是NULL,直接用replace函数就会得到NULL,导致整个SQL变成NULL。所以我用COALESCE(p_args[i], 'NULL'),把NULL参数替换成SQL里的NULL关键字,语义正确且不会破坏SQL结构。
用法示例:
SELECT safe_build_sql( 'select * from users where id = $1 and status = $2', '18', E'active''; drop table users;--' );第二个参数会被转义成'active''; drop table users;--',作为一个安全字面量使用。
3.4 动态执行封装:safe_execute_query 与 safe_execute_table
净化函数只负责生成安全的SQL文本还不够,还得有统一的执行入口。否则应用层绕过去直接执行原始SQL,净化就成了摆设。
第一个执行入口是safe_execute_query,接收模板和参数数组:
CREATE OR REPLACE FUNCTION safe_execute_query( p_sql text, p_args text[] DEFAULT NULL ) RETURNS SETOF record LANGUAGE plpgsql AS $$ DECLARE v_sql text; BEGIN v_sql := sanitize_code(p_sql); IF p_args IS NULL OR array_length(p_args, 1) = 0 THEN v_sql := safe_build_sql(v_sql); ELSE v_sql := safe_build_sql(v_sql, VARIADIC p_args); END IF; RAISE NOTICE 'safe_execute_query -> %', v_sql; RETURN QUERY EXECUTE v_sql; END; $$;应用层这样调用:
SELECT * FROM safe_execute_query( 'select id, name from users where age > $1', ARRAY['18'] ) AS t(id int, name text);需要注意的是,返回类型SETOF record要求调用方必须显式指定每列的名称和类型,也就是AS t(...)里的列定义。这是一层隐性的“契约约束”:查询结果的结构必须在调用点被声明,想浑水摸鱼都不行。
第二个入口是更常用的safe_execute_table,针对“按表名做条件查询”的场景,把表名白名单校验、条件净化、分页限制都封装好:
CREATE OR REPLACE FUNCTION safe_execute_table( p_table text, p_where text DEFAULT '', p_limit int DEFAULT 100 ) RETURNS SETOF anyelement LANGUAGE plpgsql AS $$ DECLARE v_table text; v_where text; v_sql text; BEGIN v_table := normalize_identifier(p_table); IF p_where IS NULL OR p_where = '' THEN v_where := ''; ELSE v_where := ' WHERE ' || sanitize_code(p_where); END IF; IF p_limit IS NOT NULL AND p_limit > 0 THEN v_sql := format('SELECT * FROM %s%s LIMIT %s', v_table, v_where, p_limit); ELSE v_sql := format('SELECT * FROM %s%s', v_table, v_where); END IF; RAISE NOTICE 'safe_execute_table -> %', v_sql; RETURN QUERY EXECUTE v_sql; END; $$;调用示例:
SELECT * FROM safe_execute_table('users', 'age > 18', 50) AS t(id int, name text, age int, status text);这里我特意用了RETURNS SETOF anyelement,PostgreSQL会根据调用方指定的返回类型自动做行类型转换,对调用方来说像查询普通表一样自然。
边界要讲清楚:p_where虽然是净化过的文本,但净化只去掉注释、压缩空白,不会判断条件里有没有OR 1=1。所以这个函数只适合接收可信团队传入的受控条件模板,不能直接把用户输入丢给p_where。如果业务确实要开放给终端用户拼接条件,应该再封装一层结构化条件解析,只允许白名单字段和操作符,这个后面我会在扩展方案里说。
3.5 审计与追踪:query_audit_log 和 write_audit_log
净化做得再好,如果执行过程不可追溯,出了问题依然难排查。我设计了一张审计表:
CREATE TABLE IF NOT EXISTS query_audit_log ( id bigserial PRIMARY KEY, call_channel text NOT NULL, raw_sql text, norm_sql text, exec_ms double precision, row_count bigint, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_query_audit_log_created_at ON query_audit_log (created_at); CREATE INDEX IF NOT EXISTS idx_query_audit_log_channel ON query_audit_log (call_channel);写入函数:
CREATE OR REPLACE FUNCTION write_audit_log( p_channel text, p_raw text, p_norm text, p_ms double precision, p_rows bigint ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ BEGIN INSERT INTO query_audit_log(call_channel, raw_sql, norm_sql, exec_ms, row_count) VALUES (p_channel, p_raw, p_norm, p_ms, p_rows); END; $$; REVOKE ALL ON query_audit_log FROM PUBLIC; GRANT INSERT ON query_audit_log TO app_audit_role;这里有两个安全细节。第一,SECURITY DEFINER函数要固定search_path,否则攻击者可以在同名schema里放一个恶意函数劫持执行。第二,审计表默认对PUBLIC收回所有权限,只有专门的审计角色能写入,避免普通用户伪造审计记录。
生产环境不能每条查询都写审计日志,写入本身有开销。我用的是采样策略:普通查询百分之一概率记录,超过500ms的慢查询百分百记录,异常SQL百分百记录。按这个策略执行,审计表增长可控,排查慢SQL时又能抓到大头。
4. 性能、兼容性与权限设计:上线前必须想清楚的几个问题
4.1 性能陷阱:净化频率比净化函数本身更关键
状态机逐字符扫描的性能究竟如何?我在一个带Index Scan、SQL长度约800字符的查询上测过,单次sanitize_code大约耗时0.4到0.8毫秒。这个数字不算夸张,但架不住调用频繁。一个每秒2000次的报表接口,光在净化函数上就要烧掉1秒多的CPU时间。
所以真正的性能优化点在频率,而不是函数细节。两个经验:
- 把净化结果缓存起来。同一个业务模板每天被调用成千上万次,净化结果完全相同。可以在内存侧对固定模板做MD5缓存,命中后不再扫描。
- 利用IMMUTABLE标记做常量折叠。
sanitize_code被声明为IMMUTABLE,PostgreSQL会在表达式中出现常量SQL时直接预计算净化结果,避免重复执行。这也是我特意给核心函数加IMMUTABLE的原因之一。
用正则做净化?我建议彻底放弃。虽然PG的正则引擎做了优化,但SQL解析这种带引号状态的场景,正则要么写不完整,要么嵌套量词导致灾难性回溯。一个上百字符的恶意输入就可能让正则引擎卡住数秒,状态机线性扫描最坏也只是遍历一遍,这是安全兜底的底气。
4.2 跨版本兼容:PG版本差异与搜索路径
这套函数在PostgreSQL 11到15上都是兼容的,没有用到特别新的语法,但有几个版本相关的行为需要确认。
第一,regexp_replace里的\s字符类在不同版本中行为一致,但如果你用了E字符串写法,要注意standard_conforming_strings设置。我建议泛化一点,状态机里直接用字符比较,遇到空白字符时用一个简单的判断函数,避免依赖正则语义。
第二,format函数的%I、%L占位符是PG 9.1引入的,老版本用不了,但现在基本都够新。
第三,jsonb_to_record这类函数在PG 11以后行为稳定,如果你要写结构化过滤函数,可以大胆用。
每个函数我都建议在定义时固定search_path:
CREATE OR REPLACE FUNCTION safe_execute_table(...) RETURNS ... LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ ... $$;这个习惯能避免一类非常隐蔽的安全问题:如果某用户在自己的schema里建了一个同名的normalize_identifier函数,且函数是SECURITY DEFINER,就会发生函数劫持。把search_path钉死在系统schema和public,能极大压缩这种风险。
4.3 权限设计:让普通用户绕不过去
函数写好了,权限没卡住等于白写。我的建议如下:
REVOKE ALL ON FUNCTION safe_execute_query(text, text[]) FROM PUBLIC; REVOKE ALL ON FUNCTION safe_execute_table(text, text, int) FROM PUBLIC; GRANT EXECUTE ON FUNCTION safe_execute_query(text, text[]) TO app_read_role; GRANT EXECUTE ON FUNCTION safe_execute_table(text, text, int) TO app_read_role;同时要求应用层统一用app_read_role连接数据库,而不是用超级用户或表的所有者连接。这样应用连数据库后能执行的就是这几个净化函数,底层表不直接暴露,注入就算到了数据库层也没有操作面。
如果函数用SECURITY DEFINER,还有一个必须注意的点:定义者权限的函数会以函数所有者的身份执行SQL。这意味着如果某个低权限用户能调用这个函数,而函数内部又没有对表名做白名单约束,他就能利用函数所有者身份访问本不该访问的表。所以凡是SECURITY DEFINER函数,内部必须做三层保护:固定search_path、白名单校验、参数范围限制。宁可漏掉某条合法查询,也不要放开风险面。
5. 常见问题与排查技巧实录
5.1 问题速查表
把项目里真实遇到过的问题整理成表,排查时对照着看:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 查询结果里字符串内容被改了 | 压缩空白时把单引号内部空格也压了 | 检查净化前后日志,对比引号位置 | 状态机只在引号外压缩空白 |
| 注释没去掉,或语句粘连报错 | 正则去注释删除后没有保留词法边界 | 看净化后SQL是否出现1+2粘连 | 注释替换为空格而非删除 |
| 动态参数变成NULL导致查询异常 | quote_literal(NULL)返回NULL | 检查参数是否传了NULL | 用COALESCE(p_arg, 'NULL') |
| 带特殊字符的表名被拒绝 | 白名单正则太严格 | 确认业务是否真需要特殊标识符 | 切换quote_ident模式或调整命名 |
| 调用safe_execute_query报“record column”错误 | SETOF record没指定列定义 | 看调用是否带AS t(列...) | 调用点显式声明返回列 |
| 慢SQL日志没减少 | 净化函数本身成为热点 | 看pg_stat_statements里函数调用次数 | 加模板缓存,标记IMMUTABLE |
| 低权限用户能访问不该访问的表 | SECURITY DEFINER放大权限 | 检查函数定义与GRANT | 固定search_path,收紧白名单 |
| 审计表膨胀严重 | 每个查询都写日志,没有采样 | 看审计表行数和写入频率 | 改为采样记录,只全量记录慢SQL |
5.2 一次真实的排查过程
这套函数上线第二周,运维反馈某报表库的CPU占用不降反升。我先从pg_stat_statements里抓高频SQL,发现一个查询的调用次数极多,但这查询本身并不慢,执行计划也都是走的索引。再往下看发现,这个查询每次执行前都调用了sanitize_code,而它的SQL文本长且包含大量复杂表达式,状态机单次要扫描近一毫秒。
问题根源不是函数写错了,而是调用频率太高。这个报表页面每隔几秒就刷新一次,后端每次生成新SQL,净化函数就跟着跑一遍,CPU被白白耗在无差别的重复净化上。
排查过程让我意识到一个设计缺陷:净化函数做了太多“全量活”,没有按调用方分类缓存。后来我在函数外层加了一个轻量缓存表,以SQL文本MD5为key,命中后直接返回净化结果。因为净化函数是纯函数,相同的输入必然得到相同输出,缓存不会引入正确性问题。上线后CPU占用立刻降了六成。
这个案例值得所有做基础设施的人记住:性能问题往往不出在算法细节上,而出在调用模式上。函数本身再快,扛不住高频重复执行一样是灾难。
5.3 回归测试怎么做
净化函数属于基础工具,改一个字符都可能影响线上SQL行为。我强烈建议在函数库目录下建一个回归测试脚本,每次变更都跑一遍。
核心测试用DO块加断言即可:
DO $$ BEGIN -- 行注释应被替换为空格 IF sanitize_code('select 1 -- comment') <> 'select 1 ' THEN RAISE EXCEPTION 'fail: line comment'; END IF; -- 字符串内部的--不能被误删 IF sanitize_code($sql$select 'a--b' as s$sql$) <> $sql$select 'a--b' as s$sql$ THEN RAISE EXCEPTION 'fail: string quote'; END IF; -- 块注释内部的/*不能被误删 IF sanitize_code($sql$select '/*x*/' as s$sql$) <> $sql$select '/*x*/' as s$sql$ THEN RAISE EXCEPTION 'fail: string block'; END IF; -- 引号外空白压缩 IF sanitize_code($sql$select a, b from t$sql$) <> $sql$select a, b from t$sql$ THEN RAISE EXCEPTION 'fail: compress space'; END IF; -- 双引号标识符内的空白保留 IF sanitize_code($sql$select "a b" from t$sql$) <> $sql$select "a b" from t$sql$ THEN RAISE EXCEPTION 'fail: quoted identifier space'; END IF; RAISE NOTICE 'all sanitize_code tests passed'; END; $$;这些断言覆盖了最容易踩的四个边界:行注释、字符串中的注释符号、块注释、引号内空白。每次改动后跑一遍,至少能拦住八成回归问题。
6. 最后再说几句实在话
踩过几次坑之后,我的体会是:SQL净化这类基础设施,最怕的不是解析算法不够复杂,而是“你以为处理了所有边界,其实没有”。状态机、白名单、安全转义,本质上都是在把“不确定”压成“确定”。真正难的不是写代码,而是列全边界条件。
这套函数库上线到现在,效果是实打实的——慢查询日志里不再有几千字符的面条SQL,注入口子被堵住大半,审计表让每次动态执行都有据可查。但我必须诚实地说,它只是存量系统过渡期的一张安全网。等应用层逐步改造为真正的参数化查询后,这套库依然可以用,只是角色从“第一道防线”退成“最后一道审计关口”。
最后分享一个小技巧:给所有净化函数加上版本号注释,比如-- version: 2024.06.01,配合回归测试脚本一起维护。这个习惯让我在排查线上问题时能快速确认数据库里跑的到底是哪个版本的函数,省掉了无数“版本对不上”的扯皮。