Skip to content

feat(clickhouse): native protocol support - #414

Open
bernie-g wants to merge 16 commits into
mainfrom
bernie/pam-509-add-native-protocol-support-for-clickhouse-pam
Open

bernie-g wants to merge 16 commits into
mainfrom
bernie/pam-509-add-native-protocol-support-for-clickhouse-pam

Conversation

@bernie-g

@bernie-g bernie-g commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Description 📣

Serves ClickHouse's native TCP protocol alongside HTTP, so clickhouse-client, clickhouse-driver and clickhouse-go work with a PAM account, and a server with HTTP disabled works at all.

One local port, routed per connection by its first byte. The handshake swaps the client's credentials for the account's. Statements arrive in their own packet, so blocking and recording apply as they do over HTTP. A packet the gateway can't read ends the session rather than being relayed uninspected.

Nothing translates between the two interfaces. An account with no HTTP port turns HTTP clients away with an explanation, and an account with no native port does the same for native clients.

Type ✨

  • Bug fix
  • New feature
  • Improvement
  • Breaking change
  • Documentation

Tests 🛠️

136 tests across clickhouse and gateway-v2, no containers and no environment variables. Both packages now run in CI under -race.

Real-client interop was verified by hand through a real session: clickhouse-client and Python's clickhouse-driver across compression, parameters, settings, INSERT and exotic column types, plus native-only, HTTP-only and dual-port accounts. The e2e suite test follows in its own PR once the backend half is on main, the way the other PAM account types were done, since that job runs against infisical/main.

go test -race ./packages/pam/handlers/clickhouse/ ./packages/gateway-v2/...

Serve ClickHouse's native TCP protocol alongside the HTTP interface, so
clickhouse-client, clickhouse-driver and clickhouse-go can be used with a
PAM account, and so a server with HTTP disabled can be used at all.

A session hands out one local port and routes each connection by its first
byte, so the driver decides the protocol rather than the user. The native
handler swaps the client's credentials for the account's during the
handshake, then parses every client packet: the statement arrives in its own
length-prefixed Query packet, which is matched against the command blocking
policy and written to the session recording. Data blocks are decoded only far
enough to find where they end and are forwarded byte for byte, because
re-encoding them would mean reproducing a serialization we do not own. A
packet the loop cannot read ends the session rather than being relayed
uninspected.

For a server with no HTTP interface, the gateway answers HTTP itself by
running the statement over the native protocol. ClickHouse formats the values
through formatRow, so every column type keeps working without the gateway
decoding one. That path exists for Web Access, which is HTTP-only; a
third-party HTTP client is turned away with an explanation.

An account now carries an HTTP port, a native port, or both, with at least one
required. The connection test probes each port that is set and names the one
that failed, and a gateway that does not report native support refuses to save
an account with a native port rather than failing at session time.
@infisical-review-police

Copy link
Copy Markdown

💬 Discussion in Slack: #pr-review-cli-414-feat-clickhouse-native-protocol-support

Posted by Review Police — reviews, comments, new commits, and CI failures will stream into this channel.

@linear

linear Bot commented Sep 25, 2026

Copy link
Copy Markdown

PAM-509

@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5 Tier: plus

[High risk] Adds ClickHouse native protocol support with new dependencies and gateway routing.

The PR appears safe to merge based on the changes since the previous review and the state of the previous threads.

Summary

The PR adds ClickHouse native TCP support alongside HTTP on a single local port.

  • Routes connections by protocol and injects PAM account credentials.
  • Adds bounded native packet decoding, command-policy checks, session recording, dual-port connection tests, and race-tested coverage.

Reviews (15) · Last reviewed commit: "chore(clickhouse): explain the bounded d..."

Comment thread packages/pam/handlers/clickhouse/native.go Outdated
Comment thread packages/pam/handlers/clickhouse/bridge.go Outdated
Comment thread packages/pam/handlers/clickhouse/bridge.go Outdated
Comment thread packages/pam/handlers/clickhouse/native.go
Comment thread packages/pam/handlers/clickhouse/edge_cases_test.go Outdated
Comment thread packages/pam/handlers/clickhouse/bridge.go Outdated
Comment thread packages/pam/handlers/clickhouse/native.go
Comment thread packages/pam/handlers/clickhouse/native.go Outdated
@veria-ai

veria-ai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 3 · PR risk: 0/10

@greptile-apps

This comment has been minimized.

ch-go allocates a declared string length before reading it, so an
unauthenticated client could name a terabyte and take the process down.
Handshake fields are now read through a bounded reader.

The session revision was pinned to min(client, ch-go) and never clamped to
the upstream's. Against a server older than ch-go that put feature-gated
bytes on the wire it never reads, desynchronising the stream.

A native port that accepts the connection and then says nothing dropped the
deadline error, so it classified as a rejected credential and stopped the
heartbeat schedule. It now wraps, and the handshake read is bounded by the
probe's own budget rather than outliving it.

Also refuses a connection test that was given no port, and classifies an
unauthorised port as a transport failure instead of leaving it unknown.
The handler package carried 23 tests behind PAM_CLICKHOUSE_NATIVE_IT that
needed an ambient ClickHouse with hand-seeded tables. No fixtures were
committed and no CI job ran them, so they only ever ran on one machine.

Real-client interop moves to e2e/pam, where testcontainers starts the server
and the container's own clickhouse-client drives the session, matching the
Postgres and Redis suites. The handler package now runs with no containers
and no environment variables, and CI runs it under -race along with
gateway-v2.

e2e is a separate module and its go.mod had gone stale against the new
ch-go dependency, which was failing three CI jobs.
Comment thread e2e/pam/clickhouse_test.go Outdated
Comment thread packages/pam/handlers/clickhouse/native.go
The policy matched the recorded form of a statement, which carries a
"-- parameters:" suffix, while ClickHouse received the bare SQL. An
end-anchored rule stopped matching the moment a client attached a parameter
and the blocked statement ran. Both forms are now checked, on the HTTP path
as well as the native one.

The e2e test drove clickhouse-client from inside the container, which can
never reach a PAM proxy: those bind loopback only, and
TestLocalProxiesBindLoopback enforces it. It now drives ch-go's client from
the host instead.

A handshake bounded by a shorter probe budget also reported the ten-second
limit rather than the one it applied.
Comment thread e2e/pam/clickhouse_test.go Outdated
ch-go allocates a declared string length before it reads a byte and rejects
only a length that goes negative, so a client naming a terabyte took the
gateway down with it. Verified in a 512 MB container: 4 GB allocates fine
because untouched pages never become resident, while 1 TB is SIGKILL, which
no recover can catch, and it takes every other session on that gateway.

The query packet is now decoded field for field with every string read
through a cap, and the settings and parameters lists carry a count bound so
neither can grow without limit. A round-trip test encodes with ch-go and
decodes with ours across three revisions, since a field read in the wrong
order would desynchronise the stream, which is the failure this exists to
prevent.

The handshake was already bounded; those helpers move alongside the rest.
Comment thread packages/pam/handlers/clickhouse/native.go
The ClickHouse ports start listening before the entrypoint has created
CLICKHOUSE_DB, so seeding raced initialisation and clickhouse-client exited
81, UNKNOWN_DATABASE. The wait now runs a query against the database itself,
which is what proves it is usable.

The HTTP helper also asserted on its request, and the wait for the proxy to
bind called it in a retry loop, so a connection refused while the port was
coming up would have failed the test instead of retrying.
The PAM e2e job checks out infisical/main with no ref, so a CLI test can
only exercise backend behaviour that has already merged. Against main the
account's nativePort is stripped as an unknown key and port is still
required, so every ClickHouse account comes back HTTP-only and three of the
four subtests fail on a backend that predates the feature.

Every other PAM e2e test landed this way, in its own PR once the backend was
in place: redis shipped in December and its test followed in April. This one
follows the same route.

The go.mod tidy stays, since that failure is real and independent.
Comment thread packages/pam/handlers/clickhouse/bounded_decode.go
Comment thread packages/pam/handlers/clickhouse/native.go
Comment thread packages/pam/handlers/clickhouse/native.go Outdated
… races

Per-field caps only bound one field at a time, so thousands of individually
legal settings still added up. The query packet now carries one budget across
every field it decodes.

The refusal check sat outside the lock that orders writes to the client, so a
server packet cleared a moment before a refusal could still land after the
exception. The check now happens under that lock.

The server loop returning left the client blocked on a read until the idle
deadline when the upstream went away, waiting for a result that could never
arrive. It now ends the session instead.
…anic

ch-go sizes a column from the declared row count before reading any of it,
so a ~30 byte data block header committed 763 MB for Int64 and 3.2 GB for
Int256 while the client sent nothing further. That allocation succeeds
rather than panicking, so the handler's recover could not catch it. The
block header is now scanned before the decoder sees it, without consuming
it, so an absurd row or column count is refused.

The scan is best-effort by design: anything it cannot parse falls through to
ch-go, so a mistake in it can only miss an attack, never reject real
traffic. It peeks only what has already arrived, since a fixed window would
stall a session whose next block is smaller than it. Compressed blocks are
left alone, being already capped by ch-go.

Session teardown was straight-line after the client loop, so a panic there
skipped it and every in-flight statement vanished from the session log
without even an INTERRUPTED. It is deferred now.

A near-exhausted probe budget rendered as "within 0s" and told the operator
their port was misconfigured, which is a wrong diagnosis for a timeout the
test itself caused. It now says the budget ran out, and the test that locked
in the one duration that rendered correctly asserts the meaning instead.

The upstream-disconnect test set its read deadline after triggering the
teardown it was testing, so it failed about 1% of runs on the setup line
rather than the assertion. 300 runs clean.
Comment thread packages/pam/handlers/clickhouse/bounded_block.go Outdated
The header pre-scan could be skipped by a split header or a compressed block.
The limits now run in the decoder's result hook, which sees the parsed row and
column counts before any column is allocated.
Comment thread packages/pam/handlers/clickhouse/bounded_block_test.go
@bernie-g
bernie-g requested a review from lb-vn September 28, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant