Python 3.14 t-strings 实战:手写防注入 SQL 构造器与安全 HTML 渲染

2026-10-14 0 976

前段时间 review 一段老代码,看到一个查询用户订单的函数长这样:

def get_orders(uid, status):
    sql = f"SELECT * FROM orders WHERE uid = {uid} AND status = '{status}'"
    return db.execute(sql)

uid 是 int 还好,status 是字符串,直接拼进单引号里。当时我在评论区写了一句”这玩意儿能注入”,作者回我”前端限制了选项的”。这话我听过不下十次,每次都想叹气。

但这次不聊怎么修,聊一个更根本的问题:f-string 从设计上就没打算给你拦截的机会。Python 3.14 引入的 t-strings 想解决的正是这件事。这篇文章会把它的结构掰开,然后用它写三个能直接放进项目的工具。

f-string 的问题不是”不安全”,是”没得管”

f-string 里能塞任意表达式,包括函数调用:

user_input = "{__import__('os').system('rm -rf /')}"
print(f"结果:{user_input}")  # 安全,只是打印字符串
print(f"结果:{eval(user_input)}")  # 不安全,但这是 eval 的锅

这个例子常被拿来吓唬人,其实有点冤枉 f-string。真正的麻烦在别处:一旦写下 {...},求值和拼接是原子的,中间没有任何钩子。你不能在拼接之前检查一下这个值是不是脏的,也不能决定”这个位置要当一个 SQL 参数,而不是一段字面量”。

所以每次处理用户输入,都得绕开 f-string,改用参数化查询:

db.execute("SELECT * FROM orders WHERE uid = ? AND status = ?", (uid, status))

参数化的能力一直都在,问题在于——没有强制,全靠自觉。哪天有人为了加一个”根据字段名排序”的功能,写了一行 f"ORDER BY {sort_field}",你又得去 review 一次。

t-string 换了个思路

Python 3.14 里可以这么写:

from string.templatelib import Template, Interpolation

name = "Alice"
template = t"SELECT * FROM users WHERE name = {name}"

print(type(template))  # <class 'string.templatelib.Template'>

语法上跟 f-string 一模一样,只是前缀从 f 变成 t。但结果完全不同:template 不是字符串,而是一个 Template 对象。表达式根本没有被转换成字符串,只是被”记录”下来了。

这意味着,处理权交给了你。你想把它拼成 SQL,就拼成 SQL;想 HTML 转义,就 HTML 转义;想直接拒绝,就抛异常。

这一点是核心,比”能写自定义字符串格式化”的说法准确得多。

Template 里面到底装了什么

一个 Template 对象有两个关键属性,还有一个很好用的迭代行为。

strings 和 interpolations

name = "Alice"
age = 30
t = t"Hi {name}, age {age + 1}"

t.strings         # ('Hi ', ', age ', '')
t.interpolations  # (Interpolation('Alice', 'name'), Interpolation(31, 'age + 1'))
t.values          # ('Alice', 31)

注意几个细节。

第一,strings 的长度永远比 interpolations 多一个,即使末尾没有内容,也会有一个空字符串占位。个数对齐之后,处理起来不用额外的边界判断。

第二,每个 Interpolation 里有四个属性:

  • value:表达式求值后的实际对象,这里是 "Alice" 和 31。原始类型没有被转成字符串,这一点非常重要。
  • expression:源代码文本,这里是 "name" 和 "age + 1"。做调试和日志的时候很有用。
  • conversion:转换字符,!r 是 'r',没写就是 None。
  • format_spec:格式说明符,比如 :>10 就是 ">10",没写是空字符串。

迭代行为

处理 template 时,最顺手的写法是直接迭代它:

for item in t:
    if isinstance(item, str):
        ...  # 静态文本
    elif isinstance(item, Interpolation):
        ...  # 插值槽

迭代会按顺序把静态片段和插值槽交替吐出来,顺序和你在源码里写的完全一致。写转换函数时用这个模式,比手动按索引对齐 strings 和 interpolations 要清爽很多。

