参数绑定为数据库中的取值提供一个明确位置。姓名里即使带有引号或分号,也仍然只是值,不会因此变成另一段查询条件。复用命名预备语句则是另一项优化:服务端能够在同一个会话内,跨多次执行保留语句信息。安全地绑定数据,并不要求应用为每一条查询都显式建立一个命名语句,也不要求先证明查询会频繁重复。
坏掉的样子 在 Psycopg 中,占位符 %s 属于驱动接口,不是让程序使用 Python 字符串格式化。值应作为 execute 的第二个参数传入,占位符不要加引号,只有一个位置参数时也需要单元素元组。如果先把值插入字符串,再调用一个支持参数化的接口,原本希望保护的边界早就已经被越过;接口名称本身不会替程序补救这次拼接。
占位符代表值,不代表表名、列名或关键字。动态标识符需要明确的结构选择:先用白名单限制支持的列,再用驱动的标识符组合功能正确引用选中的列名。升降序关键字也应由应用控制。分页的数量与偏移量可以直接绑定,同时仍须按产品要求验证范围。能在语法上接收一个整数,不代表允许请求任意大的页面或无限制消耗资源。
预备语句不会把第一次的执行计划永久钉死。PostgreSQL 区分根据本次参数生成的定制计划,以及跨不同参数复用的通用计划;自动选择时会考虑规划开销与估算执行成本。模式或统计信息变化也可能触发重新规划。因此,语句保持参数化时,访问路径仍可以变化。是否复用某种计划,与输入能否变成 SQL 语法,是不同的性质,不能混为一谈。
快速验证 准备安装了 Psycopg 3 的 Python 环境,把 PG_DSN 设置为可丢弃的 PostgreSQL 数据库连接。连接配置沿用已有安全提供方式。实测版本为 Psycopg 3.3.5 与 PostgreSQL 18.6。将下面代码保存为 Python 文件,在已设置该环境变量的终端执行。使用的是临时表,连接关闭后会自动消失:
import os import psycopg from psycopg import sql with psycopg.connect(os.environ['PG_DSN'], autocommit=True, prepare_threshold=5) as conn: conn.execute('CREATE TEMP TABLE people (id integer PRIMARY KEY, label text NOT NULL)') conn.execute("INSERT INTO people VALUES (1,'alice'),(2,'bob')") query = 'SELECT id FROM people WHERE label=%s ORDER BY id LIMIT %s' hostile = "' OR true --" assert conn.execute(query, (hostile, 10), prepare=False).fetchall() == [] count = conn.execute('SELECT count(*) FROM pg_prepared_statements').fetchone()[0] assert count == 0 print('bound without named preparation:', count, 'named statements; no match') for value in ("O'Reilly", r'a\b', 'x; DROP TABLE people; --'): assert conn.execute('SELECT %s::text', (value,), prepare=False).fetchone() == (value,) for label, expected in (('alice', [(1,)]), ('bob', [(2,)])): assert conn.execute(query, (label, 10), prepare=True).fetchall() == expected assert conn.execute('SELECT count(*) FROM pg_prepared_statements').fetchone() == (1,) print('named preparation reused: alice=1, bob=2') def ordered(column, limit): if column not in {'id', 'label'}: raise ValueError('unsupported sort column') if type(limit) is not int or not 1 <= limit <= 100: raise ValueError('limit must be an integer from 1 through 100') statement = sql.SQL('SELECT id FROM people ORDER BY {} LIMIT %s').format( sql.Identifier(column)) return conn.execute(statement, (limit,), prepare=False).fetchall() assert ordered('label', 1) == [(1,)] for column, limit in [('label; DROP TABLE people', 1), ('id', 0), ('id', 101)]: try: ordered(column, limit) except ValueError: pass else: raise AssertionError('invalid request accepted') print('identifier allow-list and bound LIMIT checked') conn.execute('PREPARE plan_lab(text) AS SELECT id FROM people WHERE label=$1') conn.execute('SET plan_cache_mode=force_custom_plan') assert conn.execute("EXECUTE plan_lab('alice')").fetchall() == [(1,)] counters = "SELECT generic_plans,custom_plans FROM pg_prepared_statements WHERE name='plan_lab'" assert conn.execute(counters).fetchone() == (0, 1) conn.execute('SET plan_cache_mode=force_generic_plan') assert conn.execute("EXECUTE plan_lab('bob')").fetchall() == [(2,)] assert conn.execute(counters).fetchone() == (1, 1) print('same prepared statement: custom=1, generic=1') conn.execute('RESET plan_cache_mode') conn.execute('DEALLOCATE plan_lab') 第一项输出显示命名语句数量为零,而且看起来像注入的输入没有匹配任何记录。这次调用明确关闭命名预备,却保留参数绑定,证明安全边界在语句复用之前就存在。随后包含引号、反斜杠、分号的文本都原样返回,表仍可被后面的查询继续使用,字符串里写着删除命令也没有获得执行机会。
...