去年帮人看一个内部后台的代码,翻到一处查询大概是这么写的:
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><img src=x onerror=alert(1)></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"SELECT、f"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 的时候,字符串拼接发生在业务代码里,安全与否取决于写这行代码的人那一刻有没有想起来。现在这个位置被挪到了处理器函数里,写一次,全项目受益,而且后来的人想绕过去,得先绕过类型系统——成本高了很多。对于注入这类”只要有一处漏了就全盘皆输”的问题,把这件事从习惯变成结构,是唯一靠得住的办法。

