f-stringは便利だが、SQLを組み立てるのに使うと事故が起きる。
name = "x' OR '1'='1"
conn.execute(f"SELECT name FROM users WHERE name = '{name}'").fetchall()
# → [('alice',), ('bob',), ('carol',)] 全件取れてしまうf-stringは評価した時点でただのstrになる。どこまでがSQLで、どこからが埋め込んだ値なのかという情報が残らないので、受け取る側は値をエスケープできない。
3.14のt-string(PEP 750)は、fをtに変えるだけで、文字列ではなくstring.templatelib.Templateを返す。Templateは「固定の文字列部分」と「埋め込んだ値」を分けたまま持っている。
name, age = "alice", 20
t = t"name={name!r} age={age:>5} expr={age + 1}"
t.strings # ('name=', ' age=', ' expr=', '')
t.values # ('alice', 20, 21)
list(t)
# ['name=', Interpolation('alice', 'name', 'r', ''),
# ' age=', Interpolation(20, 'age', None, '>5'),
# ' expr=', Interpolation(21, 'age + 1', None, '')]Interpolationは、評価済みの値(value)のほかに、元の式の文字列(expression)、!rなどの変換指定(conversion)、:>5などの書式指定(format_spec)を持っている。!rも:>5も適用されずにそのまま渡ってくるので、どう解釈するかは受け取る関数が決める。
小さなSQLビルダーを作る
値を?プレースホルダーに置き換え、値そのものは別のリストに分ける関数を書く。matchでInterpolationの種類ごとに振り分けると読みやすい。
import re
import sqlite3
from string.templatelib import Interpolation, Template
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
def sql(t: Template) -> tuple[str, list[object]]:
if not isinstance(t, Template):
raise TypeError(f"expected Template, got {type(t).__name__}")
parts: list[str] = []
params: list[object] = []
for item in t:
match item:
case str(): # 固定の SQL 部分
parts.append(item)
case Interpolation(value=Template() as inner): # 入れ子の t-string
q, p = sql(inner)
parts.append(q)
params.extend(p)
case Interpolation(value=value, format_spec="ident"): # 識別子
if not isinstance(value, str) or not _IDENT.fullmatch(value):
raise ValueError(f"invalid identifier: {value!r}")
parts.append(f'"{value}"')
case Interpolation(value=list() | tuple() as values): # IN 句
parts.append(", ".join("?" * len(values)))
params.extend(values)
case Interpolation(value=value): # 普通の値
parts.append("?")
params.append(value)
return "".join(parts), params
def execute(conn: sqlite3.Connection, t: Template) -> list[tuple]:
query, params = sql(t)
return conn.execute(query, params).fetchall()使ってみる
name = "x' OR '1'='1"
sql(t"SELECT name FROM users WHERE name = {name}")
# → ('SELECT name FROM users WHERE name = ?', ["x' OR '1'='1"])
execute(conn, t"SELECT name FROM users WHERE name = {name}")
# → [] ただの文字列として比較されるだけリストを渡すと、IN句のプレースホルダーを要素の数だけ展開する。
ids = [1, 3]
sql(t"SELECT name FROM users WHERE id IN ({ids})")
# → ('SELECT name FROM users WHERE id IN (?, ?)', [1, 3])t-stringは入れ子にできる。条件を部品として組み立て、別のt-stringの中に埋め込める。値は最後まで値として扱われる。
min_age = 18
cond = t"age >= {min_age}"
sql(t"SELECT name FROM users WHERE {cond} ORDER BY {'age':ident}")
# → ('SELECT name FROM users WHERE age >= ? ORDER BY "age"', [18])テーブル名や列名は?にできない(プレースホルダーに使えるのは値だけ)。そこで、書式指定:identを「これは識別子です」という印として使い、正規表現で検査してから埋め込む。書式指定に何を書くかは受け取る側が決められる、というt-stringの性質を利用している。
table = "users; DROP TABLE users"
sql(t"SELECT * FROM {table:ident}")
# ValueError: invalid identifier: 'users; DROP TABLE users'型チェッカーがf-stringを弾いてくれる
executeの引数をTemplateで型付けしておくと、うっかりf-stringを渡したときに型エラーになる。tをfに書き間違えたら、CIで止まる。
$ mypy --python-version 3.14 app.py
app.py:5: error: Argument 2 to "execute" has incompatible type "str"; expected "Template" [arg-type]
$ pyright --pythonversion 3.14 app.py
app.py:5:15 - error: Argument of type "LiteralString" cannot be assigned to parameter "t" of type "Template"ハマりどころ
実行時の型チェックを省くと、f-stringが素通りする。上のコードの先頭にあるisinstanceの検査を外すと、strもループで回せてしまう(1文字ずつのstrとしてcase str():に入る)。その結果、f-stringが渡されても何のエラーも出ずにインジェクションが成立する。実際に試すと[('alice',), ('bob',)]と全件返ってきた。型チェッカーを通さない呼び出し元もありうるので、実行時の検査は必須。
ほかに気をつける点は次のとおり。
str(t)はSQLを返さない。Templateには「文字列に戻す」決まった方法がなく、str()するとTemplate(strings=(...), interpolations=(...))というreprが返る。これは設計上の意図で、文字列にする方法はいつも受け取る側が決める。Templateとstrは+でつなげない。t"a" + "b"はTypeErrorになる(t"a" + t"b"はTemplateになる)。文字列を後から足していくような書き方は、自然と避けられる。- 値はt-stringを書いた時点で評価される。遅延評価ではない。ループの中で作れば、そのたびに評価される。
- 反復すると空の文字列は飛ばされる。
t"{a}{b}"のstringsは('', '', '')だが、list(t)にはInterpolationしか出てこない。
実務では
自前でSQLを組み立てる小さな関数(社内のバッチ処理、分析用のクエリ)をTemplateしか受け取らない形にしておくと、レビューで「このf-stringは安全か」を確かめる手間がなくなる。同じ考え方は、HTML(値を自動でエスケープする)、シェルのコマンド(値をshlex.quoteする)、ログ(構造化ログで値を別のフィールドに出す)にもそのまま使える。
ただし、本番で使うならSQLAlchemyなどのクエリビルダーで足りることが多い。t-stringが役に立つのは、生のSQLを書きたいが安全も欲しい、という間の領域だ。