pREST v2.3.0: Critical SQL Injection Fix and the Adapter Registry

Upgrade first, read after. pREST v2.3.0 fixes an unauthenticated SQL injection that affects every release from v2.0.0 through v2.2.0. If you run prestd with a reachable HTTP port, this isn’t an optional release.

Nothing else in this post matters until that patch is in. Below: why the bug was so easy to hit, what the fix accepts, and what to do in order.

pREST v2 series: Architecture (rc6) → v2.0.0 / v2.1.0 GA → MCP tutorial → AI plugins → v2.2.0 → This post (v2.3.0)

Table of Contents

Why one anonymous GET was enough

The advisory is GHSA-qvx3-q8vx-9q3c, rated CVSS 9.8 (critical). It’s an incomplete-fix follow-up to CVE-2025-58450. The v2.0.0 remediation added an identifier allow-list but left one shortcut in place.

Two code paths skipped the allow-list

pREST validates every identifier through a strict allow-list in internal/ident. Two code paths skipped it.

The first was in SelectFields. Aggregate expressions like SUM("salary") had to keep working. So the builder treated any field containing a double-quoted substring as a trusted function expression. It appended that field to the SELECT clause verbatim:

// adapters/postgres/postgres.go (v2.2.0)
isFunction, _ := regexp.MatchString(groupRegex.String(), field)
if isFunction {
    aux = append(aux, field)   // attacker-controlled, unvalidated
    continue
}
if !ident.IsValid(field) { /* strict allow-list, already bypassed above */ }

groupRegex was \"(.+?)\", which matches any double-quoted run anywhere in the string. An aliased scalar sub-select satisfies that gate and keeps the outer query valid.

The second sink was CountByRequest. It interpolated the raw _select value into SELECT COUNT(%s)%s FROM with no validation at all.

Nothing upstream compensated

columnsByRequest only splits on commas. FieldsPermissions returns request columns unchanged when access.restrict is off. That’s the shipped default, alongside auth.enabled=false. The official Docker images connect as the PostgreSQL superuser.

So on a default deployment, one anonymous GET was enough:

curl -s --get 'http://target:3000/prest/public/todos' \
  --data-urlencode '_select=(SELECT rolpassword FROM pg_authid WHERE rolname='"'"'postgres'"'"' LIMIT 1)"h"'
[{"h": "SCRAM-SHA-256$4096:dHCbEz7M...:ov50Vhb..."}]

The same shape returns pg_read_file('/etc/passwd') and dumps unrelated tables regardless of your field policy. It also runs pg_sleep() as a blind oracle or a cheap denial of service.

What the v2.3.0 fix accepts

v2.3.0 adds a single shared gate, sanitizeSelectField, guarding both sinks. It accepts only:

  • *
  • Valid identifiers and dotted paths (ident.IsValid + ident.Quote)
  • Colon-syntax aggregates via the existing NormalizeGroupFunction (sum:salary → SUM("salary"))
  • Pre-quoted aggregates in the exact FUNC("ident")[ AS "alias"] shape that NormalizeGroupFunction emits. A strict anchored allow-list matches six functions: SUM, AVG, MAX, MIN, STDDEV, VARIANCE.

Everything else is rejected with ErrInvalidIdentifier: sub-selects, pg_* calls, extra parens. The TimescaleDB adapter inherits the fix, because it embeds the Postgres adapter and doesn’t override SelectFields.

The regression coverage matters as much as the patch. Unit tests reject the PoC payloads through both sinks. They keep SUM("salary"), AVG("age") AS "avg_age", colon syntax, * and dotted identifiers working. Integration tests assert 400 Bad Request for the injection payloads end to end.

One behavior change is visible. _select values inside the count query are now properly quoted (, celphone becomes , "celphone"). If you assert on generated SQL anywhere, expect that diff.

How to upgrade, in order

Binaries for Linux, macOS, Windows and BSD are on the release page. Docker users pull prest/prest:v2.3.0.