案例一:防注入 SQL 构造器

先立个目标:希望调用方能写成这样,同时接口是安全的。

stmt, params = sql(t"SELECT * FROM users WHERE id = {user_id} AND status = {status}")
db.execute(stmt, params)

字段名这种不能参数化的东西,要显式包一层。

先写标识符校验

import re

_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")

class Identifier:
    __slots__ = ("name",)

    def __init__(self, name: str):
        if not _IDENT_RE.match(name):
            raise ValueError(f"非法的 SQL 标识符: {name!r}")
        self.name = name

    def __repr__(self):
        return f"Identifier({self.name!r})"

关键点是白名单式的正则。不要用黑名单去过滤分号、空格、注释符,那种做法总有漏网的写法。只允许字母数字下划线,其他一律拒绝,简单有效。

主转换函数

from string.templatelib import Template, Interpolation

def sql(template: Template) -> tuple[str, list]:
    if not isinstance(template, Template):
        raise TypeError("sql() 只接受 t-string")

    parts = []
    params = []

    for item in template:
        if isinstance(item, str):
            parts.append(item)
            continue

        if not isinstance(item, Interpolation):
            raise TypeError(f"意外的类型: {type(item)}")

        value = item.value

        if isinstance(value, Identifier):
            parts.append(value.name)

        elif isinstance(value, (list, tuple, set, frozenset)):
            seq = list(value)
            if not seq:
                parts.append("(NULL)")
            else:
                parts.append("(" + ", ".join("?" * len(seq)) + ")")
                params.extend(seq)

        elif value is None:
            parts.append("NULL")

        else:
            parts.append("?")
            params.append(value)

    return "".join(parts), params

几处值得说明的设计。

标识符和值走两条路。 参数化查询的原理决定了表名、列名、排序字段这些结构性的东西没法用占位符,只能拼进 SQL 文本。所以这里用 Identifier 显式包装,进来之前必须先过正则。普通值一律走 ? 占位符。

自动展开 IN 子句。 WHERE id IN {ids} 这种写法很常见,手写占位符又容易数错个数,交给构造器处理省心。空列表处理成 (NULL),保证 IN (NULL) 永远不匹配任何行,语义上符合直觉。

None 直接写成 SQL 的 NULL。 不能参数化成 ?,因为 id = NULL 永远为假,得写 IS NULL。但这里只做到了字面量替换,如果调用方写 WHERE status = {None},生成的语句还是 WHERE status = NULL,语义上是错的。

这个问题没有通用解,除非你解析整个 SQL 语法树。实际项目里我的做法是:检测到 None 时给出警告,让调用方改成显式的 IS NULL 语句。宁可在日志里多一条提醒,也不要在生产上悄悄返回空结果。

用起来是什么感觉

user_id = 42
status = "paid'; DROP TABLE users; --"
fields = Identifier("created_at")
order_ids = [101, 102, 103]

stmt, params = sql(t"""
    SELECT id, amount, {fields}
    FROM orders
    WHERE user_id = {user_id}
      AND status = {status}
      AND id IN {order_ids}
    ORDER BY {fields} DESC
""")

print(stmt)
print(params)

如果 status 带了注入内容,它会被塞到参数列表里原封不动传给驱动,数据库会把整段字符串当作一个普通值来匹配,什么也不会发生。这就是参数化的价值。

如果调用方忘了包 Identifier,直接写 {fields},那也会被当作参数处理,SQL 文本里只有一个问号,驱动在 ORDER BY ? 的位置会报语法错误。这种失败方式是好的失败方式——出错比悄悄出结果安全。

案例二:HTML 安全渲染

同样的思路套到 HTML 上。很多人对模板引擎的 {{ }} 默认转义已经习以为常了,但手写拼接 HTML 的代码还是到处都是,尤其是在生成邮件内容和导出报告的时候。

目标 API:

html(t"<p>你好,{nickname}</p>")

其中 nickname 无论是什么内容,输出时都会被正确转义。

转义函数

def escape(text) -> str:
    s = str(text)
    # 顺序不能变,& 必须第一个处理
    return (
        s.replace("&", "&")
         .replace("<", "&lt;")
         .replace(">", "&gt;")
         .replace('"', "&quot;")
         .replace("'", "&#39;")
    )

五个字符里的顺序问题:如果先替换 <,会把 &lt; 里的 & 也一起替换掉,最后变成 &amp;lt;,页面上用户就会看到一堆 &lt; 的字面量。& 永远是第一步。

已信任内容怎么办

全转义有个副作用:你拼出来的那些标签本身也会被转义掉。比如:

html(t"<p>{user_input}</p>")

静态部分 "<p>" 和 "</p>" 会原样进入结果,没问题。但如果结果是嵌进另一层模板里的,第二层就会把它们转义掉。

解决办法是引入一个”已信任”的标记类型,跟 Jinja2 的 Markup 一个思路:

class SafeString(str):
    """标记为已经过转义或本身可信的 HTML 片段。"""
    __slots__ = ()

    def __html__(self):
        return str(self)


def html(template: Template) -> SafeString:
    parts = []
    for item in template:
        if isinstance(item, str):
            parts.append(item)
        else:
            v = item.value
            if isinstance(v, SafeString):
                parts.append(str(v))
            else:
                parts.append(escape(v))
    return SafeString("".join(parts))

这样 html() 的输出可以安全地嵌套进更外层的模板,不会出现双重转义。判断逻辑只有一条:是 SafeString 就放行,不是就转义。没有第三种情况,也就没有绕过转义的意外路径。

一个常见的顾虑

有人会问:SafeString 是不是继承了 str,那它不就等于还没转义吗?

答案是不是。关键不在类型,在于谁负责转义。所有进入 html() 的普通值都会被转义,出来的结果是一个 SafeString,意思是”我已经转义过了”。它本质上是一个承诺,不是一种特权。要是谁手动构造一个 SafeString(user_input),那确实会绕过检查,但这时候写代码的人是在明确说”我确认这段内容安全”,责任归属很清楚。

安全设计里,”能否绕过”和”绕过得写一行明显的代码”是两回事。后者的价值在于,review 时 grep SafeString 就能把可疑的地方全捞出来。

案例三:日志脱敏

t-string 的另一个顺手场景是把敏感信息从日志里挤出去。

import re

_PATTERNS = [
    (re.compile(r"1[3-9]d{9}"), lambda s: s[:3] + "****" + s[-4:]),
    (re.compile(r"bd{17}[dXx]b"), lambda s: s[:6] + "********" + s[-4:]),
    (re.compile(r"bd{16,19}b"), lambda s: "****" + s[-4:]),
]


def _mask_text(s: str) -> str:
    for pattern, repl in _PATTERNS:
        s = pattern.sub(lambda m: repl(m.group()), s)
    return s


def safe_log(template: Template) -> str:
    out = []
    for item in template:
        if isinstance(item, str):
            out.append(_mask_text(item))
        else:
            v = item.value
            # 只对字符串做脱敏,避免影响数字类型的格式化
            if isinstance(v, str):
                out.append(_mask_text(v))
            else:
                out.append(_apply_format(item))
    return "".join(out)

这里引出一个躲不开的问题:插值上的格式化怎么处理。

如果调用方写了 t"金额 {amt:.2f}",直接 str(v) 会丢掉两位小数的格式化。正确做法是先应用 conversion,再应用 format_spec:

def _apply_format(item) -> str:
    value = item.value

    if item.conversion == "r":
        value = repr(value)
    elif item.conversion == "s":
        value = str(value)
    elif item.conversion == "a":
        value = ascii(value)

    if item.format_spec:
        value = format(value, item.format_spec)

    return value if isinstance(value, str) else str(value)

顺序和 f-string 的语义一致:先 conversion 后 format_spec。f"{x!r:>20}" 的含义是”先取 repr,再右对齐到 20 宽”,写反了结果就对不上。

另外,跟普通的 f-string 不同,Interpolation.__format__ 在 t-string 里不会被自动调用。你不处理 format_spec,它就是一个躺在对象里的字符串,谁也不管。这一点刚上手容易漏。

顺带提醒,脱敏规则应该先字符串化再匹配。value 可能是个 Decimal 或者自定义对象,直接喂给正则的 sub 会报类型错误。上面代码里只对 str 类型做脱敏,非字符串走格式化路径,避开这个问题。

几个会踩到的坑

1. Template 不是字符串

最常见的错误是把它当字符串用:

t = t"hello {name}"
t.upper()   # AttributeError

需要字符串时,走转换函数,或者调用 t.format()。后者返回的是跟 f-string 一样的结果,但它不做任何转义,一律不做。真要用它,得先想清楚为什么需要它而不是自己写处理函数。

2. conversion 的值不是符号

item.conversion 拿到的是 'r'、's'、'a',或者 None。不是 '!r',也不是 repr。写判断的时候别写成字符串带感叹号。

3. 嵌套的 t-string 会原样保留

inner = t"a={a}"
outer = t"prefix {inner} suffix"

outer 里那个插值的 value 是另一个 Template 对象,不会自动展开。如果你的场景需要递归处理,得自己写递归,或者在处理前先判断 isinstance(v, Template) 再走一遍转换。不处理的话,日志里就会出现一串 <string.templatelib.Template object at 0x...>。

4. 别把处理函数写成万能转换器

有些实现会在一个函数里判断”这个值是数字就用数字路径,是字符串就用字符串路径,是 SQL 就拼 SQL”。这种做法短期省事,长期必然出问题——调用方根本不知道在什么条件下会走什么分支。

我的建议是一个函数只做一件事:sql() 就只生成 SQL,html() 就只生成 HTML,需要组合的场景,先转成对应的 SafeString 类型再嵌。类型明确,责任才明确。

兼容性怎么处理

t-strings 在 Python 3.14 正式落地,3.13 及以前都不认识 t"" 这个前缀,代码会直接语法报错。这不是运行时兼容问题,是解析阶段就挂。

如果项目暂时升不到 3.14,有两个方向。

一是用 string.Template 或者自研的轻量解析,但损失了拼接表达式的灵活性,t"age {age + 1}" 这种写法就得改成先把 age + 1 算出来再传变量名,用起来差很多。

二是各模板引擎自带的方案。Jinja2 的 Markup、Django 的 mark_safe、SQLAlchemy 的表达式构造器,这些工具在各自领域都很成熟,t-string 的价值在于它不绑定任何领域,一个机制能同时覆盖 SQL、HTML、日志、CLI 输出。如果你手上已经有三个模板引擎在处理同类问题,那 t-string 是值得等的一个统一入口。

还有一点容易忽略:如果项目里同时有 Python 3.12 和 3.14 的环境(比如本地开发用新版,CI 用旧版),含 t"" 的文件需要做版本隔离,或者在 CI 里明确跳过。用 pyproject.toml 里的 requires-python 声明清楚最低版本,比靠口头约定靠谱。

写完这几段代码之后

t-strings 最吸引我的地方,是它没打算变成一个更花哨的字符串格式化工具,而是老老实实承认了一件事:字符串插值本来就不该是语言层面的原子操作,中间应该有函数插手的位置。

我团队里有个习惯,凡是拼接外部输入的地方,一律走转换函数。以前是靠 code review 和自定义 lint 规则硬撑,现在终于有语言层的支持了。三个函数不算多,但省下来的调试时间、以及避免的那次线上事故,值这个价。

如果你的项目里有类似的拼接点,可以先把最简单的那个抽出来试一下。不用一步到位,先跑起来再说。

Python 3.14 t-strings 实战:手写防注入 SQL 构造器与安全 HTML 渲染
收藏 (0) 打赏

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

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

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

淘吗网 python Python 3.14 t-strings 实战:手写防注入 SQL 构造器与安全 HTML 渲染 https://www.taomawang.com/server/python/2938.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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