Skip to content

PgSQL: tab/newline in a tracked startup parameter injects extra backend "-c" options into pooled connections #6497

Open

Description

@renecannao

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:
    • 212a628 ("track search_path with the reported startup settings") moved search_path into the startup-option block, adding a second route;
    • e23c4bd ("carry the session settings in the native StartupMessage") rewrote the escaping as pg_append_escaped_option_value() with the same gap.

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 below PGSQL_NAME_LAST_LOW_WM.
  • include/proxysql_structs.h:2010: the PGSQL_TIMEZONE entry has validator nullptr.
  • lib/PgSQL_Variables_Validator.cpp:484-498: a quoted part of search_path accepts every byte except the closing quote.
  • lib/PgSQL_Protocol.cpp:1456-1513 (startup packet) and lib/PgSQL_Session.cpp (SET handling) store the value with client_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

  1. pg_append_escaped_option_value(): backslash-escape every whitespace character pg_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.
  2. 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, for SET, and for pgsql-default_*, and fail with the existing invalid 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

  1. added 2 commits that reference this issue on Oct 18, 2026
    b218be3
    cc7b987
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions