Python 3.14 t-string 实战:写四个处理器,把 SQL 注入和 XSS 堵在源头

2026-09-16 0 295

去年帮人看一个内部后台的代码,翻到一处查询大概是这么写的:

keyword = request.args.get("q", "")
sql = f"SELECT id, name FROM products WHERE name LIKE '%{keyword}%'"
cursor.execute(sql)

我说这里有问题,对方回了一句特别经典的:这不是有 WAF 嘛,而且这接口在内网。

这话我听过太多次了。内网、WAF、参数已经过校验,三条里只要有一条成立,写代码的人就会心安理得地把 f-string 直接怼进 SQL。问题在于,f-string 在语法层面就没打算区分”这段文本是我写的”和”这段文本是用户给的”。它把两部分拼成一个平平无奇的 str,交出去的时候,谁也没法复原哪一段来自哪里。下游拿到那个字符串,只能照单全收。

Python 3.14 带来的 t-string 解决的正是这个问题。它不生成字符串,生成的是一个保留了结构信息的对象。这个差别看起来很小,但足以让一整类漏洞在类型层面就写不出来。

一、t-string 到底给了你什么

先确认版本。t-string 是 PEP 750 的产物,跟着 Python 3.14 一起转正,不需要任何 flag:

python -V
# Python 3.14.0

语法上,把 f 换成 t 就行:

from string.templatelib import Template, Interpolation

user = "alice"
query = t"SELECT * FROM users WHERE name = {user}"

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

注意最后一行。它不是 str。你如果直接把 query 丢给 cursor.execute(),会得到一个类型错误——这恰恰是设计意图,逼你必须先想清楚怎么处理它。

这个对象的内部结构很朴素,就是”静态文本”和”插值”交替排列。Template 是可迭代的,也可以拆成两个属性看:

tpl = t"你好,{name},你有 {count:>5} 条消息"

tpl.strings
# ('你好,', ',你有 ', ' 条消息')

tpl.interpolations
# (Interpolation('name', 'name', None, ''), Interpolation('count', 'count', None, '>5'))

strings 永远比 interpolations 多一个元素,因为插值总是夹在两段静态文本中间(首尾可能是空串)。每个 Interpolation 上有四个属性:

  • .value —— 表达式求值后的真实对象,不是字符串。传数字进去拿到的就是数字。
  • .expression —— 表达式在源码里长什么样,比如 "user.password"。这个属性是 f-string 永远给不了你的。
  • .conversion —— !r / !s / !a 里的那个字母,没写就是 None
  • .format_spec —— 冒号后面那截格式说明符,没写就是空串。

换句话说,t"..." 交给你的是一个半成品。怎么组装、要不要转义、转义哪一段,全部由接收方决定。这就把”安全处理”从”开发者的自觉”变成了”处理器的职责”。

二、处理器一:SQL 参数化

最直接的用法。既然插值和静态文本是分开的,那把插值换成占位符、把值塞进参数列表,几乎是照着结构念一遍:

from string.templatelib import Template, Interpolation


def sql(template: Template) -> tuple[str, list]:
    """把 t-string 编译成 (带占位符的语句, 参数列表)"""
    parts: list[str] = []
    params: list = []

    for chunk in template:
        if isinstance(chunk, Interpolation):
            if chunk.conversion or chunk.format_spec:
                raise TypeError(
                    f"SQL 参数不支持转换和格式化: {{{chunk.expression}}}"
                )
            parts.append("?")
            params.append(chunk.value)
        else:
            parts.append(chunk)

    return "".join(parts), params

上面那个 raise 不是画蛇添足。有人会顺手写 {price:.2f},想让参数保留两位小数。但参数化查询要的是原始对象,类型该交给数据库驱动去判断。你把 3.5 格式化成字符串 "3.50" 传进去,字段类型是 DECIMAL 还好,是 INT 就直接炸了,是 VARCHAR 就更糟——数据静悄悄地脏了。宁可在开发阶段报错,也别让它在生产环境里变成一条诡异的记录。

用起来是这样:

stmt, params = sql(
    t"""SELECT id, name, price FROM products
        WHERE category = {category}
          AND name LIKE {pattern}
        ORDER BY price DESC
        LIMIT {limit}"""
)

cursor.execute(stmt, params)

现在再回头看开头那段代码。想改成”用户输入直接拼进 SQL”,语法上根本做不到——{keyword} 一定会走占位符分支,除非你显式地绕过整个处理器。而绕过处理器这个动作,在 code review 里一眼就能看见。

三、识别符和 IN 列表:给处理器留一个受控的例外

参数化查询有个绕不过去的死角:表名、列名、排序方向这些东西没法参数化。数据库驱动只负责值,不负责结构。于是有人就开始写 f"ORDER BY {sort_col}",注入的口子又开了。

t-string 的处理方式很优雅:让处理器认识一种”已经被校验过、可以直接拼”的类型。

import re

_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}Z")


class Fragment:
    """一段可以安全拼进 SQL 的原始片段(可能自带参数)"""

    __slots__ = ("text", "params")

    def __init__(self, text: str, params: tuple = ()):
        self.text = text
        self.params = params


def ident(name: str) -> Fragment:
    """校验并返回一个合法识别符"""
    if not isinstance(name, str) or not _IDENT.match(name):
        raise ValueError(f"不是合法识别符: {name!r}")
    return Fragment(name)


def in_list(values) -> Fragment:
    """展开成 (?, ?, ?) 形式,空列表给一个恒假条件"""
    items = list(values)
    if not items:
        return Fragment("(NULL)", ())
    marks = ",".join(["?"] * len(items))
    return Fragment(f"({marks})", tuple(items))

然后在上面的 sql() 里加一个分支,放在 Interpolation 判断的最前面:

for chunk in template:
    if isinstance(chunk, Interpolation) and isinstance(chunk.value, Fragment):
        frag = chunk.value
        parts.append(frag.text)
        params.extend(frag.params)
    elif isinstance(chunk, Interpolation):
        # ... 走占位符分支
        pass
    else:
        parts.append(chunk)

于是这种写法就成立了,而且全程没有一处字符串拼接来自用户输入:

sort_column = request.args.get("sort", "id")

stmt, params = sql(
    t"""SELECT id, name FROM {ident(table)}
        WHERE status IN {in_list(statuses)}
          AND category = {category}
        ORDER BY {ident(sort_column)} DESC"""
)

sort_column 就算被传成 "id; DROP TABLE users--",也会在 ident() 那一行直接抛 ValueError,根本走不到数据库。

这里有个设计上的取舍值得说一句。Fragment 绕过了参数化,所以它必须是一个只有少数几个工厂函数能造出来的类型。如果允许随手写 Fragment(user_input),那刚才做的所有事都白费了。生产项目里可以考虑给构造函数加个下划线前缀,或者干脆把这个类放进内部模块不导出。

四、处理器二:HTML 自动转义

SQL 之后再看前端渲染,套路完全一样。区别只是”安全”的定义从”不变成语法”变成了”不变成标签”。

from html import escape


class Html(str):
    """已转义或开发者确认可信的 HTML 片段"""


def html(template: Template) -> str:
    out: list[str] = []

    for chunk in template:
        if isinstance(chunk, Interpolation):
            if isinstance(chunk.value, Html):
                out.append(str(chunk.value))
            else:
                out.append(escape(_render_text(chunk)))
        else:
            out.append(chunk)

    return "".join(out)

其中 _render_text 负责把 !r!s 和格式说明符这些 f-string 里现成的东西补回来——t-string 不会替你处理这些,处理器得自己实现:

def _converted(item: Interpolation):
    v = item.value
    c = item.conversion
    if c == "r":
        return repr(v)
    if c == "a":
        return ascii(v)
    if c == "s":
        return str(v)
    return v


def _render_text(item: Interpolation) -> str:
    v = _converted(item)
    if item.format_spec:
        v = format(v, item.format_spec)
    return v if isinstance(v, str) else str(v)

用起来,静态模板部分照常写标签,插值部分自动被转义:

nickname = '<img src=x onerror=alert(1)>'

html(t"<div class="card"><span>{nickname}</span></div>")
# '<div class="card"><span>&lt;img src=x onerror=alert(1)&gt;</span></div>'

html(t"<div>{Html('<b>加粗</b>')}</div>")
# '<div><b>加粗</b></div>'

这种”默认转义、显式标记可信”的模式,跟 Jinja2 的 autoescape 和 markupsafe 的 Markup 是同一套思路。t-string 的好处是它把这个能力带到了模板引擎之外——你在随便哪个返回 HTML 片段的函数里都能用,不用引一个渲染引擎进来。

五、处理器三:shell 命令

再往下走,subprocess 也是个重灾区。shell=True 配上 f-string,等于给用户开了一个 shell:

# 千万别这么写
os.system(f"convert {filename} -resize 800x800 out.png")

处理器版本,本质是给每个插值加引号:

import shlex


def sh(template: Template) -> str:
    out: list[str] = []
    for chunk in template:
        if isinstance(chunk, Interpolation):
            out.append(shlex.quote(_render_text(chunk)))
        else:
            out.append(chunk)
    return "".join(out)


cmd = sh(t"convert {user_filename} -resize 800x800 {out_path}")
subprocess.run(cmd, shell=True, check=True)

user_filename 就算写成 "a.png; rm -rf /",也会被 shlex.quote 包成 'a.png; rm -rf /',整体作为一个参数传给 convert,分号失去意义。

不过还是那句老话:能用列表形式就别用 shell=True。t-string 在这里的价值是,当你不得不走 shell 的时候,至少有一个统一的收口点。

六、处理器四:脱敏日志,顺带解决惰性求值

前面三个处理器用的都是 .value,日志这个场景用的是 .expression。这是 t-string 相比 f-string 最不可替代的一个能力。

先看需求。日志里经常出现这种行:

logger.info(f"登录请求: user={user.name}, pwd={user.password}, ip={ip}")

两个毛病。一是密码进了日志文件;二是就算当前日志级别是 WARNING,这条 f-string 也已经老老实实算完了,白费一次字符串格式化。

用 t-string 的处理器版本:

SENSITIVE = ("password", "passwd", "token", "secret", "id_card", "phone")


def _mask(item: Interpolation) -> str:
    expr = item.expression.lower()
    if any(k in expr for k in SENSITIVE):
        return "***"
    return _render_text(item)


def log_text(template: Template) -> str:
    out: list[str] = []
    for chunk in template:
        if isinstance(chunk, Interpolation):
            out.append(_mask(chunk))
        else:
            out.append(chunk)
    return "".join(out)

调用侧写成 logger.info(t"登录请求: user={user.name}, pwd={user.password}"),输出里 pwd 那一项自动变成 ***。判断依据是源码里的表达式文本,不是运行时的值。这意味着哪怕 user.password 的值恰好是一个空字符串或者 "null",脱敏依然生效——用值去猜字段名,这是黑名单方案的经典失败点。

至于惰性求值,t-string 本身不提供,因为插值在构造 Template 时就已经求过值了。但可以用一个约定补上:处理器发现 .value 是 callable 时,自己决定要不要调用。

def lazy(value):
    """标记为延迟求值,处理器可以在真正需要时才调用"""
    return value


def log_text(template: Template, *, evaluate=True) -> str:
    out: list[str] = []
    for chunk in template:
        if isinstance(chunk, Interpolation):
            v = chunk.value
            if callable(v) and not evaluate:
                out.append("...")
                continue
            out.append(_mask(chunk))
        else:
            out.append(chunk)
    return "".join(out)

调用侧:logger.debug(t"明细: {lazy(lambda: load_huge_payload(sku))}")。日志级别没到 DEBUG 的时候,load_huge_payload 一次都不会被调用。代价是多写一个 lazy(...),换来的是把”这条日志到底要不要算”的决策权收回到了日志系统手里。

七、完整可跑的例子

把 SQL 和 HTML 两个处理器凑一块,跑一个能验证效果的脚本:

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


_IDENT = None  # 省略正则,见上文 ident()


class Fragment:
    __slots__ = ("text", "params")

    def __init__(self, text, params=()):
        self.text = text
        self.params = tuple(params)


def ident(name: str) -> Fragment:
    import re
    if not isinstance(name, str) or not re.match(r"[A-Za-z_][A-Za-z0-9_]{0,63}Z", name):
        raise ValueError(f"不是合法识别符: {name!r}")
    return Fragment(name)


def in_list(values) -> Fragment:
    items = list(values)
    if not items:
        return Fragment("(NULL)", ())
    return Fragment("(" + ",".join(["?"] * len(items)) + ")", tuple(items))


def sql(template: Template):
    parts, params = [], []
    for chunk in template:
        if isinstance(chunk, Interpolation) and isinstance(chunk.value, Fragment):
            parts.append(chunk.value.text)
            params.extend(chunk.value.params)
        elif isinstance(chunk, Interpolation):
            if chunk.conversion or chunk.format_spec:
                raise TypeError(f"参数不支持转换/格式化: {{{chunk.expression}}}")
            parts.append("?")
            params.append(chunk.value)
        else:
            parts.append(chunk)
    return "".join(parts), params


def html_of(template: Template) -> str:
    out = []
    for chunk in template:
        if isinstance(chunk, Interpolation):
            out.append(escape(str(chunk.value)))
        else:
            out.append(chunk)
    return "".join(out)


# --- 跑一遍 ---
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE products (id INTEGER, name TEXT, category TEXT, price REAL)")
conn.executemany(
    "INSERT INTO products VALUES (?,?,?,?)",
    [(1, "机械键盘", "外设", 399.0),
     (2, "无线鼠标", "外设", 129.0),
     (3, "显示器支架", "桌面", 259.0)],
)

category = "外设"
keyword = "%键%"
sort_col = "price"
statuses = [1, 2, 3]

stmt, params = sql(
    t"""SELECT id, name, price FROM products
        WHERE category = {category}
          AND name LIKE {keyword}
          AND id IN {in_list(statuses)}
        ORDER BY {ident(sort_col)} DESC"""
)
print(stmt)
print(params)

rows = conn.execute(stmt, params).fetchall()
print(rows)

# 试试注入
try:
    sql(t"SELECT * FROM products ORDER BY {ident('id; DROP TABLE products--')}")
except ValueError as e:
    print("拦下了:", e)

# 试试带格式化的参数
try:
    sql(t"SELECT * FROM products WHERE price > {39.9:.1f}")
except TypeError as e:
    print("拦下了:", e)

# HTML 转义
print(html_of(t"<p>搜索词: {keyword}</p>"))

输出大致是这样:

SELECT id, name, price FROM products
        WHERE category = ?
          AND name LIKE ?
          AND id IN (?,?,?)
        ORDER BY price DESC
['外设', '%键%', 1, 2, 3]
[(1, '机械键盘', 399.0)]
拦下了: 不是合法识别符: 'id; DROP TABLE products--'
拦下了: 参数不支持转换/格式化: {39.9:.1f}
<p>搜索词: %键%</p>

八、几个绕不过去的细节

第一,str() 拿不到渲染结果。这是刻意的。t-string 没有默认处理器,你想让它变成字符串,就必须自己写一个。有人第一次用会写 print(t"hello {name}"),然后看到 <Template object at 0x...> 一脸懵。这不是 bug。

第二,t-string 不能被 + 拼接,也不能直接塞进 .format()一旦允许拼接,两边拼在一起之后就又退化成没有结构信息的普通字符串了。想组合两个模板,正确做法是在处理器里处理多个 Template,或者把 Fragment 那一套用在模板层面。

第三,嵌套要小心。t"{t'{x}'}" 这种写法里,内层的 t-string 会先求值成一个 Template 对象,外层处理器拿到的 .value 就是一个 Template。如果你没在处理器里递归处理它,输出结果就是那个对象的 repr。

第四,静态检查要跟上。类型标注写成 template: Template,mypy 和 pyright 才会在有人传普通字符串进来的时候报警。更进一步,可以在项目里加一条 lint 规则,把 f"SELECTf"INSERT 这类模式标出来——它们不一定是漏洞,但每一处都值得看一眼。

第五,性能的量级心里要有数。f-string 的格式化是在字节码层面完成的,非常快;t-string 的处理器是一个 Python 层的循环,每次调用要遍历所有 chunk、做 isinstance 判断、调转义函数。单次开销大概在微秒级,比 f-string 慢一到两个数量级。放在一次数据库查询或者一次 HTTP 请求旁边,这个差距可以忽略;但如果是在紧循环里拼几千个短字符串,那还是老老实实用 f-string。

九、老项目怎么过渡

如果生产环境还在 3.13 或者更早,没法直接用 t"" 字面量。但接口是可以先定下来的:

def sql(template) -> tuple[str, list]:
    ...

# 过渡期:手搓一个假的 Template
class _LegacyTemplate:
    def __init__(self, strings, values):
        self.strings = tuple(strings)
        self.interpolations = tuple(values)

def legacy_sql(strings, *values):
    return sql(_LegacyTemplate(strings, values))

调用侧写成 legacy_sql("... {} ...", a, b)。等你升级到 3.14 之后,把包装函数删掉、把调用点改成 t"..." 就行,处理器那一层一个字都不用动。这样迁移的爆炸半径被控制在了很窄的范围里。

说到底,t-string 提供的不是新功能,而是一个新的约定位置。以前你写 SQL 的时候,字符串拼接发生在业务代码里,安全与否取决于写这行代码的人那一刻有没有想起来。现在这个位置被挪到了处理器函数里,写一次,全项目受益,而且后来的人想绕过去,得先绕过类型系统——成本高了很多。对于注入这类”只要有一处漏了就全盘皆输”的问题,把这件事从习惯变成结构,是唯一靠得住的办法。

Python 3.14 t-string 实战:写四个处理器,把 SQL 注入和 XSS 堵在源头
收藏 (0) 打赏

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

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

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

淘吗网 python Python 3.14 t-string 实战:写四个处理器,把 SQL 注入和 XSS 堵在源头 https://www.taomawang.com/server/python/2764.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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