Python 3.14 t-string 实战:从源头堵住 SQL 注入,比参数化查询还好用

2026-09-29 0 224

上个月帮一个朋友看代码,他们做的是一个内部的数据报表系统。有一段查询是这样写的:

def search_orders(date_from, date_to, keyword):
    sql = f"""
        SELECT id, user_id, amount, created_at
        FROM orders
        WHERE created_at >= '{date_from}'
          AND created_at <= '{date_to}'
          AND remark LIKE '%{keyword}%'
    """
    return db.execute(sql)

这段代码跑了一年多,从来没出过事,因为用的人都是同事。但他最近想把系统开放给外部几个合作方用,才想起来得查一遍有没有安全问题。这一查,问题不小——三个参数全是 f-string 直接拼进去的,只要 keyword 传一个 ' OR '1'='1,整张表都被拖出来。

我第一反应是”改成参数化查询不就完了”,但改了一圈发现没那么简单。他们的查询条件是根据前端勾选项动态拼的,有时候带 date_from,有时候不带,写带 WHERE 1=1 加 if 加 params.append 的路子,代码量翻了一倍还多,可读性比原来差不少。同事已经在那儿嘀咕”这样写感觉还不如直接拼”。

这件事在我脑子里挂了挺久。直到 Python 3.14 正式发布,看到 t-string 这个特性(PEP 750),我才意识到这个问题的解法可能从一开始就不一样。

先说清楚 t-string 是什么

t-string 长得像 f-string,只在前面多了一个 t:

name = "world"
msg = t"Hello {name}"

区别是:f-string 求值后是一个 str,t-string 求值后是一个 Template 对象。这个对象里保存着原始信息——哪些地方是固定文本,哪些地方是插进去的值,值的表达式原文是什么,有没有格式化说明符。

打个比方:f-string 是”结果”,t-string 是”配方”。配方的好处在于,你可以决定怎么把配料下锅。

3.14 里 Template 和 Interpolation 放在 string.templatelib 里。一个 Template 对象可以迭代,迭代时会交替产出字符串片段和 Interpolation 对象:

from string.templatelib import Template, Interpolation

user_input = "1 OR 1=1"
tpl = t"SELECT * FROM users WHERE id = {user_input}"

for piece in tpl:
    print(type(piece).__name__, repr(piece))

# str 'SELECT * FROM users WHERE id = '
# Interpolation Interpolation('1 OR 1=1', 'user_input', None, '')

print(tpl.strings)         # ('SELECT * FROM users WHERE id = ', '')
print(tpl.interpolations)  # (Interpolation('1 OR 1=1', 'user_input', None, ''),)

Interpolation 对象上有四个属性:value(求值后的结果)、expression(表达式源码字符串)、conversion(比如 !r)、format_spec(比如 :>10)。这不是一个普通的字符串,它把”值从哪来”这件事也一起带过来了。

用 t-string 写一个 SQL 构造器

回到开头那个问题。我们真正想要的是:SQL 模板里写起来像拼接,但执行时必须走参数化。t-string 恰好能做到。

from string.templatelib import Template

def sql(template: Template) -> tuple[str, list]:
    """把 t-string 拆成 (sql_with_placeholders, params)"""
    if not isinstance(template, Template):
        raise TypeError("expected a t-string")

    pieces = []
    params = []

    for item in template:
        if isinstance(item, str):
            pieces.append(item)
        else:
            pieces.append("?")
            params.append(item.value)

    return "".join(pieces), params

函数不长,但把整件事讲透了:固定文本原样进 SQL,插值一律退化成占位符,值单独装进 params。任何用户输入都没有机会混进 SQL 结构里。

调用侧:

user_input = input("请输入订单号:")   # 用户输入 "' OR '1'='1"

sql_text, params = sql(t"SELECT * FROM orders WHERE id = {user_input}")
print(sql_text)
# SELECT * FROM orders WHERE id = ?
print(params)
# ["' OR '1'='1"]

cursor.execute(sql_text, params)

即便用户输入的是攻击字符串,它也只会被当作一个普通参数值传给数据库驱动,驱动会做正确的转义。SQL 结构从头到尾没被碰过。

动态条件怎么拼

前面那个朋友的痛点在动态条件。用 t-string 处理起来其实很自然——因为参数就装在 Interpolation 里,外面这层可以直接用 Python 的 if 逻辑控制:

def build_search(date_from=None, date_to=None, keyword=None):
    conditions = []
    params = []

    if date_from:
        conditions.append("created_at >= ?")
        params.append(date_from)

    if date_to:
        conditions.append("created_at <= ?")
        params.append(date_to)

    if keyword:
        conditions.append("remark LIKE ?")
        params.append(f"%{keyword}%")

    where = " WHERE " + " AND ".join(conditions) if conditions else ""
    sql_text = "SELECT id, user_id, amount FROM orders" + where
    return sql_text, params

