别再用 f-string 拼 SQL 了:Python 3.14 t-string 改造实录

2026-09-11 0 435

扫描报告里那条 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}'"

statuspaid 时,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.interpolationsInterpolation 对象组成的元组,每个对象有四个属性:

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 关键字。这些位置数据库不允许用绑定参数,只能靠字符串拼接。

这就是最开始那段代码里最危险的地方。tablecolumns 都是直接插进语句里的。

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>欢迎回来,&lt;script&gt;alert(1)&lt;/script&gt;</p>'

这段代码的价值不在转义本身——html.escape 谁都会调。价值在于它不可能漏掉某一次转义。只要用 render_html,所有插值一律过一遍转义,不存在”某一行忘了写 escape”这种事。

以前用 Jinja2 的 autoescape 也是这个逻辑,只不过需要一个模板引擎;现在标准库层面就能做到类似的效果。

结构化日志的字段名

我们的日志之前都是这样写的:

logger.info(f"user {uid} paid {amount} for order {oid}")

丢进日志平台之后,uidamountoid 全变成了正文字符串的一部分,想按用户 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。

别再用 f-string 拼 SQL 了:Python 3.14 t-string 改造实录
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python 别再用 f-string 拼 SQL 了:Python 3.14 t-string 改造实录 https://www.taomawang.com/server/python/2743.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务