Repository navigation
PgSQL: tab/newline in a tracked startup parameter injects extra backend "-c" options into pooled connections #6497
Description
Summary
When ProxySQL opens a PostgreSQL backend connection, it sends the client's tracked variables (DateStyle, IntervalStyle, standard_conforming_strings, TimeZone, search_path) in the startup options value as -c name=value tokens. The value escaper, pg_append_escaped_option_value(), only escapes space and backslash. PostgreSQL's pg_split_opts() splits that string on any isspace() character (tab, newline, VT, FF, CR, space). A client can therefore put a tab in a tracked value and smuggle extra -c options to the backend.
TimeZone has no validator, and the search_path validator accepts any byte inside a quoted part, so both carry such values through. The injected setting is untracked, becomes the backend session's reset value (it survives DISCARD ALL / RESET ALL), and the connection is returned to the pool, which matches only on user and database. Later clients of the same user and database inherit it.
Affected versions / tiers
- All tiers (Stable v3.0.x, v3.1.x, v4.0.x), and both backend paths (libpq conninfo
options=and the native StartupMessage). - Pre-existing: v3.0.11 already sent TimeZone through
escape_string_backslash_spaces(), which had the same gap. It was widened in this cycle:
Root cause
lib/gen_utils.cpp:548-571 (at d7ce280):
while (*p != '\0' && *p != ' ' && *p != '\\') p++;
...
if (*c == ' ' || *c == '\\') {deps/postgresql/.../src/backend/utils/init/postinit.c:517-541, pg_split_opts():
if (isspace((unsigned char) *optstr) && !last_was_escape)
break;lib/PgSQL_Connection.cpp:1311-1320,build_and_record_startup_session_params(): emits-c <name>=+pg_append_escaped_option_value(value)for every variable belowPGSQL_NAME_LAST_LOW_WM.include/proxysql_structs.h:2010: thePGSQL_TIMEZONEentry has validatornullptr.lib/PgSQL_Variables_Validator.cpp:484-498: a quoted part ofsearch_pathaccepts every byte except the closing quote.lib/PgSQL_Protocol.cpp:1456-1513(startup packet) andlib/PgSQL_Session.cpp(SET handling) store the value withclient_set_value()after the (absent) validator.
How to reproduce
Send a StartupMessage to the ProxySQL PgSQL port with a TimeZone parameter that contains a tab, for example:
import socket, struct
params = {"user": "app", "database": "app", "TimeZone": "UTC\t-c\tstatement_timeout=1"}
body = struct.pack("!I", 196608) + b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in params.items()) + b"\0"
s = socket.create_connection(("proxysql", 6133))
s.sendall(struct.pack("!I", len(body) + 4) + body)
# ... complete authentication and run a query so a backend connection is opened, then disconnect(The mechanism was confirmed by code reading and with a copy of pg_split_opts(); I have not run this end to end.)
ProxySQL accepts the value and builds -c TimeZone=UTC<TAB>-c<TAB>statement_timeout=1; the backend applies statement_timeout=1. Disconnect, then connect again as the same user/database with a clean client: queries on the reused backend connection are cancelled after 1 ms. Other payloads work the same way, e.g. -c default_transaction_read_only=on, or -c role=<a role the user is a member of>. A quoted search_path such as 'a<TAB>-c<TAB>statement_timeout=1<TAB>-c<TAB>search_path=b' is also accepted by the validator.
The same value can be stored with SET TIME ZONE ...; it is then used in the startup options of the next backend connection the session opens.
Impact
An authenticated client can change backend settings that ProxySQL does not track, for the whole lifetime of a pooled backend connection, affecting other clients that share the same backend user and database (cancelled queries, read-only transactions, a different role, etc.). The blast radius is limited to the same user and database.
Proposed fix
pg_append_escaped_option_value(): backslash-escape every whitespace characterpg_split_opts()splits on (space,\t,\n,\v,\f,\r), not just space. This alone keeps every value a single option on both backend paths.- Defence in depth: reject control characters (0x00-0x1F, 0x7F) in tracked values at validation time:
- add a TimeZone validator that rejects them (and otherwise accepts the value unchanged; the backend still resolves the zone);
- make the search_path validator reject them inside a quoted part.
Both validators run for the startup packet, forSET, and forpgsql-default_*, and fail with the existinginvalid value for parameter "...": "..."error (SQLSTATE 22023).
Is this really a bug?
Yes. The escaper exists precisely so that a value stays one -c option, and it misses five of the six characters the backend treats as separators; the result is untracked state leaking across clients through the pool. The only judgment call is the validation layer: PostgreSQL itself accepts a few contrived TimeZone spellings containing whitespace control characters (e.g. a leading tab before a numeric offset, E'\t5', because it parses with strtod()), which ProxySQL would now refuse. No real client sends such values, and the escaping fix alone already closes the injection, so the validation layer can be dropped if that strictness is unwanted.
Activity
fix(pgsql): escape all whitespace in backend startup options
fix(pgsql): escape all whitespace in backend startup options