等等,这里我并没有用到 t-string。那 t-string 的用武之地在哪?

真正的场景是:固定的查询模板加少量可变部分。比如预先写好几套查询,只在参数上做变化:

QUERIES = {
    "recent": t"""
        SELECT id, user_id, amount
        FROM orders
        WHERE created_at >= {since}
        ORDER BY created_at DESC
        LIMIT {limit}
    """,
    "by_user": t"""
        SELECT id, amount
        FROM orders
        WHERE user_id = {user_id}
    """,
}

这里模板本身是静态的,只有几个变量位置可变。用 t-string 的好处是:写模板的时候人眼看到的 SQL 是完整的、可读的,运行时却自动变成安全的参数化查询。以前这种情况要么是整段 SQL 用 f-string 拼(不安全),要么是写成一堆占位符然后参数列表对不上(易错)。

一个更实用的增强版

上面的 sql() 只能处理最简单的等值插值。实际项目里,我们还需要支持”标识符(表名、列名)”和”IN 列表”这两个特殊情况。标识符不能参数化,只能白名单;IN 列表需要展开成若干个占位符。

from string.templatelib import Template, Interpolation

class Identifier:
    """包裹一个经过白名单校验的标识符"""
    def __init__(self, name: str):
        if not name.replace("_", "").isalnum():
            raise ValueError(f"invalid identifier: {name!r}")
        self.name = name

    def __str__(self):
        return self.name


def sql(template: Template) -> tuple[str, list]:
    pieces = []
    params = []

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

        value = item.value

        # 标识符:直接嵌入(已经过白名单校验)
        if isinstance(value, Identifier):
            pieces.append(str(value))
            continue

        # 列表:展开成多个占位符
        if isinstance(value, (list, tuple)):
            if not value:
                raise ValueError("empty IN list")
            pieces.append(",".join("?" * len(value)))
            params.extend(value)
            continue

        # 普通值:占位符 + 参数
        pieces.append("?")
        params.append(value)

    return "".join(pieces), params

使用方式:

table = "orders"
since = "2025-01-01"
statuses = ["PAID", "SHIPPED"]

sql_text, params = sql(t"""
    SELECT id, user_id
    FROM {Identifier(table)}
    WHERE created_at >= {since}
      AND status IN ({statuses})
""")

print(sql_text)
# SELECT id, user_id FROM orders WHERE created_at >= ? AND status IN (?,?)

print(params)
# ['2025-01-01', 'PAID', 'SHIPPED']

注意这里 Identifier 是一个显式的包装类。表名是动态的时候,必须用这个类包一下,才能进 SQL 结构。而 {since} 这种普通插值,无论外面怎么传都只会变成占位符。默认安全,特殊情况显式开洞——这个设计比”忘了一个就要出事”的拼接方式稳得多。

t-string 在别的场景也能用

SQL 只是最有代表性的一个。t-string 的本质是”我可以拿到原始文本和插值,然后自己决定怎么组合”。这个能力在很多地方都派得上用场。

HTML 渲染:自动转义

import html

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

用起来:

user_name = "<script>alert(1)</script>"

page = render_html(t"<div class='greeting'>你好,{user_name}</div>")
print(page)
# <div class='greeting'>你好,&lt;script&gt;alert(1)&lt;/script&gt;</div>

以前在 Flask 或 Django 模板里这种转义是框架替你做的。脱离了那套模板系统自己拼 HTML 的时候,很多人会忘。现在可以用 t-string 强制走一遍过滤管线。

日志脱敏:只打印该打印的

我之前参与的一个项目,日志里不小心把用户的手机号、身份证号打出来过。后来加了一层脱敏函数,但总有人忘记调用。用 t-string 可以把脱敏这件事焊死在日志上:

import re

SENSITIVE = {
    "phone": (re.compile(r"^1[3-9]d{9}$"), lambda s: s[:3] + "****" + s[-4:]),
    "idcard": (re.compile(r"^d{17}[dXx]$"), lambda s: s[:6] + "********" + s[-4:]),
}

def mask(value):
    if not isinstance(value, str):
        return value
    for pattern, fn in SENSITIVE.values():
        if pattern.match(value):
            return fn(value)
    return value


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

使用:

phone = "13812345678"
log.info(safe_log(t"用户 {phone} 登录成功"))

# 用户 138****5678 登录成功

有人可能会问:为什么不直接用 f"{mask(phone)}"?因为那样需要开发者每次记得写 mask()。用 t-string 之后,脱敏是”日志输出”这个动作的一部分,改变的是”日志”这件事本身,而不是”每次写日志时记得做的事”。

几个必须知道的细节

t-string 看着简单,但用起来有几个地方容易踩。

