Python 3.14 t-strings 实战:手写一个防注入的 SQL 参数化构造器

2026-10-11 0 502

f-string 是 Python 里最顺手的语法糖之一,用久了会产生一种错觉:拼接字符串是安全的。真到 SQL、HTML、shell 命令这些地方,把一个用户可控的值直接嵌进去再交给下游解析,出事几乎是必然的。过去的解法是把值拆出来单独传,但语法上没有任何约束,谁写快了谁就漏。

Python 3.14 引入的 t-string(PEP 750)把这件事做成了语法层面的东西:t"..." 不会立刻变成一个字符串,而是变成一个 Template 对象,静态文本和插值点分开摆在那里,由接收方决定怎么处理。接收方如果是个懂 SQL 的库,它就能把插值点转成占位符;接收方如果是个 HTML 渲染器,它就能顺手做转义。

这篇文章不聊设计哲学,直接拿两个真实场景把 t-string 用一遍:一个防注入的 SQL 参数化构造器,一个 HTML 自动转义渲染器。过程中会碰到的问题也一并写出来。

先确认版本

t-string 是 3.14 的新语法,低版本直接报语法错误,连模块都加载不进去。先跑一条命令:

python -V
# Python 3.14.0

如果本地是 3.13 或者更早,用 uv 起一个隔离环境最快,不用动系统 Python:

uv python install 3.14
uv run --python 3.14 demo.py

后面所有代码都在标准库范围内,不需要装任何第三方包。

t-string 到底长什么样

先看看它和 f-string 在运行时的差别:

name = "世界"

s = f"你好,{name}"
print(type(s))          # <class 'str'>
print(s)                # 你好,世界

from string.templatelib import Template
t = t"你好,{name}"
print(type(t))          # <class 'string.templatelib.Template'>
print(t.strings)        # ('你好,', '')
print(t.interpolations) # (Interpolation('世界', 'name', None, ''),)

关键在这里:f-string 一旦写出来,插值点就消失得无影无踪,剩下的只是一个普通的 str。t-string 不同,它把结构保住了——strings 是 N+1 个静态片段,interpolations 是 N 个插值对象,两者交替排列。

每个 Interpolation 上有四个属性:

  • value:插进去的实际值,也就是 name 求值的结果
  • expression:源码里的表达式文本,方便报错时定位,是 "name" 这个字符串
  • conversion:!r !s !a 三种转换,没写就是 None
  • format_spec:冒号后面的格式说明符,没写就是空字符串

而且 Template 本身是可迭代的,会交替吐出一个 str 和一个 Interpolation。这是写自定义处理函数最舒服的入口,不用自己去对齐两个元组的下标。

第一个最小可运行的渲染器

动手之前先写个纯拼接版本,把结构摸清楚:

from string.templatelib import Template, Interpolation


def render(template: Template, /) -> str:
    out = []
    for item in template:
        if isinstance(item, str):
            out.append(item)
        elif isinstance(item, Interpolation):
            value = item.value
            if item.conversion:
                from string.templatelib import convert
                value = convert(value, item.conversion)
            if item.format_spec:
                value = format(value, item.format_spec)
            out.append(str(value))
        else:
            raise TypeError(f"模板里出现了意外类型:{type(item).__name__}")
    return "".join(out)

试一下:

price = 1299.5
print(render(t"总价:{price:.2f} 元"))
# 总价:1299.50 元

行为上和 f-string 基本一致,唯一的区别是求值时点往后挪了——值先被装进 Interpolation,什么时候转成文本由 render 说了算。这一点就是后面所有安全控制的立足点。

实战一:SQL 参数化构造器

写 SQL 时最危险的动作就是把用户输入拼进语句字符串。即使你手动做了转义,只要有一处漏了单引号处理,整张表就交代了。参数化的核心思路是:SQL 的结构和值分开走两条路,数据库驱动只负责把值填进占位符。

t-string 恰好就长成这个形状。静态片段拼成 SQL,插值点全变成 ?,值进列表:

from string.templatelib import Template, Interpolation


class InjectionError(ValueError):
    """t-string 里出现了会破坏参数化前提的写法。"""


class Query:
    __slots__ = ("sql", "params")

    def __init__(self, sql: str, params: list[object]) -> None:
        self.sql = sql
        self.params = params

    def __repr__(self) -> str:
        return f"Query(sql={self.sql!r}, params={self.params!r})"


def sql(template: Template, /) -> Query:
    if not isinstance(template, Template):
        raise TypeError(
            f"sql() 只接受 t-string,收到的是 {type(template).__name__}。"
            "f-string 在传参之前就已经求值成 str,参数化的前提在那一步就丢了。"
        )

    chunks: list[str] = []
    params: list[object] = []

    for item in template:
        if isinstance(item, str):
            chunks.append(item)
        elif isinstance(item, Interpolation):
            if item.conversion is not None:
                raise InjectionError(
                    f"不允许使用 !{item.conversion} 转换:值在进驱动之前被改写过,"
                    "类型和内容都不再是你传进去的那个"
                )
            if item.format_spec:
                raise InjectionError(
                    f"不允许使用格式说明符 {item.format_spec!r}:"
                    "格式化把值转成了字符串,驱动拿到的不再是原始类型"
                )
            chunks.append("?")
            params.append(item.value)
        else:
            raise TypeError(f"模板里出现了意外类型:{type(item).__name__}")

    return Query("".join(chunks), params)

跑几个用例看看效果。先看正常查询:

user_id = 42
q = sql(t"SELECT id, name FROM users WHERE id = {user_id}")
print(q)
# Query(sql='SELECT id, name FROM users WHERE id = ?', params=[42])

再看经典的注入串:

name = "' OR '1'='1"
q = sql(t"SELECT * FROM users WHERE name = {name}")
print(q)
# Query(sql='SELECT * FROM users WHERE name = ?', params=["' OR '1'='1"])

SQL 的骨架没变,攻击串被完整地关进了参数列表里。对比一下手动拼接会变成什么:

bad = f"SELECT * FROM users WHERE name = '{name}'"
print(bad)
# SELECT * FROM users WHERE name = '' OR '1'='1'

条件恒真,整张表就出去了。

还有几个边界情况值得留意。sql() 收到普通字符串时会直接抛 TypeError,这是有意的——sql("SELECT 1") 和 sql(f"SELECT {x}") 都必须失败,否则整个保护形同虚设:

sql("SELECT 1")
# TypeError: sql() 只接受 t-string,收到的是 str ...

sql(f"SELECT {user_id}")
# 同样抛 TypeError,f-string 在这之前已经变成 str 了

而 sql(t"SELECT {user_id}") 正常通过。类型系统在这一层帮你把误用挡住了。

为什么禁用转换和格式说明符

刚才那两个 InjectionError 不是为了防注入本身,而是为了保住这个构造器的契约:值原样传给驱动。

!r 会让驱动收到一个 repr() 之后的字符串,{n:03d} 会让驱动收到 "042" 而不是 42。绝大多数情况下驱动还能处理,但类型推断会走岔路——比如某些驱动看到字符串会自动加引号,看到整数不会。既然这个构造器的卖点就是”值和结构严格分离”,那就干脆一点,任何在 Python 侧改写值的写法都不放行。

如果你确实需要格式化,把它挪到参数外面:先算好再传进去。

实战二:HTML 自动转义渲染器

第二个场景是 HTML 输出。用户提交的评论、昵称、地址一旦被直接塞进页面,XSS 就来了。这里的要求和 SQL 不同:不做占位符,而是对每个插值点做 HTML 转义,同时允许已经安全的片段原样通过。

from html import escape
from string.templatelib import Template, Interpolation


class SafeHTML:
    """明确标记:这段内容已经处理过,不需要再转义。"""
    __slots__ = ("_raw",)

    def __init__(self, raw: str) -> None:
        self._raw = raw

    def __str__(self) -> str:
        return self._raw


def html(template: Template, /) -> SafeHTML:
    if not isinstance(template, Template):
        raise TypeError("html() 只接受 t-string")

    out: list[str] = []

    for item in template:
        if isinstance(item, str):
            out.append(item)
        elif isinstance(item, Interpolation):
            value = item.value
            if item.format_spec:
                value = format(value, item.format_spec)
            if isinstance(value, SafeHTML):
                out.append(str(value))
            else:
                out.append(escape(str(value)))
        else:
            raise TypeError(f"模板里出现了意外类型:{type(item).__name__}")

    return SafeHTML("".join(out))

注意 SafeHTML 这个包装类型。默认行为是”没打过标记的一律转义”,只有显式包过的才放行。这个默认值很重要——开发者不需要记住”哪些字段要转义”,忘掉的东西会被默认兜住。

看效果:

comment = '<script>alert("xss")</script>'

print(f"<div>{comment}</div>")
# <div><script>alert("xss")</script></div>     ← 浏览器会执行

print(html(t"<div>{comment}</div>"))
# <div>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</div>

组合场景也很自然。拼接一段已经安全的内容时,它不会被二次转义:

nickname = "<b>管理员</b>"

badge = html(t"<span>{nickname}</span>")   # 转义一次
page  = html(t"<div class='card'>{badge}</div>")  # badge 是 SafeHTML,不再转义

print(page)
# <div class='card'><span>&lt;b&gt;管理员&lt;/b&gt;</span></div>

这套机制能成立,靠的就是 t-string 没有提前求值——如果 badge 是个普通 str,html() 就分不清它到底是原始文本还是已处理的 HTML,只能一律再转一遍。

几个容易踩的地方

t-string 必须在源码里字面书写

和 f-string 一样,它不是一个”把字符串转成模板”的函数。下面这种写法不成立:

raw = "SELECT * FROM users WHERE id = {uid}"
sql(t"{raw}")   # 这只会把 raw 整个当成一个参数值,不会展开

模板是编译期构造的,预处理发生在解析阶段。所以如果你的 SQL 片段是从配置里读出来的动态字符串,t-string 帮不上忙,得换别的手段(白名单校验、查询构造器 API 等)。

别把 f-string 混进来

有一种写法很容易写错:

sql(t"SELECT * FROM {table} WHERE id = {user_id}")
# 如果 table 是个 t-string,这里只会把它当普通值丢进参数列表

表名、列名这类标识符在参数化里是没有位置的——占位符只能出现在值的位置。所以表名该走白名单校验,不该指望 t-string。

性能别想太多

t-string 比 f-string 多一点开销,因为要构造 Template 和一组 Interpolation 对象。在普通业务代码里这点差异可以忽略,但如果你在一个每秒调几十万次的热点循环里做字符串拼接,还是老实用 f-string。t-string 的价值在安全和结构,不在速度。

嵌套不要太深

t-string 里可以嵌 f-string,也可以嵌另一个 t-string,但两种混在一起读起来相当难受:

sql(t"SELECT * FROM users WHERE {condition} AND name = {f'{prefix}_{name}'}")

这种写法能跑,但三个月后没人愿意改。把嵌套的部分先算出来放到变量里,再丢进模板。

收个尾

把两个渲染器放在一起看,会发现它们共享同一个骨架:遍历模板、静态片段直接追加、插值点按业务规则处理。区别只在于”插值点怎么处理”这一件事——SQL 换成占位符,HTML 做转义,如果哪天要拼 shell 命令,换成 shlex.quote 就行。

这就是 t-string 真正有意思的地方。过去我们靠约定和代码审查来保证”用户输入必须处理”,现在可以把这条规则编码进类型签名:接收 Template 的函数自动获得了拦截时机,而传 str 进来的调用会在运行时直接失败。约束从文档挪到了代码里,漏掉的概率就小多了。

如果你手上维护着老项目,短期内也没必要全量改写。可以先把新写的 SQL 和 HTML 生成函数换成 t-string 版本,老的慢慢替换。两者可以共存,不会互相干扰。

Python 3.14 t-strings 实战:手写一个防注入的 SQL 参数化构造器
收藏 (0) 打赏

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

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

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

淘吗网 python Python 3.14 t-strings 实战:手写一个防注入的 SQL 参数化构造器 https://www.taomawang.com/server/python/2921.html

常见问题

相关文章

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

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