Proxy internals
haltsql-proxy is a PostgreSQL wire-protocol (v3) firewall. This page
explains how it works inside. For configuration and operations, see
../proxy.md. For when to use it, see
deployment-modes.md.
The code lives in lib/pgwire and bin/proxy:
| File | Role |
|---|---|
bin/proxy/main.ml | CLI: run, validate, scram-verifier; signal handling |
lib/pgwire/proxy_config.ml | Loads and validates kind: ProxyConfig; builds each user's or identity's binding (policy + context) |
lib/pgwire/server.ml | Listener, connection limits (total and per-address before authentication), TLS server config, hot reload and which sessions it ends, graceful shutdown |
lib/pgwire/session.ml | One client connection: startup, authentication, upstream search_path check, the enforcing relay, ending at a transaction boundary |
lib/pgwire/enforce.ml | Decisions: verify, transaction-control allowlist, Bind rewriting, denial fields |
lib/pgwire/stmt_state.ml | Prepared-statement bookkeeping across pipelined Sync segments |
lib/pgwire/wire.ml | Message codecs |
lib/pgwire/scram.ml | Server side of SCRAM-SHA-256 |
lib/pgwire/upstream.ml | Connects to the real server (TLS, authentication, pinned search_path); forwards cancel requests |
lib/pgwire/paudit.ml | The audit log: hash-chained records, sinks, failure policies |
lib/pgwire/offload.ml | Bounded thread pool for verification and audit file writes |
lib/pgwire/plog.ml | Operational JSON logs on stderr |
Architecture and trust
The proxy sits on the only network path from untrusted clients to the database. The client holds a proxy credential. Only the proxy holds the database credential.
Four properties follow from this layout:
- The client cannot skip verification. Every
QueryandParsegoes throughHaltsql.verifybefore anything is forwarded. - The client cannot choose its policy or tenant. Both are bound to the authenticated identity in the config, not to anything in the startup packet or the SQL.
- The forwarded SQL is the verified SQL. The SQL bytes sent upstream are
always byte for byte the bytes that were verified. Only
Bindparameter values for tenancy parameters are rewritten. - The server reads names the way the verifier did. The upstream
session's
search_pathis set from the bound policy and confirmed before the first statement, and a session keeps one policy for its whole life.
Startup and authentication
Session.run handles everything up to the first ReadyForQuery. Startup and
authentication together must finish within limits.auth_timeout_seconds
(default 30), otherwise the client gets FATAL 08006.
Details that matter for security:
- Slots before authentication. A connection counts against
limits.max_connectionsfrom accept. Until it has authenticated it also counts againstlimits.max_unauthenticated_per_ip(default 20) for its source address; the connection over that cap is refused at accept withFATAL 53300.Serverkeeps the per-address counter and decrements it whenSessionreports a successful authentication or the connection ends. - TLS. An
SSLRequestis answered withSand a TLS handshake whentlsis configured, otherwiseN. Plaintext bytes pipelined after theSSLRequestare a protocol violation (CVE-2021-23214).GSSENCRequestis always refused withN. Withtls.required: true(the default), a client that did not upgrade getsFATAL 28000. - Startup parameters.
useris required.databasemust equalupstream.database(otherwise3D000). Anyreplicationparameter is refused. Only a printableapplication_name(63 bytes or less) is forwarded.optionsand every other parameter are ignored, so a client cannot set GUCs such assearch_path. A newer protocol minor version or_pq_.*options getNegotiateProtocolVersionfor 3.0. - SCRAM (
auth.method: scram). The proxy implements the server side of SCRAM-SHA-256 (no channel binding) and stores only verifiers in PostgreSQL's format (haltsql-proxy scram-verifiermakes one). An unknown user goes through a full fake exchange using a per-process mock secret, so user names cannot be probed. Every failure is the sameFATAL 28P01 password authentication failed for user "...". - Client certificates (
auth.method: cert). The TLS layer verifies the chain againsttls.client_ca. The proxy then matches the leaf's subject CN or a URI SAN (spiffe_id) againstauth.identities. The startup user name is not used for authorization. - The binding. Authentication returns a
binding: the policy name, the loadedPolicy.t, and theContext.tfrom the config. Validation already checked that the context supplies every value the policy's tenancy needs. Everything after this point uses only the binding. - Upstream sanity. The proxy always sends
client_encoding=UTF8. If the server ever reports a differentclient_encoding, orstandard_conforming_stringsother thanon, the session is terminated. The verified bytes must mean the same thing to Postgres as they did to the parser. - Pinned
search_path. The verifier resolves an unqualified name through the policy'ssearch_path(pg_catalogfirst unless the policy places it). PostgreSQL resolves it through the session'ssearch_path, which by default comes fromALTER ROLE/ALTER DATABASEorpostgresql.confand has nothing to do with the policy. SoUpstream.connectputssearch_pathin the upstream startup packet (which overrides role and database settings): the policy's schemas, each double-quoted, thenpg_temp.pg_tempis explicit and last because PostgreSQL otherwise searches temporary tables before everything else. When the upstream reportsReadyForQuery,Session.confirm_search_pathsendsSHOW search_path, parses the answer the way PostgreSQL parses the setting (Upstream.parse_search_path) and compares the schema lists. The client'sReadyForQueryis held back until the comparison passed, so no client statement can be forwarded before it; a mismatch ends the session withFATAL 08006. PostgreSQL 18 and later also report the setting as aParameterStatus, at startup and on every change, and a reported value that differs ends the session. The path belongs to the session's binding, which never changes (see Lifecycle). - Upstream transport.
upstream.tls.modeisdisable(the default),require(encrypted, any certificate accepted) orverify-full(chain and host verified); only the last authenticates the database. The proxy never sends a cleartext password without TLS, and refuses MD5 authentication without TLS unlessupstream.allow_insecure_authis set.Proxy_config.transport_notesproduces the warning logged at start when TLS is off towards a host that is not a loopback address.
The enforcing relay
After startup, four promises run side by side (Lwt.pick; the first to finish
ends the session):
client_loopreads one frontend message at a time and passes it tohandle_client_message, which verifies, rewrites, replaces or drops it.upstream_loopreads backend messages, updates statement bookkeeping, swaps injected errors for denial errors, and relays everything else.lifetime_timerasks the session to end afterlimits.max_session_lifetime_seconds.terminatorwaits for such a request (from the timer or from a reload) and ends the session at the next transaction boundary (see Lifecycle).
Threads
The proxy is one Lwt event loop: code that blocks blocks every session. Two
things would, so they run on a bounded pool of system threads (Offload,
limits.worker_threads, default the number of CPUs clamped to 2..8):
- Verification.
Haltsql.verifywaits for the parser's own worker pool and then analyses the statement.handle_client_messagehandsEnforce.simple/Enforce.parseto the pool and waits for the verdict. - Audit writes to
stdoutandfilesinks, which are blockingwritecalls (see Audit and logs).
Ordering does not change. client_loop handles one message at a time and
does not read the next one until the current one is verified, audited and
forwarded (or replaced), so pipelined Parse / Bind / Execute reach the
upstream in the client's order; only other sessions run while one waits.
upstream_loop keeps relaying responses to earlier segments meanwhile, which
is the same interleaving Stmt_state already handled for a Bind waiting on
an earlier Parse. A pool job cannot be cancelled: if the session ended
while its job ran, the result is discarded and nothing is forwarded.
Simple protocol
A Query message is verified as a whole (Enforce.simple). It is denied if
the verdict denies, or if the verdict has bind_params: the simple protocol
has no Bind message, so the proxy has no way to supply tenant values. The
denial carries E_PARAM_UNBOUND and a hint to use a prepared statement.
When allow_transaction_control is on (the default), plain BEGIN,
START TRANSACTION [modes], COMMIT, END, ROLLBACK and ABORT pass
without a policy check, so drivers work outside autocommit. The match is exact
token by token (Enforce.transaction_control): comments, quotes, savepoints,
AND CHAIN and PREPARE TRANSACTION do not match and go to the policy.
Extended protocol
This is where tenancy is enforced. The policy proves that each tenancy
predicate compares the tenant column with a parameter such as $1, and the
verdict's bind_params names those parameter positions. The proxy then fills
those positions itself.
Parse. Enforce.parse verifies the SQL and the verdict is audited.
- Denied: the message is replaced by a denial (see below).
- Allowed, but the named statement already exists: refused with
42P05 PROXY_STATEMENT_EXISTS. The proxy never lets a secondParsesilently change what an existing name means. - Allowed: the verdict is recorded as pending for that statement name, and
the
Parseis forwarded.
Declared parameter types. A Parse may declare a type for each
parameter, and PostgreSQL decodes the Bind value with that type. A declared
type such as float8, name or "char" could change how the injected tenant
value is read (rounding, truncation). So
Enforce.reset_tenancy_param_types sets the declared type of every tenancy
parameter to 0 (unspecified), and the server infers it from the tenant
predicate. Other parameters keep their declared types. If nothing changed,
the original bytes are forwarded.
Bind. The proxy looks up the statement's verdict. An unknown statement is
refused with 26000 PROXY_STATEMENT_UNKNOWN. If the verdict has no
bind_params, the Bind is forwarded unchanged. Otherwise Enforce.bind:
- checks that the
Bindsupplies every tenancy parameter position; - refuses binary format for a tenancy parameter (only text format is allowed, so the value cannot be reinterpreted);
- refuses conflicting tenancy values for one position;
- replaces each tenancy parameter's value with the session's context
value, whatever the client sent. A single value is sent as is. A set of
values is sent as a PostgreSQL array literal (
{"a","b"}).
A problem in steps 1 to 3 is a denial with E_PARAM_UNBOUND. The client
cannot make the proxy skip the rewrite: any shape it cannot rewrite safely is
refused.
Execute. A verdict is recorded at Parse, but a prepared statement can
run any number of times. Each Execute therefore writes an execute audit
record before it is forwarded: the portal, the statement the portal was bound
from, that statement's sql_sha256 and fingerprint, and the row limit, never
parameter values. The session keeps a small portal table for this (filled at
Bind, emptied on Close, when the statement is closed, and when the
transaction that owned the portal has ended), so a portal the proxy did not
bind is recorded with a null statement instead of a guessed one. If the
record cannot be written under fail_closed, the Execute is replaced by a
denial like any other refused message.
Other messages. Describe, Close, Flush and Sync are validated and
relayed. Close of a statement removes its verdict right away (removing
early is safe, because a missing entry can only cause a denial).
Denials and ordering
Clients pipeline. A driver may send Parse, Bind, Execute for three
statements and one Sync before reading anything. If the proxy wrote an
error to the client directly, it could overtake the responses to earlier
messages that the server has not answered yet. The driver would then pair
responses with the wrong requests.
Instead, the proxy lets PostgreSQL produce the error at the right position:
- It generates a random token
haltsql_deny_<16 random bytes in hex>and stores it, with the denial's error fields, on the current segment. - It forwards a message that cannot parse in place of the denied one:
- simple protocol: a
Querywhose text is the token; - extended protocol: a
Parsewhose statement name and query are the token. The session then enters discarding mode and drops every client message up to the nextSync, as PostgreSQL itself does after an error.
- simple protocol: a
- PostgreSQL answers with a syntax error at exactly that point in the
stream, and skips to
Syncon its own. upstream_loopsees anErrorResponsewhose message contains the token, and sends the stored denial fields to the client instead.
The client sees a normal PostgreSQL error, in the right position, and the connection stays usable. Inside a transaction the denial aborts the transaction, exactly like a real permission error. The random token is new for every denial and never reaches the client.
The error the client sees:
| Field | Value |
|---|---|
S, V | ERROR |
C | deny_sqlstate (default 42501) |
M | haltsql: query denied by policy "<name>": <first reason> |
D | HaltSQL reasons: <codes> |
H | the first reason's hint, when there is one |
Statement bookkeeping
The proxy must know, for every Bind, which verdict applies to the statement
it names. That is harder than it sounds: a Parse only takes effect when the
server answers ParseComplete, and after an error the server ignores
messages until Sync. Stmt_state models this exactly.
- Messages are grouped into segments: everything up to a
Sync(or one simpleQuery) is one segment. ParseandCloseare queued as pending operations of the current segment.ParseCompletecommits the head operation's verdict to the committed table.CloseCompleteremoves one.ErrorResponsedrops the remaining pending operations of the head segment (the server will ignore them).ReadyForQuerycloses the head segment.- A
Bindlooks in the current segment first, then the committed table. If the name has a pending operation in an earlier segment, theBindwaits until the server has answered it. - A missing entry always denies. Bookkeeping can only err towards denial, and a client cannot get one statement's tenancy positions applied to another statement.
- A simple
Querydestroys the unnamed statement, as PostgreSQL does. An unnamedParsereplaces it. - More than 10,000 pending segments or operations ends the session with
54000 too many pipelined statements.
ConnectingUpstream includes the SHOW search_path round trip: the client
sees its first ReadyForQuery only after it. In Ending the relay works as
before (statements are still verified, under the session's own policy) until
the upstream reports ReadyForQuery with status idle and nothing is in
flight.
Cancel requests
PostgreSQL cancels a running query through a separate connection that sends the backend's (pid, key). The proxy must not hand out the upstream's real pid and key, and must not let one client cancel another's queries.
- During startup, the upstream's
BackendKeyDatais replaced with a proxy-issued pair: a random non-zero 30-bit pid and a random 32-bit key. The process-wide cancel table maps it to the upstream pair. - A
CancelRequestis looked up by pid, and its key is compared in constant time. On a match,Upstream.send_cancelforwards the upstream pair over a fresh upstream connection (with TLS if configured). Wrong keys and unknown pids are logged and ignored. - The entry is removed when the session ends.
COPY, FunctionCall and other messages
| Message | Handling | Why |
|---|---|---|
FunctionCall (fastpath) | Denied in-band with PROXY_FUNCTION_CALL, through the same token trick (a simple Query with the token) | It calls a function by OID without SQL, so there is nothing to verify |
CopyData, CopyDone, CopyFail from the client | FATAL 08P01, connection closed | COPY statements are already denied by the policy (E_STATEMENT_KIND) |
| Upstream enters COPY mode anyway | FATAL 08P01, connection closed | Defense in depth |
Unexpected R, K, v from upstream after startup | FATAL 08P01 | The proxy has lost protocol sync |
| Unknown types, malformed or oversized messages | FATAL 08P01 (oversize is checked against limits.max_message_bytes) | Never guess at bytes it cannot parse |
Terminate | Forwarded, session ends |
Idle sessions are closed after limits.idle_timeout_seconds (off by
default) with FATAL 57P05, but only when no request is in flight.
Audit and logs
Paudit writes the audit log. Every verified statement produces one record,
including statements let through by the transaction-control allowlist, and
every forwarded Execute produces one more:
{"ts":"...","event":"statement","session":"49fd05aacd53","user":"agent",
"policy":"proxy-test","policy_version":"sha256:...","protocol":"extended",
"allowed":false,"reasons":["E_COLUMN_DENIED"],"shadow_reasons":[],
"sql_sha256":"...","fingerprint":"...","tables":["app.users"],
"bind_params":[1],"statement":"","prev_hash":"<64 hex>","hash":"<64 hex>"}
{"ts":"...","event":"execute","session":"49fd05aacd53","user":"agent",
"policy":"proxy-test","policy_version":"sha256:...","protocol":"extended",
"portal":"","statement":"s1","sql_sha256":"...","fingerprint":"...",
"max_rows":0,"prev_hash":"<64 hex>","hash":"<64 hex>"}
bind_paramslists parameter positions only, never values.executerecords carry no parameter values either.- Proxy-level refusals have their own events (
bind,parse,function_call) withreasonsandmessage. - The SQL text appears only with
audit.log_raw_sql: true. - Operational logs (
Plog) go to stderr as JSON lines. Neither stream contains passwords, verifiers, SCRAM messages, keys or bind values.
Hash chain. Records are sealed with the same Audit_chain code as the
haltsql-server audit log: hash = sha256_hex(prev_hash ^ L), L being the
compact JSON of the record without hash, and the first record of a chain
has 64 zeros as prev_hash. Each sink (stdout, file, s3, gcs) keeps
its own chain; a file sink continues the chain of the existing file across
restarts, and refuses to start on a file whose last line is not a sealed
record. haltsql audit verify checks any of them.
A record that cannot be written blocks the statement. With the default
on_error: fail_closed, Paudit.record_async returns an error and the
session replaces the statement (or the Execute) with a refusal: ERROR,
SQLSTATE 58030, PROXY_AUDIT_UNAVAILABLE, delivered in-band through the
same token mechanism as a policy denial. Nothing reaches the database without
its audit record, and a denial that cannot be recorded is refused the same
way. buffer queues records in memory up to max_buffer_records and refuses
beyond that; drop discards, counts and logs. See
../proxy.md.
Order and threads. record_async takes a process-wide FIFO lock, stamps
the record, and writes stdout/file sinks on an Offload thread; object
sinks append to their in-memory batch (and spool) on the event loop, where
their uploader lives. One writer at a time, served in arrival order, means
that in every sink the chain order, the timestamp order and the order in
which statements were accepted are the same. The session waits for the
outcome before it forwards, which is what keeps fail_closed true with the
write off the loop. The retry loop that drains a buffer and the final flush
at shutdown take the same lock.
Lifecycle
haltsql-proxy run -c FILEloads and validates the config.SIGHUP, or a change to the config or a file it pulls in withreload.watch: true, reloads it (Server.reload). A configuration that does not load or validate is rejected and the current one stays in force. The listen address and the audit sinks only change with a restart.- A session has one binding for life. It keeps the configuration snapshot
it was accepted under; a reload never swaps the policy of an open session,
because its upstream
search_pathwas pinned for that policy and its prepared statements were verified under it. - A reload ends the sessions it changes. Each authenticated session is
registered with the listener (
Session.live: principal, credential, binding, snapshot). After a reload,Session.stalecompares it with the new configuration: user or certificate identity removed, SCRAM verifier or client CA bundle changed, bound to another policy, policy content (version) or context changed, upstream changed, orallow_transaction_controlchanged. Such a session is asked to end; all others continue untouched.reload.terminate_changed_sessions: falseturns this off. - Every session ends after
limits.max_session_lifetime_seconds(default one hour,0for no limit), which bounds how long anything a reload does not see can stay in effect. - Ending happens at a transaction boundary. The session goes on until the
upstream reports
ReadyForQuerywith transaction status idle and no segment is in flight (immediately, if it is already there). Then the client getsFATAL 57P01(admin_shutdown, whatpg_terminate_backendproduces, so drivers and pools reconnect) and both connections close. A client message that arrives at that point is neither verified nor forwarded. A session that does not get there withinlimits.termination_grace_seconds(default 30) is closed anyway, so holding a transaction open does not keep an old policy alive; the upstream rolls the transaction back. - New connections above
limits.max_connections, or abovelimits.max_unauthenticated_per_ipfor their address, getFATAL 53300. - On
SIGTERMorSIGINTthe proxy stops accepting, lets sessions finish forshutdown_grace_seconds, closes the rest, flushes the audit sinks and exits0.
Related pages: deployment-modes.md, tenancy-proof.md, verification-pipeline.md, ../defense-in-depth.md.