PostgreSQL leakproof 函数:行级安全与性能之间的那条线

2026-06-02 24 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:13 分钟

行级安全(Row-Level Security)是 PostgreSQL 提供给多租户场景的一把利器——在数据库层面直接隔离不同租户的数据,省去应用层反复过滤的麻烦。但很多团队真正用上 RLS 后,第一个撞上的墙不是安全,而是性能。查询慢得离谱,索引仿佛失效,原因指向一个不起眼的函数属性:LEAKPROOF。Laurenz Albe 的这篇文章把这个问题拆得很透,也提出了一个值得社区争论的核心问题——leakproof 的门槛到底该设多高?

两种行级隔离手段

PostgreSQL 提供了两个相关特性来隐藏表中不该被看到的行:安全屏障视图(Security Barrier View)和行级安全策略(RLS)。两者的思路一致——在扫描表时自动追加过滤条件,但适用场景各有侧重。

安全屏障视图

从 PostgreSQL 9.2 引入。核心机制是:只要视图的 owner 有权限访问底层表,任何对视图有权限的用户就能通过视图查询,无需直接授权底层表

CREATE TABLE account (
    account_nr bigint PRIMARY KEY,
    owner      text NOT NULL,
    amount     numeric(15,2) NOT NULL
);

CREATE INDEX account_owner_idx ON account (owner);

CREATE VIEW my_account AS
    SELECT account_nr, owner, amount
    FROM account
    WHERE owner = current_user;

GRANT SELECT, INSERT ON my_account TO PUBLIC;

每个数据库角色只能看到自己的账户行,即便他们对 account 表毫无权限。你甚至可以在视图上授予 INSERT——插入的行会落入底层表。如果想防止"插入了自己看不到的行"这种尴尬情况,加上 check option:

ALTER VIEW my_account SET (check_option = cascaded);

行级安全策略

从 PostgreSQL 9.5 引入,弥补视图的几处不足:视图难以叠加多条策略、对数据修改的支持有限、也无法对 SELECT 和 INSERT 施加不同规则。RLS 更灵活:

ALTER TABLE account ENABLE ROW LEVEL SECURITY;
ALTER TABLE account FORCE ROW LEVEL SECURITY;

CREATE POLICY account_sel ON account
    FOR SELECT TO public USING (owner = current_user);

CREATE POLICY account_ins ON account
    FOR INSERT TO public WITH CHECK (owner = current_user);

GRANT SELECT, INSERT ON account TO PUBLIC;

PostgreSQL 实现的方式很直接——扫描表时自动追加条件。执行计划会显示:

EXPLAIN (COSTS OFF) SELECT * FROM account;
QUERY PLAN
--------------------------
Seq Scan on account
  Filter: (owner = CURRENT_USER)

注意豁免规则:表 owner(除非 FORCE)、superuser、带 BYPASSRLS 属性的角色,以及 row_security = off 时,都不受 RLS 约束。

leakproof:安全与性能的枢纽

没有 leakproof 会怎样?

假设你写了一个"会泄露信息"的函数——比如对某些参数值抛出包含参数内容的错误消息。攻击者只需让优化器把这个函数安排在 RLS 条件之前执行,就能绕过行级隔离。

理论上,可以强制优化器永远先执行 RLS 条件。但代价惊人:一条简单的 WHERE account_nr = 42 也无法走主键索引了,因为 bigint 等值比较的底层函数 int8eq 会先于 RLS 条件执行。这意味着几乎所有索引扫描都会失效。

LEAKPROOF 属性的定义

PostgreSQL 的解决方案是给函数打上 LEAKPROOF 标签:

LEAKPROOF 表示函数没有副作用,不会通过返回值以外的渠道泄露关于参数的信息。例如,对某些参数值抛出错误而对其他值不抛出的函数,或在错误消息中包含参数值的函数,都不是 leakproof。

打上 LEAKPROOF 的函数和运算符被信任,可以在 RLS/安全屏障条件之前执行。不带参数或参数不来自受保护表的函数,也无需标记 leakproof 即可提前执行。

对于视图,还需显式启用安全屏障:

ALTER VIEW my_account SET (security_barrier = on);

只有确实承担安全职责的视图才该开启——普通视图没必要牺牲性能。

保守的现状

PostgreSQL 传统上偏向谨慎——宁可慢也不冒数据风险。v18 标准安装中只有 345 个 leakproof 函数,绝大多数是基础类型的比较函数。实际效果是:只有基于基础类型的 B-tree 索引扫描能被推到 RLS 条件之前。这意味着大量使用 RLS 的用户遭遇性能滑坡,成为该特性推广的主要绊脚石。

EXPLAIN (ANALYZE):最显眼的侧信道

文档明确说了:安全屏障视图和 RLS 只保证"不可见的行不会被传给可能不安全的函数",不保证用户无法通过其他手段推断隐藏数据。侧信道攻击是真实存在的。

最简单的演示:

SET enable_seqscan = off;

EXPLAIN (ANALYZE, COSTS OFF, BUFFERS OFF)
SELECT * FROM account WHERE owner = 'duff';
Index Scan using account_owner_idx on account (...)
  Index Cond: (owner = 'duff'::text)
  Filter: (owner = CURRENT_USER)
  Rows Removed by Filter: 1
Execution Time: 0.076 ms

