Skip to content

Instantly share code, notes, and snippets.

@IgorOhrimenko
Last active August 1, 2026 13:59
Show Gist options
  • Select an option

  • Save IgorOhrimenko/703ef2ab3541f8448372ba83f7c78fb2 to your computer and use it in GitHub Desktop.

Select an option

Save IgorOhrimenko/703ef2ab3541f8448372ba83f7c78fb2 to your computer and use it in GitHub Desktop.
PgDog: parameterized set_config() bypasses query-parser interception and poisons pooled connections (search_path leak)

PgDog: parameterized set_config() bypasses interception and poisons pooled connections

SELECT pg_catalog.set_config('search_path', '', false) sent as a literal is correctly intercepted by the query parser (#1055 / #1072) and tracked as session state.

However, the same call sent over the extended protocol with bound parameters:

SELECT pg_catalog.set_config($1, $2, false)
-- Bind: $1 = 'search_path', $2 = ''

is not intercepted: parse_args() only handles A_Const nodes, so with ParamRef arguments it returns None and the statement is passed through as a plain Command::Query without any session-state tracking. The server connection is left with an empty search_path, returns to the pool, and every subsequent client that gets this connection fails with 42P01 relation "..." does not exist on unqualified table names — until the pooler is restarted.

Relevant code: frontend/router/parser/query/set_config.rs (parse_argsNone → "warn and pass through" branch).

Environment

  • pgdog v0.1.51 (latest release), query_parser = "on"
  • transaction pooling (default), plain PostgreSQL 18 backend
  • default_pool_size = 1 in the repro only to make the reuse deterministic; with a larger pool the poisoned connection simply bites intermittently
# pgdog.toml
[general]
query_parser = "on"
default_pool_size = 1
min_pool_size = 1

[[databases]]
name = "pgdog"
host = "postgres"
port = 5432

Reproduction (psql 16+, uses \bind)

# 1. baseline — fresh client through pgdog
psql -h <pgdog> -p 6432 -U pgdog -d pgdog -tA -c 'SHOW search_path'
#  "$user", public

# 2. poison: set_config with bound parameters (extended protocol), then disconnect
psql -h <pgdog> -p 6432 -U pgdog -d pgdog <<'SQL'
SELECT pg_catalog.set_config($1, $2, false) \bind search_path "" \g
SQL

# 3. fresh clients now inherit the poisoned server connection
for i in 1 2 3 4 5; do
  psql -h <pgdog> -p 6432 -U pgdog -d pgdog -tA -c 'SHOW search_path'
done
#  ""  ×5   ← empty search_path leaked across clients

Control cases where this particular path is handled correctly (no leak observed):

  • literal SELECT pg_catalog.set_config('search_path', '', false) — simple protocol
  • the same literal over the extended protocol (\bind \g with no params)
  • SET search_path = '' on its own

Note: this is not the only way a pooled connection can end up with an empty search_path. A separate issue exists where the following sequence poisons the connection with no set_config() involved at all (reproduced on the same v0.1.51):

SET search_path TO '';
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY;
RESET search_path;
ROLLBACK;

That sequence is emitted by pg_dump -t <table>, so a pg_dump through the pooler can still poison a connection regardless of the fix for the case described above.

Expected behavior

When set_config() arguments cannot be resolved to constants, passing the statement through silently leaves the pooler blind to a session-state change. Suggested options:

  • mark the server connection as tainted and issue RESET ALL (or DISCARD ALL) before returning it to the pool, or
  • resolve the bound values from the Bind message where available.

Side note

For the literal set_config over the extended protocol, pgdog's synthesized response is slightly malformed — libpq reports server sent data ("D" message) without prior row description ("T" message). The value is applied and tracked correctly; only the wire response is off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment