参数绑定为数据库中的取值提供一个明确位置。姓名里即使带有引号或分号,也仍然只是值,不会因此变成另一段查询条件。复用命名预备语句则是另一项优化:服务端能够在同一个会话内,跨多次执行保留语句信息。安全地绑定数据,并不要求应用为每一条查询都显式建立一个命名语句,也不要求先证明查询会频繁重复。

坏掉的样子

在 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')

第一项输出显示命名语句数量为零,而且看起来像注入的输入没有匹配任何记录。这次调用明确关闭命名预备,却保留参数绑定,证明安全边界在语句复用之前就存在。随后包含引号、反斜杠、分号的文本都原样返回,表仍可被后面的查询继续使用,字符串里写着删除命令也没有获得执行机会。

接下来两次显式预备调用复用了同一个命名语句,分别返回爱丽丝的编号一和鲍勃的编号二。连接的阈值配置允许显式预备。在本次驱动版本里,如果把 prepare_threshold 设为 None,命名预备整体被禁用,连显式请求也不会创建它。这个设置不等于关闭绑定,不能根据看不到命名语句就断言查询不安全。

排序函数对受支持的列和绑定上限返回一条结果;对不支持的列,以及一至一百之外的页大小,在发送 SQL 前拒绝。标识符引用负责保持语法正确,白名单负责说明这个入口究竟支持哪些合法结构。这两项职责不能交给任意请求文本决定,尤其不能以为加好了引号,就应该允许所有真实存在的列被外部选择。

最后,同一条 SQL 预备语句分别在强制定制计划与强制通用计划下执行。计数从通用零次、定制一次变成两种各一次,两次也都返回正确行。强制设置只是让差异能够稳定观察,并未测量自动模式面对真实倾斜数据会选择什么,更不是建议把整个服务永久改成某一种强制模式。退出前恢复设置并释放命名语句。

在真实部署中观察复用

PostgreSQL 默认的自动策略先用定制计划执行带参数语句,之后再比较通用方案。是否适合复用,需要结合真实参数分布和执行计划判断。这个只有两行数据的实验没有测量延迟,也没有统计协议往返次数;驱动如何批量发送协议消息、连接建立成本与查询本身开销,都会影响最终测量,不能从“预备”二字推导一个固定网络代价。

命名语句属于服务端会话,连接池和事务级代理必须正确处理这段生命周期。部署环境需要明确支持相应的预备语句行为,或者在不支持时关闭命名复用。即使只执行一次查询,也应默认绑定数据。租户过滤、对象授权和结果大小限制仍须单独成立:一条完全参数化的 SQL,也可能因为缺少业务权限条件而返回调用者无权查看的信息。

延伸阅读

来源