owner = 'duff' 用了 leakproof 的 text 等值运算符,所以优化器把它推到了 RLS 条件之前,走上了索引扫描。但 Rows Removed by Filter: 1 直接告诉你:数据库里存在一个 owner 为 duff 的账户。泄露信息的不是运算符,是 EXPLAIN 本身

其他侧信道同样存在:

  • pg_stat_get_tuples_returned() 诊断函数(无并发访问时可直接推断行数)
  • 时间攻击:反复执行同一语句,RLS 过滤额外行带来的执行时间增量可以被统计出来
  • 执行计划差异:过滤掉不同数量的行可能导致优化器选择不同计划,计划本身成为信息源

Albe 曾提议在涉及 RLS 时将 EXPLAIN (ANALYZE) 限制为 pg_read_all_stats 角色成员。社区的反驳是:堵一个洞可能让人误以为 RLS 变得更安全了,而其他侧信道依然敞开;同时限制 EXPLAIN 对性能调优的伤害可能大于有限的安全收益——如果每个开发者都申请加入 pg_read_all_stats,反而削弱安全。Albe 最终撤回了补丁,但仍不完全认同"因为还有别的洞就不堵这个大洞"的逻辑。

leakproof 的门槛该多高?

文档的措辞很严格:任何会泄露参数信息的行为——包括抛出包含参数值的错误消息——都让函数不能标记为 leakproof。Albe 提出了一个具体案例:内存分配失败时的错误消息。

/* dsa.c 中的错误消息示例 */
ERROR: out of memory
DETAIL: Failed on DSA request of size 1234.

如果分配的内存量取决于某个数据值的大小,这条错误消息就泄露了关于该数据的信息。攻击者可以对服务器施加精确的内存压力,使分配失败,从而推断隐藏数据的尺寸。

但 Albe 质疑这种场景的实际可利用性:如果你不能控制数据库服务器、不能执行任意 SQL,怎么施加受控的内存压力?反过来,如果把 OOM 错误也算作泄露,那几乎所有涉及内存分配的函数都不能标记 leakproof,RLS 的性能将更加不可用。

他的观点是:只有当攻击者能通过向 SQL 语句(由应用控制)提供参数值来触发泄露信息的错误时,才应该拒绝 leakproof 标记。更高的标准会让一个本该好用的特性在实际应用中变得无用。

实践建议:如何正确使用 RLS

基于以上分析,行级安全不应该被视为"能抵御执行任意 SQL 的用户"的铜墙铁壁。正确的使用姿势是:

  1. 在应用层控制 SQL——用户只能影响参数值,不能自由构造查询。RLS 在这个场景下是可靠的。
  2. 对承担安全职责的视图启用 security_barrier,普通视图不要开。
  3. 谨慎标记自定义函数为 LEAKPROOF——只有确实不泄露任何参数信息的函数才该标记,且只有超级用户能设置。
  4. 不要向不受信任的用户暴露 EXPLAIN (ANALYZE)——即便社区没堵这个洞,你可以在应用层控制。
  5. 关注侧信道——如果数据敏感性极高(比如医疗、金融),RLS 可能不够,考虑更严格的隔离方案(如分库)。

一个可运行的完整示例,展示 RLS 的基本配置和 leakproof 对执行计划的影响:

-- 1. 创建表和索引
CREATE TABLE account (
    account_nr bigint PRIMARY KEY,
    owner      text NOT NULL,
    amount     numeric(15,2) NOT NULL
);
CREATE INDEX account_owner_idx ON account (owner);

-- 2. 插入测试数据
INSERT INTO account VALUES
    (1, 'alice', 100.00),
    (2, 'bob',   200.00),
    (3, 'carol', 300.00);

-- 3. 创建受限角色
CREATE ROLE rls_user;
GRANT SELECT, INSERT ON account TO rls_user;

-- 4. 启用 RLS
ALTER TABLE account ENABLE ROW LEVEL SECURITY;
ALTER TABLE account FORCE ROW LEVEL SECURITY;

-- 5. 创建策略:每个用户只能看自己的行
CREATE POLICY account_sel ON account
    FOR SELECT TO rls_user USING (owner = current_user);

-- 6. 用 rls_user 身份测试
SET ROLE rls_user;

-- 6a. 简单查询:owner 等值条件用了 leakproof 运算符,可以走索引
EXPLAIN (COSTS OFF) SELECT * FROM account WHERE owner = current_user;
-- 预期:Index Scan,条件被推到 RLS filter 之前

-- 6b. 用非 leakproof 函数的条件,优化器必须先执行 RLS filter
EXPLAIN (COSTS OFF) SELECT * FROM account WHERE lower(owner) = lower(current_user);
-- 预期:Seq Scan 或 Filter 顺序不同,性能可能显著下降

-- 7. 恢复身份
RESET ROLE;

-- 8. 清理(可选)
DROP ROLE rls_user;
DROP TABLE account;

第 6b 步中 lower() 不是 leakproof 函数,优化器不敢把它推到 RLS 条件之前,查询可能退化为顺序扫描。这正是很多 RLS 用户遭遇性能问题的根源——一旦 WHERE 条件里出现非 leakproof 的函数或运算符,索引优势就消失了。

核心取舍很清晰:leakproof 标记越严格,RLS 越安全但越慢;标记越宽松,性能越好但侧信道风险更大。Albe 的建议是放宽到"应用可控参数值无法触发泄露"的程度,这个标准是否合理,值得每个在生产中使用 RLS 的团队思考。


相关推荐