t-string 实战:用 Python 3.14 模板字符串写一个防注入的 SQL 拼装器

2026-10-21 0 654

SQL 拼接出问题,套路永远是同一个:业务要”灵活”,表名、列名、排序字段都从请求参数里来,开发者图省事,直接一个 f-string 拼进去。写的时候觉得”这些字段都做了校验的”,一年后有人加了个新参数,校验漏了,线上出事。

Python 3.14 引入的模板字符串(t-string,PEP 750)第一次把”插值这件事”变成了语言可以拦截的对象。它不是新的字符串格式化语法糖,而是换了一种返回类型:f-string 立即拼成 str,t-string 返回一个 Template 对象,里面每一项插值都是结构化的,你的函数可以决定每一项怎么落地。

这篇拿一个完整的 SQL 构建器把这个特性走一遍,包括我写第一版时踩的三个坑。

一、先看清 t-string 拿到了什么

语法只差一个字母:

name = "张三"
n = 3

s = f"你好 {name},你有 {n} 条消息"    # 立即得到 str
t = t"你好 {name},你有 {n} 条消息"    # 得到 Template

print(type(s))   # <class 'str'>
print(type(t))   # <class 'string.templatelib.Template'>

Template 暴露两个平行的序列:

>>> t.strings
('你好 ', ',你有 ', ' 条消息')

>>> t.interpolations
(Interpolation('张三', expression='name', conversion=None, format_spec=None),
 Interpolation(3, expression='n', conversion=None, format_spec=None))

规律很清楚:strings 永远比 interpolations 多一个元素,也就是”静态片段和插值交替出现”。拼接时的顺序是 strings[0] + interp[0] + strings[1] + interp[1] + ... + strings[-1]。

每个 Interpolation 有四个属性:

属性 含义 例子
value 表达式求值后的实际对象 3、"张三"、某个 dataclass 实例
expression 花括号里的源码文本 'n'、'user.id'
conversion 转换符,None 或 's'/'r'/'a' 来自 {x!r}
format_spec 格式说明符,None 或字符串 来自 {x:.2f}

有个常见误解得先澄清:t-string 并没有延迟求值。{n} 里的 n 在创建 Template 的那一刻就已经求值完了,结果存在 Interpolation.value 里。延迟的是”拼接成字符串”这一步,不是”求值”这一步。所以指望用 t-string 来做惰性日志求值是行不通的,那件事得靠 logger.debug("%s", arg) 这类风格。

二、为什么这个特性天生适合防注入

关键在于类型上:Template 不继承 str。这一点是刻意设计的。

>>> isinstance(t, str)
False

>>> "SELECT " + t
TypeError: can only concatenate str (not "Template") to str

假如 Template 能冒充 str,那么所有接受字符串的库(包括数据库驱动)都会默默接受它,conn.execute(t) 直接拼进去,这个特性就废了。正因为不是 str,任何不懂 Template 的 API 都会当场报错,逼迫调用方显式处理。

这意味着只要你把”接收 SQL 的函数签名写成 Template 而不是 str“,调用方就不可能绕过你的构建逻辑去拼裸字符串 —— 除非他自己调 str(),那属于主动作死,后面单独说。

三、搭一个 SQL 构建器

设计上分两条路径走:插值如果是我们约定的”标识符包装类”,就走白名单校验后直接拼进 SQL;其他任何对象一律转成参数占位符,由驱动做参数化。

先写包装类。这种”必须显式包装才能当标识符”的设计,好处是调用方无法用 t"SELECT * FROM {table}" 偷偷插表名 —— 编译器不会拦,但你的 build_sql 会把 table 当成值,变成 SELECT * FROM ?,SQLite 直接语法错误。失败得早,总比线上被注入好。

import re

class Ident:
    """标记一个字符串是 SQL 标识符(表名 / 列名 / 排序字段),而非值"""

    __slots__ = ("name",)
    _PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")

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

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

    def __repr__(self) -> str:
        return f"Ident({self.name!r})"

接着处理 Interpolation。f-string 里可以写 {x!r} 和 {x:.2f},t-string 保留了同样的能力,所以拿最终值的时候需要按顺序应用 conversion 和 format_spec。这个小逻辑自己写比依赖不确定的辅助函数更稳:

def _resolve(interp):
    value = interp.value

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

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

    return value

然后是按方言生成占位符,以及标识符的引号转义(SQL 标准是双引号,内部双引号翻倍):

_PLACEHOLDER = {"sqlite": "?", "mysql": "%s", "postgres": "%s"}

def quote_ident(name: str) -> str:
    return '"' + name.replace('"', '""') + '"'

def build_sql(template, dialect="sqlite"):
    if not isinstance(template, Template):
        raise TypeError("build_sql 只接受 t-string,收到裸字符串请改用参数化查询")

    ph = _PLACEHOLDER[dialect]
    parts = []
    params = []

    for i, chunk in enumerate(template.strings):
        parts.append(chunk)
        if i < len(template.interpolations):
            value = _resolve(template.interpolations[i])
            if isinstance(value, Ident):
                parts.append(quote_ident(value.name))
            else:
                parts.append(ph)
                params.append(value)

    return "".join(parts), tuple(params)

入口那行 isinstance 检查值得留着。它挡住的不只是误传,更是一个隐性的迁移保护:老代码里如果有人图省事,直接把自己的 f-string 传进来,会当场 TypeError,而不是悄悄绕过全部防护。

四、跑一遍,用 SQLite 验证

import sqlite3

conn = sqlite3.connect(":memory:")
conn.executescript("""
CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, body TEXT);
INSERT INTO articles (title, body) VALUES
  ('t-string 上手', 'body a'),
  ('SQL 注入防范', 'body b'),
  ('Python 3.14 概览', 'body c');
""")

table = "articles"
keyword = "SQL"
order = "title"
limit = 10
offset = 0

sql, params = build_sql(
    t"SELECT {Ident('id')}, {Ident('title')} FROM {Ident(table)} WHERE {Ident('title')} LIKE {f'%{keyword}%'} ORDER BY {Ident(order)} DESC LIMIT {limit} OFFSET {offset}"
)

print(sql)
# SELECT "id", "title" FROM "articles" WHERE "title" LIKE ? ORDER BY "title" DESC LIMIT ? OFFSET ?

print(params)
# ('%SQL%', 10, 0)

for row in conn.execute(sql, params):
    print(row)
# (2, 'SQL 注入防范')

生成的 SQL 里,标识符已经引号包裹,值全部变成了 ?。数量对得上:插值 6 个,其中 2 个是被识别为 Ident 的(表名、排序列),再加上 id、title 两个列名也是 Ident,一共 4 个标识符、2 个值,params 里两个元素,正确。

换成”恶意输入”再试:

keyword = "' OR 1=1 --"
sql, params = build_sql(
    t"SELECT * FROM {Ident('articles')} WHERE {Ident('title')} LIKE {f'%{keyword}%'}"
)
print(params)
# ("%' OR 1=1 --%",)

print(conn.execute(sql, params).fetchall())
# []  —— 被当成普通字符串做 LIKE 匹配,没有命中任何行

# 如果攻击者试图控制表名
try:
    build_sql(t"SELECT * FROM {Ident('articles; DROP TABLE articles--')}")
except ValueError as e:
    print(e)
# 非法的 SQL 标识符: 'articles; DROP TABLE articles--'

两条路径都堵上了。值走参数化,永远不会进入 SQL 语义;标识符走正则白名单,分号、空格、连字符一律拒绝。

五、坑一:conversion 会把你包装的对象拆掉

写第一版时,我在日志里看到过下面这个诡异报错:

sql, params = build_sql(
    t"SELECT * FROM {Ident(table)!r}"
)
# OperationalError: near "Ident": syntax error

原因是 !r 让 _resolve 返回了 repr(Ident('articles')),也就是字符串 "Ident('articles')"。这时候 isinstance(value, Ident) 已经是 False 了,走的是参数化分支,SQL 变成了 SELECT * FROM ?,SQLite 报语法错。

从机制上讲这是对的 —— !r 的意思本来就是”转成 repr 字符串”,转换之后原对象就没了。但写代码的人很容易在调试时随手加个 !r,然后把问题归咎于”这个特性不好用”。踩过之后就记住:想走标识符分支,就绝对不能加任何 conversion 或 format_spec。

代码层面可以在 build_sql 里加一条断言,把这个错误提前暴露出来:

if interp.conversion or interp.format_spec:
    if isinstance(interp.value, Ident):
        raise ValueError(
            f"{interp.expression!r} 是标识符,不能加转换符或格式说明符"
        )

六、坑二:str() 是逃生舱,一按全废

这个坑不出现在代码里,出现在 code review 里。有人觉得 _resolve 那套太绕,直接:

stmt = t"SELECT * FROM {Ident(table)} WHERE id = {user_id}"
print(str(stmt))
# SELECT * FROM articles WHERE id = 42

看起来输出非常”正常”,甚至比 build_sql 的结果更像人话。但这行 str(stmt) 一旦执行,Template 就塌缩成了普通字符串,42 已经拼进 SQL 文本里了,参数化完全消失。如果 user_id 来自用户输入、又没经过严格类型约束,这里就是标准的注入点。

更麻烦的是这个错误很难在测试里被发现,因为它”看起来是对的”。所以我把 build_sql 的第一行写成 isinstance 检查,就是防止有人在外面 str() 之后又传给内部构建器,形成一条”看似经过了防护”的假路径。

团队规范层面也可以加一条:在 SQL 相关模块里禁止对 Template 调 str(),用 lint 规则或者简单的代码检查脚本扫一遍就行。

七、坑三:format_spec 里的字符串不受控

下面这段看起来无害,其实留了个口子:

def build_sql(template, dialect="sqlite"):
    ...
    if interp.format_spec:
        value = format(value, interp.format_spec)
    ...

问题在于 format_spec 来自模板源码,而模板源码有可能是拼接出来的(比如从配置文件、用户提供的模板字符串里生成)。如果攻击者能让 format_spec 变成 ">1000000",虽然它不会注入 SQL,但可以把一个整数格式化成一千万个填充字符,撑爆内存。

对于 SQL 构建这个场景,最省事的做法是:值类型只接受 int、float、str、bool、None、datetime 等基础类型,超过这个范围直接抛错。够用,也够安全:

_ALLOWED_VALUE_TYPES = (int, float, str, bool, type(None))

# 在 build_sql 末尾
for p in params:
    if not isinstance(p, _ALLOWED_VALUE_TYPES):
        raise TypeError(f"不支持的参数类型: {type(p).__name__}")

复杂对象在进入 SQL 层之前应该已经转成值,比如 Decimal 转字符串、date 转 ISO 格式。多这一道检查,能把很多”类型在驱动层才炸”的问题提前到业务代码里。

八、同一个思路用在 HTML 上

SQL 之外,HTML 拼接是另一个经典注入面。用同样的模板结构,改 build_sql 里的分支就行:

from html import escape

def render_html(template):
    if not isinstance(template, Template):
        raise TypeError("render_html 只接受 t-string")

    parts = []
    for i, chunk in enumerate(template.strings):
        parts.append(chunk)                       # 静态片段原样保留
        if i < len(template.interpolations):
            value = _resolve(template.interpolations[i])
            parts.append(escape(str(value), quote=True))
    return "".join(parts)

注意静态片段不做转义,只有插值转义。这个判断是对的:<b>{name}</b> 里的 <b> 是开发者自己写的标签,转义了页面就没法看了;而 name 是外部数据,必须转义。f-string 做不到这个区分,因为到了 f-string 里,静态部分和插值部分已经融为一体了。

user_input = '<script>alert(1)</script>'

print(render_html(t"<p>你搜索的是:{user_input}</p>"))
# <p>你搜索的是:&lt;script&gt;alert(1)&lt;/script&gt;</p>

这种”默认安全,转义粒度精确”的写法,比 Jinja2 的自动转义更细,因为它不需要你区分整个模板是 HTML 还是 JS,只需要你在每个插值处决定转义方式。

九、顺手再做一个结构化日志

日志场景里,Interpolation.expression 是个宝藏 —— 它是花括号里的源码文本,可以拿来做字段名:

def log_payload(template):
    if not isinstance(template, Template):
        raise TypeError("log_payload 只接受 t-string")

    msg = template.strings[0]
    fields = {}

    for i, interp in enumerate(template.interpolations):
        key = interp.expression.strip()
        fields[key] = _resolve(interp)
        msg += "{" + key + "}"
        msg += template.strings[i + 1]

    return msg, fields

msg, fields = log_payload(
    t"用户 {user_id} 在 {ip} 登录失败,原因:{reason}"
)
print(msg)
# 用户 {user_id} 在 {ip} 登录失败,原因:{reason}
print(fields)
# {'user_id': 10086, 'ip': '10.0.0.7', 'reason': '密码错误'}

配合 logging.Logger.info(msg, extra=fields),日志系统能直接把这些字段索引起来,检索 user_id:10086 这样的查询就成了键值查找,不用再正则解析日志行。

有个小限制:expression 是源码文本,{user.id} 得到的键是 "user.id",带点号,很多日志后端的字段名不接受点号。所以要么在业务里写成简单变量 {user_id},要么在 log_payload 里做一次 replace(".", "_")。我在项目里选了前者,因为简单变量读起来也清楚。

十、兼容性和迁移

t-string 是语法级特性,没法 polyfill。老版本的 Python 看到 t"..." 会直接语法报错,连 import 都撑不到。所以升级路径只有两条:

路径一:直接把项目升到 3.14。这是正路。3.14 的兼容性相对平稳,主要的破坏性变更集中在废弃 API 上,跟 t-string 无关。如果项目依赖库版本锁得比较死,先跑一遍全量测试,把 from __future__ import annotations 之类的遗留注解处理掉。

路径二:库的 API 设计成双模。把 build_sql 的签名放宽成 Template | str,收到裸字符串时走一条显式的、不编译的路径:

def build_sql(template, dialect="sqlite"):
    if isinstance(template, str):
        # 明确拒绝,或者返回一个需要调用方自己填参的骨架
        raise TypeError("请使用 t-string 构建 SQL")
    if not isinstance(template, Template):
        raise TypeError(...)
    ...

这样同一个库在 3.13 和 3.14 里都能 import,只是 3.13 里用不了真正的构建功能,调用会报错。听起来鸡肋,但它能让整个项目在过渡期里保持可安装、可测试,逐步替换调用点。等全部改完,删掉拒绝分支就行。

十一、写在最后

t-string 最被低估的地方,不是”多了一种字符串语法”,而是它把”外部数据”和”模板结构”在类型层面分开了。以前我们靠约定(”记得参数化”)和 code review(”你看这里拼了字符串”)来防注入,现在可以靠类型系统——函数签名要求 Template,调用方写 f-string 会当场报错。

当然它不会自动让代码变安全。像上面第六节那种 str(stmt) 的写法,永远能绕过所有类型约束。工具能做的,是把”无意中犯错”的概率压下去,把需要解释的边界变得清晰。至于剩下那部分,还是得靠人。

t-string 实战:用 Python 3.14 模板字符串写一个防注入的 SQL 拼装器
收藏 (0) 打赏

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

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

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

淘吗网 python t-string 实战:用 Python 3.14 模板字符串写一个防注入的 SQL 拼装器 https://www.taomawang.com/server/python/2965.html

常见问题

相关文章

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

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