Mikhail Shytsko:你的代理正在读取他人的租户数据

Mikhail Shytsko:你的代理正在读取他人的租户数据

💡 原文英文,约5400词,阅读约需20分钟。
📝

内容提要

PgBouncer 事务模式下,会话级 SET 会泄漏给下一个客户端,包括租户键、SET ROLE、search_path、statement_timeout,以及临时表、游标、LISTEN 和咨询锁,导致读到他人数据。修复方法:每次调用包在显式事务中,用 SET LOCAL 设置租户,提交后不留状态;避免 LISTEN 和 SQL 级 PREPARE。

🔎

延伸解读

会话状态泄漏的机制与影响

PgBouncer 事务模式在事务结束后不会自动清理会话级状态,因为默认不发送 server_reset_query。因此,前一个客户端设置的 SET app.tenant、SET ROLE、search_path、statement_timeout 等会保留在后端连接上,被下一个客户端继承。这导致租户隔离失效,下一个客户端可能读到前一个租户的数据,或继承其权限和超时设置。文章通过实验证明,即使客户端没有设置任何参数,也会读到前一个客户端的租户键。

SET LOCAL 的正确使用与常见陷阱

使用 SET LOCAL 在显式事务内设置租户键可以避免泄漏,因为其效果在事务结束时消失。但需注意:SET LOCAL 必须在事务块内执行,否则会收到警告且值可能为空;set_config(..., true) 在自动提交模式下仅对当前语句有效,若设置和查询分两次发送,设置会丢失。此外,一旦自定义参数被设置过,current_setting 返回空字符串而非 NULL,导致 IS NULL 判断失效,COALESCE 也无法提供默认值。

其他会话对象与参数的泄漏风险

除了参数,临时表、WITH HOLD 游标、LISTEN 订阅和会话级咨询锁也会跨客户端泄漏。临时表的数据可被下一个客户端读取,且可能因重名导致创建失败;WITH HOLD 游标在事务提交后仍可被他人 FETCH;LISTEN 订阅属于后端进程,通知会发给任何持有该后端的客户端;会话级咨询锁可被下一个客户端重复获取,导致互斥失效。SQL 级 PREPARE 同样会泄漏,而协议级预处理语句在 max_prepared_statements 为 0 时可能导致静默错误结果。

修复策略与注意事项

修复方法是将每个请求包装在显式事务中,使用 SET LOCAL 设置租户,并确保提交后不留下任何会话状态。避免使用 LISTEN 和 SQL 级 PREPARE,改用协议级预处理语句并保持 max_prepared_statements 大于 0。临时表应使用 ON COMMIT DROP,游标应声明为 WITHOUT HOLD 或在事务内消费,咨询锁应使用事务级函数。启用 server_reset_query_always 可强制清理,但会破坏合法的多语句工作,需权衡。

Q&A

为什么在 PgBouncer 事务模式下,一个客户端设置的租户变量会被下一个客户端读到?

因为 PgBouncer 在事务结束后将后端连接归还到连接池,但不会自动清理会话级状态。普通的 SET 命令设置的是会话级变量,而会话属于后端进程,不属于客户端。除非开启 server_reset_query_always,否则 server_reset_query 只在会话池模式下运行。因此下一个客户端会继承前一个客户端设置的 app.tenant 等变量,导致读到其他租户的数据。

使用 SET LOCAL 设置租户键能防止 PgBouncer 中的租户数据泄漏吗?

可以,但必须将 SET LOCAL 和受其保护的查询放在同一个显式事务中。SET LOCAL 的效果在事务提交或回滚时结束,因此不会泄漏给下一个客户端。但要注意:在事务块外执行 SET LOCAL 只会产生警告并留下空值;在自动提交模式下使用 set_config(..., true) 只对当前语句有效,如果查询在下一个往返中发送,设置已经丢失。

PgBouncer 事务模式支持预处理语句吗?SQL 级 PREPARE 会有什么问题?

PgBouncer 自 1.21.0 起支持协议级命名预处理语句,1.24.0 起默认开启,通过 max_prepared_statements 控制(默认 200)。但 SQL 级 PREPARE 不被跟踪,会留在后端连接上,下一个客户端可以执行它或与其名称冲突。如果将 max_prepared_statements 设为 0,则会出现最坏情况:一个客户端的 Bind 可能执行另一个客户端的语句并返回看似合理的错误结果。

server_reset_query_always 有什么作用?为什么文档不推荐使用?

开启 server_reset_query_always 后,PgBouncer 会在每个事务结束后运行 server_reset_query(即 DISCARD ALL),从而清除泄漏的租户、咨询锁、预处理语句和临时表。但文档不推荐,因为它无法区分调用者泄漏的状态和仍然需要的状态,导致进行合法多语句工作的客户端在每个提交时丢失对象。它只适合在应用程序代码仍需修复时临时使用。

除了租户键,PgBouncer 事务模式下还有哪些会话状态会泄漏给下一个客户端?

会泄漏的会话级状态包括:SET ROLE、search_path、statement_timeout、临时表、WITH HOLD 游标、LISTEN 订阅、会话级咨询锁(pg_advisory_lock)以及 SQL 级 PREPARE 语句。这些状态在事务结束后仍然保留在后端连接上,下一个客户端可以继承或访问它们。而事务级状态如 SET LOCAL、set_config(..., true) 和 pg_advisory_xact_lock 会随事务结束而消失。

如何修复 PgBouncer 事务模式下的租户数据泄漏问题?

修复方法包括:将每个请求包装在一个显式事务中,使用 SET LOCAL 或 set_config(..., true) 设置租户,必要时使用 SET LOCAL ROLE;提交后不留下任何状态。避免使用 LISTEN 和 SQL 级 PREPARE;使用事务级咨询锁(pg_advisory_xact_lock);临时表使用 ON COMMIT DROP;游标声明为 WITHOUT HOLD 或在事务内消费;预处理语句使用驱动协议支持并设置 max_prepared_statements 大于 0。此外,策略应使空字符串失败关闭,并避免在身份判断路径中使用 COALESCE 和 IS NULL。

🏷️

标签

➡️

继续阅读