扫描报告里那条 SQL 注入挂了快两个月,一直没人认领。它在数据导出服务里,接口长这样:
GET /export?table=orders&columns=id,amount&status=paid
负责这个模块的同事上个月离职了,代码里那段拼接是这样的:
def build_query(table, columns, filters):
sql = f"SELECT {', '.join(columns)} FROM {table}"
conds = [f"{k} = '{v}'" for k, v in filters.items()]
if conds:
sql += " WHERE " + " AND ".join(conds)
return sql
表名、列名、筛选条件全是从 query string 里直接读的,没有任何校验。改起来其实不难,但改的时候我一直在想一个问题:为什么这段代码当初会被写成这样?
答案挺朴素的——因为用 f-string 写太顺手了。顺手到没有人意识到,这个顺手本身就是一个设计缺陷。
f-string 的问题不在于”不安全”,而在于”无从检查”
常见的说法是 f-string 会导致 SQL 注入。这话对,但没点到要害。真正的要害是:f-string 在求值之后,模板结构就彻底消失了。
看这一行:
sql = f"SELECT * FROM orders WHERE status = '{status}'"
当 status 是 paid 时,Python 交给你的就是一个普通字符串 "SELECT * FROM orders WHERE status = 'paid'"。你没有任何办法从结果里看出来,哪一段是开发者写死的 SQL 结构,哪一段是用户传进来的数据。它们已经被揉成一团了。
所以任何后续处理——转义、参数化、审计——都做不了。你只能靠人肉 review 去发现问题,而人肉 review 是会漏的。
这就是为什么大部分团队的做法是”禁止用 f-string 拼 SQL”,然后在 code review 里抓。规则本身没错,但它依赖人的自觉。
t-string 换了个思路:让结构活到运行时
Python 3.14 正式加入了模板字符串,也就是 PEP 750 里的 t-string。语法看起来跟 f-string 几乎一样,只差一个字母:
from string.templatelib import Template, Interpolation
status = "paid"
t = t"SELECT * FROM orders WHERE status = {status}"
区别在于 t 不会立刻求值成一个字符串。它给你的是一个 Template 对象,静态部分和插值部分被分开保存着:
>>> t.strings
('SELECT * FROM orders WHERE status = ', '')
>>> t.interpolations
(Interpolation('paid', 'status', None, None),)
拿到这个对象之后,你可以自己决定怎么处理插值。这才是关键——决策权回到了调用方手里。
需要注意的是,插值里的值在创建 Template 的那一刻就已经求值了,跟 f-string 一样是即时的。t-string 不是惰性求值,它只是把”值”和”模板结构”一起保留下来。想清楚这一点,后面的用法就不容易搞混。
拆开看 Template 和 Interpolation
Template 上真正有用的是两个属性:
template.strings 是静态字符串片段组成的元组。它的长度永远比插值数量多一。哪怕开头或结尾没有静态内容,也会有一个空字符串占位。
template.interpolations 是 Interpolation 对象组成的元组,每个对象有四个属性:
name = "orders"
t = t"SELECT * FROM {name} LIMIT {100:>5}"
i = t.interpolations[0]
i.value # 'orders',求值后的实际对象
i.expression # 'name',源码里写的原始表达式
i.conversion # None,对应 !r / !s / !a
i.format_spec # None,对应冒号后面的那部分
j = t.interpolations[1]
j.value # 100
j.expression # '100'
j.format_spec # '>5'
expression 这个属性在 f-string 里是完全不存在的,但它在很多场景下很有用——比如做结构化日志的时候,可以直接拿它当字段名。
另外 Template 是可迭代的,会按顺序交替产出字符串和 Interpolation。写渲染逻辑时用 for 循环比手动对下标清爽得多。
第一步:把查询构造器改成参数化
改造目标很明确——返回 SQL 模板和参数列表两部分,交给驱动去做参数绑定。
from string.templatelib import Template, Interpolation
def to_sql(template: Template, placeholder: str = "%s") -> tuple[str, list]:
parts: list[str] = []
params: list = []
for item in template:
if isinstance(item, str):
parts.append(item)
else:
parts.append(placeholder)
params.append(item.value)
return "".join(parts), params
这里能直接用 isinstance(item, str) 区分两种元素,因为 Interpolation 不是 str 的子类。这个判断很轻,不用担心性能。
调用方这样写:
def export_orders(status: str, min_amount: int) -> list[dict]:
sql, params = to_sql(
t"SELECT id, amount, created_at FROM orders "
t"WHERE status = {status} AND amount >= {min_amount}"
)
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
拼出来的结果是:
SELECT id, amount, created_at FROM orders WHERE status = %s AND amount >= %s
['paid', 100]
用户传 ' OR 1=1 -- 进去,它也就是一个普通的字符串参数,会被数据库当值处理,不会被当成语法。
相邻的两个 t-string 字面量会被 Python 自动拼接——跟普通字符串字面量一样。这一点比原来用加号连接可读性好太多。
第二步:必须加的类型检查(这里最容易翻车)
上面那段代码有个隐蔽的漏洞,我是在写测试用例的时候才发现的。
如果有人不小心传了一个普通字符串进去:
sql, params = to_sql("SELECT * FROM orders WHERE id = 42")
Python 不会报错。for item in template 会遍历字符串里的每一个字符,每个字符都是 str,于是整条语句被原样当作静态片段拼起来,参数列表是空的。execute(sql, []) 照常执行。
也就是说,这个函数会静默地把一个未参数化的裸 SQL 放过去。这比原来还危险,因为它给了人一种”我用了安全函数”的错觉。
所以函数开头必须硬拦住:
def to_sql(template: Template, placeholder: str = "%s") -> tuple[str, list]:
if not isinstance(template, Template):
raise TypeError(
"to_sql 只接受 t-string,请写成 t"..." 而不是普通字符串"
)
...
这条检查后来还顺手抓出了两处误用。有个同事从别的地方 copy 了一段代码,把已经拼好的字符串传了进来,测试直接红了。
第三步:表名和列名不能参数化,只能白名单
参数化解决的是”值”的问题,但 SQL 里还有一类东西是占位符放不进去的——表名、列名、ORDER BY 后面的字段、ASC/DESC 关键字。这些位置数据库不允许用绑定参数,只能靠字符串拼接。
这就是最开始那段代码里最危险的地方。table 和 columns 都是直接插进语句里的。
t-string 在这里帮不上忙,因为问题不在于”结构丢失”,而在于”这些位置本来就允许标识符”。唯一的办法是白名单:
ALLOWED_TABLES = {"orders", "order_items", "refunds"}
ALLOWED_COLUMNS = {
"orders": {"id", "amount", "status", "created_at"},
"order_items": {"id", "order_id", "sku", "qty"},
"refunds": {"id", "order_id", "amount", "reason"},
}
def safe_identifier(name: str, pool: set[str]) -> str:
if name not in pool:
raise ValueError(f"不允许的标识符: {name!r}")
return name
然后查询构造器就可以这样写:
def export_query(table: str, columns: list[str], status: str, min_amount: int):
if table not in ALLOWED_TABLES:
raise ValueError(f"不允许的表: {table!r}")
col_pool = ALLOWED_COLUMNS[table]
safe_cols = [safe_identifier(c, col_pool) for c in columns]
sql, params = to_sql(
t"SELECT {', '.join(safe_cols)} FROM {table} "
t"WHERE status = {status} AND amount >= {min_amount}"
)
return sql, params
注意这里的 {table} 和 {', '.join(safe_cols)}。它们也会被 to_sql 替换成 %s,这就错了——表名位置不能放占位符。
所以这些需要直接内联的部分,不要放进 t-string 里,而是等参数化完成之后再拼:
base, params = to_sql(
t"SELECT * FROM __TABLE__ WHERE status = {status} AND amount >= {min_amount}"
)
sql = base.replace("__TABLE__", table)
# 列名同理,在 to_sql 之前用已经校验过的字符串拼好
col_clause = ", ".join(safe_cols)
base, params = to_sql(
t"SELECT " + col_clause + " FROM orders "
t"WHERE status = {status} AND amount >= {min_amount}"
)
看起来有点绕,但这恰恰说明了一件事:连 t-string 都没法挽救你在那些允许拼接的位置上偷懒。该做校验的地方还是要做校验,只是这个边界被划清楚了。
顺手做掉的三件事
改造完主体之后我发现,t-string 这套思路能复用到好几个以前写得很难受的地方。
HTML 转义
import html
def render_html(template: Template) -> str:
if not isinstance(template, Template):
raise TypeError("render_html 需要 t-string")
out = []
for item in template:
if isinstance(item, str):
out.append(item) # 模板里的静态部分,是开发者写的
else:
out.append(html.escape(str(item.value)))
return "".join(out)
username = '<script>alert(1)</script>'
render_html(t"<p>欢迎回来,{username}</p>")
# '<p>欢迎回来,<script>alert(1)</script></p>'
这段代码的价值不在转义本身——html.escape 谁都会调。价值在于它不可能漏掉某一次转义。只要用 render_html,所有插值一律过一遍转义,不存在”某一行忘了写 escape”这种事。
以前用 Jinja2 的 autoescape 也是这个逻辑,只不过需要一个模板引擎;现在标准库层面就能做到类似的效果。
结构化日志的字段名
我们的日志之前都是这样写的:
logger.info(f"user {uid} paid {amount} for order {oid}")
丢进日志平台之后,uid、amount、oid 全变成了正文字符串的一部分,想按用户 ID 过滤只能全文检索。后来同事想改成 JSON 格式,但手写 dict 的 key 实在累。
Interpolation.expression 正好能派上用场:
def log_with_fields(template: Template) -> tuple[str, dict]:
if not isinstance(template, Template):
raise TypeError("需要 t-string")
msg_parts = []
fields = {}
for item in template:
if isinstance(item, str):
msg_parts.append(item)
else:
key = item.expression
msg_parts.append("{" + key + "}")
fields[key] = item.value
return "".join(msg_parts), fields
uid, amount, oid = 1024, 299.00, "A-77213"
msg, fields = log_with_fields(t"user {uid} paid {amount} for order {oid}")
# msg = 'user {uid} paid {amount} for order {oid}'
# fields = {'uid': 1024, 'amount': 299.0, 'oid': 'A-77213'}
字段名直接取自源码里的变量名,不用再手动对一遍。变量改名的时候,日志字段也跟着改——当然这算好事还是坏事,取决于你的日志查询面板里有没有存量的字段名依赖。我们这边是新接的模块,没什么存量负担,就用了。
多语言消息的占位符
这条是我自己在小项目里试的。以前做 i18n 的时候,译文里的 {0}、{1} 要是对不上位,非常难查。用 t-string 可以先建立一个原始 Template,把结构存下来,翻译的时候只翻静态片段:
def translate(template: Template, lookup: dict[str, str]) -> Template:
new_strings = [lookup.get(s, s) for s in template.strings]
return Template(*interleave(new_strings, template.interpolations))
def interleave(strings, interps):
out = []
for i, s in enumerate(strings):
out.append(s)
if i < len(interps):
out.append(interps[i])
return out
Template 的构造函数接受字符串和 Interpolation 交替传入。这样一来,译文里静态部分翻错了也就是文案难看,插值位置永远跟原文一致,不会串位。
踩到的坑
渲染结果不是 str。str(template) 能拿到类似 f-string 的结果,但 isinstance(template, str) 是 False。所有旧函数签名上写着 sql: str 的地方都要过一遍。我们有个日志中间件接的是 str,传 t-string 进去之后它做了 type(msg) is str 判断,直接走了另一条分支,日志丢了一半。
conversion 会被默默忽略。如果你写 t"WHERE id = {uid!r}",to_sql 只读 item.value,!r 完全不起作用。用户传整数 42,参数就是 42;但如果 value 本身就是字符串,!r 并不会给它加上引号——该加引号的地方数据库驱动会处理,不需要你操心。不过如果有人照着 f-string 的习惯写 !r,最好在函数里直接报错拦掉,免得引起误会:
if item.conversion is not None:
raise ValueError(f"SQL 参数不需要 conversion: !{item.conversion}")
format_spec 也类似。t"{amount:.2f}" 里的 .2f 不会自动生效,得自己调 format(item.value, item.format_spec)。做 SQL 参数时我选择直接拒绝带 format_spec 的插值——数值格式化应该在业务层做完再传进来,混在 SQL 构造里容易出数值精度问题。
IDE 和 linter 的支持还在跟进。我们用的 ruff 和 mypy 都能认出 t-string 语法,但针对 Template 的类型推断还不算完善。把 to_sql 的返回类型标注成 tuple[str, list] 时,list 没带参数,mypy 会提示一下。改成 list[object] 就好了。
性能。创建 Template 对象比直接生成字符串要多做一点工作,因为要保存静态片段和插值元信息。我拿一万次构造做了个粗略对比,差距在微秒级,跟一次数据库往返完全不是一个量级。但如果你在每秒几十万次的热循环里用 t-string 生成非 SQL 的普通字符串,那确实值得测一下——那种场景本来也不该用模板字符串。
什么时候还是用 f-string
说句实话,日常写日志、拼路径、生成调试信息,f-string 依然是最舒服的选择。t-string 要付出构造对象的代价,换来的是”可以被程序检查的结构”,如果你根本不需要检查,那这个代价就是白付的。
判断标准挺简单:插值来源是用户可控的数据,并且要交给另一个解析器(SQL、HTML、Shell、正则)去处理的时候,用 t-string。其余情况 f-string。
回到最开始那个导出服务。改完之后 build_query 从 6 行变成了接近 40 行,还多了一套白名单表。代码量翻了几倍,但那 40 行里每一行都在做 f-string 版本的代码做不到的事——把结构和数据分开,把校验边界说清楚。
安全扫描报告上那条告警,两周后状态变成了 closed。