t-string 不是 str。 传到哪里都必须过一遍渲染函数。如果你把 t"..." 直接传给 log.info() 或者 print(),它不会自动转成字符串,会打印出 <Template object> 之类的东西。写日志的时候要特别小心这一点。

Template 和字符串不通用。 t"A" + t"B" 不是拼接,是类型错误。需要拼接的话,先转成 str 再拼。

插值是延迟求值的。 这一点很关键。t"{expensive_call()}" 里的表达式是在构造 Template 时求值的,但 value 保存的是结果,不是函数本身。也就是说求值时机和 f-string 一样,是即时求值。如果你期待”传一个懒计算的表达式进去”,那 t-string 做不到这件事,得自己包一层 lambda。

格式化说明符也可以拿到。 t"{x:.2f}" 里的 .2f 会保存到 Interpolation.format_spec。渲染函数可以决定是尊重它还是忽略它。这一点对 SQL 构造器来说反而是好事——所有 format_spec 一律忽略,不给任何机会伪造 SQL。

def strict_sql(template: Template) -> tuple[str, list]:
    pieces, params = [], []
    for item in template:
        if isinstance(item, str):
            pieces.append(item)
        else:
            if item.conversion or item.format_spec:
                raise ValueError(
                    f"SQL 插值不允许 conversion/format_spec:{item.expression}"
                )
            pieces.append("?")
            params.append(item.value)
    return "".join(pieces), params

加了这一层之后,就算有人在模板里写了 t"... {x!r} ...",也会被拦住。所有插值都必须走参数化这一条路。

不要指望用 t-string 做性能优化。 从 CPython 实现看,构造 Template 对象本身还有额外开销。如果只是为了拼一个日志字符串,f-string 还是更快。t-string 的价值在于语义,不在于性能。

和现有方案对比一下

方案 安全性 可读性 动态拼接 备注
f-string 拼接 低 高 容易 SQL 注入高风险
纯参数化查询 高 中 啰嗦 占位符和参数列表要手动对齐
ORM / Query Builder 高 看框架 好 重,学习成本高
t-string + 自定义 render 高 高 好 需要自己写 render 函数,但一次写好到处用

最后一行不是说 t-string 要替代 ORM。对于复杂查询、关联加载、事务管理,ORM 依然有优势。t-string 更适合那种”我想写 SQL,但我不想为了安全写成一堆问号”的场景——报表、数据导出、管理后台的筛选查询,都属于这一类。

落地建议

如果你们项目也准备上 Python 3.14,想把 t-string 用起来,我的建议是分三步走。

第一步,先把 sql() 这个函数加上。 放在项目基础库里,即使暂时没人用也没关系。等有人第一次要拼 SQL 的时候,看到这个工具就会用。

第二步,把现有代码里用 f-string 拼 SQL 的地方全找出来。 用一个简单的正则 f"[sS]*?(select|insert|update|delete) 扫一遍,忽略大小写,把可疑的地方标出来。改的时候未必能用上 t-string,很多地方直接改成参数化查询就行。但至少要把风险点盘清楚。

第三步,把日志脱敏和 HTML 转义这两个 render 函数也写出来。 这两个比 SQL 更容易被人忽略,因为很多人的直觉里”打日志”和”渲染字符串”似乎不是安全敏感操作,实际上这两处泄露数据的案例一点不比 SQL 注入少。

还有一点,t-string 是 3.14 才有的语法。如果项目还在 3.12 或者 3.13,不用急着升级——真要用可以先在工具库里写一个等价的 SafeSQL 类,用 __format__ 和 __str__ 做区分。等 3.14 上了再迁。

最后聊两句

f-string 当年出来的时候,很多人第一反应是”终于不用再写 "...".format() 了”,谁会想到几年后它成了 SQL 注入的重灾区。原因不是 f-string 有问题,而是它太顺手,顺手到让人忘了字符串拼接本身就是一件危险的事。

t-string 想解决的正是这个问题:把”看起来像拼接”和”实际是参数化”两件事分开。写的人看着是拼接,跑出来是安全。这种”不改变书写习惯,但改变执行语义”的设计,往往比”教育大家要小心”管用得多。

我个人的判断是,未来一两年里,t-string 的主要用途不会是什么花哨的功能,而是这种”给顺手加一道闸”的场景——SQL、日志、HTML、Shell 命令、正则表达式,凡是”拼完就危险”的地方,都值得配一个 render 函数。

早点把闸门装上,比出了事再去改代码便宜太多。

Python 3.14 t-string 实战:从源头堵住 SQL 注入,比参数化查询还好用
收藏 (0) 打赏

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

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

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

淘吗网 python Python 3.14 t-string 实战:从源头堵住 SQL 注入,比参数化查询还好用 https://www.taomawang.com/server/python/2835.html

常见问题

相关文章

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

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