A sane upgrade order:

  1. Patch now. The injection is unauthenticated and trivially exploitable on defaults.
  2. Check your exposure. Grep access logs for _select values containing ( or pg_. Superuser Docker images plus a public port means you should assume compromise and rotate credentials.
  3. Verify aggregates. If you use _select with pre-quoted aggregate expressions, confirm they match the accepted FUNC("ident")[ AS "alias"] shape. Anything outside it now returns 400.
  4. Smoke-test auth if you use JWKS, because of the jwx v3 migration.
  5. Then fix the defaults the advisory flags: enable auth, set access.restrict, and stop connecting as superuser.

What patching doesn’t fix

The advisory calls out two hardening items that are explicitly out of scope for this release:

  • Shipped defaults keep auth.enabled=false and access.restrict unset (CWE-1188).
  • Official Docker images connect as the PostgreSQL superuser (CWE-250).

Patching to v2.3.0 closes the injection. It doesn’t turn on your auth or drop your database privileges. Do both.

Smoke-test auth after the jwx v3 move

v2.3.0 migrates JWT/JWKS handling to github.com/lestrrat-go/jwx/v3. The v2 API surface removed the helpers pREST relied on:

Location Before After
config/config.go jwk.Fetch(ctx, uri) HTTP GET with the existing client + jwk.Parse(body)
middlewares/middlewares.go Set.Keys(ctx) iterator Len() / Key(i) walk
middlewares/middlewares.go key.Raw(&dst) jwk.Export(key, &dst)

Key-matching semantics are preserved. The loop still matches the token’s kid, and an empty-kid single-key JWKS still matches. The jwksMatched guard that prevents the empty-HMAC-key auth bypass stays in place. JWKS fetching also now rejects non-2xx responses and redacts URLs in logs.

If you use PREST_JWT_JWKS (or the jwt.jwks.* config), this is a dependency change with no configuration change. It does touch your auth path, so smoke-test a login against a staging instance before rolling out.

What changes in the adapter registry

v2.0.0 introduced multi-database support, and v2.2.0 made TimescaleDB first-class. v2.3.0 finishes the architecture underneath both.

One rule is now enforced across the codebase. Base adapters provide empty or no-op implementations; specialized adapters override with real ones. time_bucket is the concrete example. In v2.2.0, TimeBucketClause() lived in the Postgres adapter, even though it’s a TimescaleDB extension function. Now:

  • In adapters/postgres/postgres.go, TimeBucketClause() is an explicit no-op stub.
  • In adapters/timescaledb/adapter.go, it has a full implementation with interval validation and SQL generation. It’s tested across 5m, 15m, 1h, 6h, 1d, 7d, 30d and 1y.

Three files support this:

  • adapters/registry.go is new.
  • middlewares/adapter_selector.go routes requests to the right adapter per database.
  • controllers/adapter_helper.go lets handlers reach the selected adapter through the context, without importing a concrete engine.

Dependencies still point inward: handlers depend on ports, and never on adapters/postgres.

There’s also a real bug fix in app/app.go. The registry now falls back to the default "prest" alias when cfg.PGDatabase is empty. That gap used to cause registration failures in single-adapter mode. If you hit a confusing startup error on a minimal config, that was it.

TimescaleDB integration coverage expanded significantly too. Chunks, compression, continuous aggregates, joins, ACL, schema filtering and performance paths each got a test file under integration/timescaledb/controllers/.

How to keep full integration test logs

This one is small, but it will save you an afternoon. make test-integration truncates in the terminal on long runs. New targets tee the complete combined output to a file:

make test-integration-log                    # Postgres + TimescaleDB into integration-test.log
make test-integration-postgres-log           # standalone, fresh file
make test-integration-timescaledb-log        # standalone, fresh file
make test-integration-log INTEGRATION_LOG=path.log

test-integration-log runs the second suite even if the first fails. It then exits non-zero if either did, so one run gives you the full picture.

FAQ

Which pREST versions does GHSA-qvx3-q8vx-9q3c affect?

Every release from v2.0.0 through v2.2.0. The advisory rates it CVSS 9.8 (critical), and v2.3.0 fixes it. It’s an incomplete-fix follow-up to CVE-2025-58450.

Does upgrading to v2.3.0 make a default deployment safe?

It closes the injection. The shipped defaults still keep auth.enabled=false and access.restrict unset, and the official Docker images still connect as the PostgreSQL superuser. The advisory lists both as out of scope, so fix them yourself.

How do I check whether I was exposed?

Grep access logs for _select values containing ( or pg_. If you ran the superuser Docker images on a public port, assume compromise and rotate credentials.

Will my aggregate _select queries still work?

Colon syntax such as sum:salary still works. Pre-quoted aggregates must match the exact FUNC("ident")[ AS "alias"] shape, for SUM, AVG, MAX, MIN, STDDEV or VARIANCE. Anything outside it now returns 400.

Do I need to change my JWKS config for v2.3.0?

No. The jwx v3 migration is a dependency change with no configuration change. It touches your auth path, though, so smoke-test a login against staging before rolling out.