Skip to content

Commit 689f393

Browse files
authored
Validate duckgres.query_source against its allowed values (#948)
The duckgres.query_source session GUC is a billing dimension: it flows verbatim from the session into the compute-usage bucket key (duckgres_org_compute_usage.query_source) and onward into the billing pull API. It previously accepted ANY string on every set path (simple SET, split multi-statement batches, extended-protocol Parse, and the -c startup option), handing clients unbounded cardinality and arbitrary junk (10KB strings, control characters) in the billing table and its exports. Now the value is a closed enum, validated once at the transpiler interception (transform.NormalizeQuerySource) so every SQL path rejects an invalid value with SQLSTATE 22023 before anything is stored on the session: 'invalid value for "duckgres.query_source": must be "standard" or "endpoints"'. Matching is case-insensitive and normalized to lowercase; empty resets to the default (unchanged semantics); a SET whose value is not a single simple constant/identifier is rejected instead of silently resetting. An invalid startup option rejects the connection with FATAL 22023, matching the duckgres.worker_* startup options. The rejection message deliberately does not echo the offending value (it is arbitrary client input and error text lands in logs and the admin recent-errors ring). Defense in depth: server.ConnectionBilling clamps a non-canonical value to "standard" at the metering boundary (warn log, value length only), so a future validation bypass still cannot write junk into the billing bucket key.
1 parent ad379d9 commit 689f393

10 files changed

Lines changed: 450 additions & 35 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,15 @@ admin APIs, never clearable via the admin API, so every org always has one;
470470
resolved from the config snapshot at connection end, where 0 can still appear
471471
only for an unknown org / stale snapshot); `query_source` is the
472472
`duckgres.query_source` session GUC (`standard` unless set; a mid-connection
473-
change bills the whole connection under the final value). Invariants for anyone
473+
change bills the whole connection under the final value). The GUC is a **closed
474+
enum validated at SET time** (`transform.NormalizeQuerySource`): only
475+
`standard` | `endpoints` (case-insensitive, normalized to lowercase; empty =
476+
reset to default) — anything else is rejected with `22023` on every set path
477+
(simple/batched SET, extended Parse, and the `-c` startup option, which rejects
478+
the connection like invalid `duckgres.worker_*` options), and
479+
`server.ConnectionBilling` clamps a non-canonical value to `standard` as
480+
defense in depth so client junk can never become a billing bucket key.
481+
Invariants for anyone
474482
touching this path:
475483

476484
- **Metering is strictly best-effort and off the hot path.** A metering error

docs/design/billing-pull-api.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,17 @@ Internally, one row per unique key, values accumulated:
4343
org's default team. `query_source` (`standard` | `endpoints`) is set by a session
4444
GUC (`duckgres.query_source`), defaulting to `standard` when unset; the meter
4545
reads it per connection. (If it's changed mid-connection the per-connection meter
46-
uses a single value — same future refinement as team.)
46+
uses a single value — same future refinement as team.) The GUC is **validated at
47+
SET time** against the closed `{standard, endpoints}` set — case-insensitive,
48+
normalized to lowercase; empty resets to the default; any other value is
49+
rejected with SQLSTATE `22023` (`invalid value for "duckgres.query_source":
50+
must be "standard" or "endpoints"`) on every set path (simple SET, batched,
51+
extended-protocol Parse). An invalid `-c duckgres.query_source=…` startup
52+
option rejects the connection (FATAL `22023`), matching the
53+
`duckgres.worker_*` startup options. As defense in depth,
54+
`server.ConnectionBilling` clamps a non-canonical value to `standard` at the
55+
metering boundary, so client input can never put unbounded-cardinality junk
56+
in the bucket key or the billing export.
4757
- Worker size (`cpu`, `mem_gib`) is part of the key, so different worker sizes
4858
accumulate — and bill — separately. Units match the values (vCPU / GiB).
4959

@@ -167,7 +177,8 @@ sizes; stored as `NUMERIC` so grouping is exact.)
167177
at org creation on both the provisioning and admin APIs, and not clearable
168178
via the admin API; used as the bucket `team_id` — fixed per org, no per-team
169179
logic yet); a `duckgres.query_source` session GUC
170-
(`standard` | `endpoints`, default `standard`) read by the meter; a bearer secret
180+
(`standard` | `endpoints`, default `standard`, validated at SET time — anything
181+
else is a `22023` error) read by the meter; a bearer secret
171182
for the API.
172183

173184
## Storage metric

server/conn.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/posthog/duckgres/server/usersecrets"
2828
"github.com/posthog/duckgres/server/wire"
2929
"github.com/posthog/duckgres/transpiler"
30+
"github.com/posthog/duckgres/transpiler/transform"
3031
"go.opentelemetry.io/otel/attribute"
3132
"go.opentelemetry.io/otel/trace"
3233
)
@@ -196,7 +197,9 @@ type clientConn struct {
196197
// This is a prerequisite for pull-based compute billing (usage buckets are
197198
// keyed by query_source). Set via `SET duckgres.query_source = '...'` (or a
198199
// `-c duckgres.query_source=...` startup option) and read back via
199-
// `SHOW duckgres.query_source` / current_setting.
200+
// `SHOW duckgres.query_source` / current_setting. Every set path validates
201+
// against the closed {standard, endpoints} set (22023 on anything else), so
202+
// this only ever holds "", "standard", or "endpoints".
200203
querySource string
201204

202205
// Provisioned worker pod size for compute-usage billing (remote/k8s backend
@@ -265,13 +268,31 @@ func (c *clientConn) QuerySource() string {
265268
}
266269

267270
// setQuerySource records a client-supplied `duckgres.query_source` value on the
268-
// session. Pass-through: any string is accepted (only "standard"/"endpoints" are
269-
// meaningful downstream, but other values are stored, not rejected). An empty
270-
// value resets to the default.
271+
// session. Callers must pass an already-validated canonical value ("standard",
272+
// "endpoints", or "" = reset to default): the transpiler validates SET
273+
// statements (transform.NormalizeQuerySource, rejecting anything else with
274+
// 22023 before this is reached) and applyStartupQuerySource validates the
275+
// startup option. The setter itself stays dumb; ConnectionBilling additionally
276+
// clamps at the metering boundary as defense in depth.
271277
func (c *clientConn) setQuerySource(value string) {
272278
c.querySource = value
273279
}
274280

281+
// applyStartupQuerySource validates and applies a `-c duckgres.query_source=…`
282+
// startup-option value. The GUC is a billing dimension, so the value must be
283+
// in the closed {standard, endpoints} set (case-insensitive, normalized to
284+
// lowercase; empty = default). An invalid value returns the 22023 error for
285+
// the caller to reject the connection with, mirroring how the control plane
286+
// rejects invalid duckgres.worker_* startup options.
287+
func (c *clientConn) applyStartupQuerySource(raw string) error {
288+
norm, err := transform.NormalizeQuerySource(raw)
289+
if err != nil {
290+
return err
291+
}
292+
c.setQuerySource(norm)
293+
return nil
294+
}
295+
275296
// generateSecretKey is a thin alias for wire.GenerateSecretKey kept for the
276297
// internal call sites in this file. New code should call wire.GenerateSecretKey
277298
// directly.
@@ -1017,9 +1038,16 @@ func (c *clientConn) handleStartup() error {
10171038

10181039
// Honor a `-c duckgres.query_source=...` startup option (libpq `options`
10191040
// keyword / PGOPTIONS). Other GUCs in `options` are not applied here.
1041+
// An invalid value rejects the connection with FATAL 22023 — the same
1042+
// treatment resolveWorkerProfile gives an invalid duckgres.worker_*
1043+
// startup option, and what PostgreSQL itself does with an invalid
1044+
// `options` GUC value.
10201045
if opts := ParseStartupOptions(params["options"]); len(opts) > 0 {
10211046
if v, ok := opts[querySourceGUCName]; ok {
1022-
c.setQuerySource(v)
1047+
if err := c.applyStartupQuerySource(v); err != nil {
1048+
c.sendError("FATAL", "22023", err.Error())
1049+
return fmt.Errorf("invalid %s startup option", querySourceGUCName)
1050+
}
10231051
}
10241052
}
10251053

server/exports.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/posthog/duckgres/server/observe"
1111
"github.com/posthog/duckgres/server/sessionmeta"
1212
"github.com/posthog/duckgres/server/wire"
13+
"github.com/posthog/duckgres/transpiler/transform"
1314
)
1415

1516
// Exported wrappers for protocol functions used by the control plane worker.
@@ -213,11 +214,25 @@ func SetConnectionWorkerSize(cc *clientConn, millicores, mib int64) {
213214
// size). Call at the same teardown point as CloseConnectionMetrics. A
214215
// mid-connection GUC change is not split: the whole connection is metered
215216
// under the final value (documented in docs/design/billing-pull-api.md).
217+
//
218+
// The query source is clamped to the closed {standard, endpoints} set as
219+
// defense in depth: every set path already validates (22023 at SET / startup
220+
// time), so a non-canonical value here means a validation bypass — degrade it
221+
// to the default rather than writing unbounded-cardinality client input into
222+
// the billing bucket key (and onward into billing exports).
216223
func ConnectionBilling(cc *clientConn) (orgID, querySource string, millicores, mib int64, dur time.Duration) {
217224
if cc == nil {
218225
return "", "", 0, 0, 0
219226
}
220-
return cc.orgID, cc.QuerySource(), cc.workerMillicores, cc.workerMiB, time.Since(cc.backendStart)
227+
qs := cc.QuerySource()
228+
if qs != transform.QuerySourceStandard && qs != transform.QuerySourceEndpoints {
229+
// Should be unreachable. Log the length, not the value — it is
230+
// arbitrary client input.
231+
cc.logger().Warn("Non-canonical duckgres.query_source at billing teardown; degrading to default.",
232+
"value_len", len(qs))
233+
qs = defaultQuerySource
234+
}
235+
return cc.orgID, qs, cc.workerMillicores, cc.workerMiB, time.Since(cc.backendStart)
221236
}
222237

223238
// CancelClientConn cancels the context of a clientConn.

0 commit comments

Comments
 (0)