← Back to Blog

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 is not an optional release.

pREST v2 series: Architecture (rc6)v2.0.0 / v2.1.0 GAMCP tutorialAI pluginsv2.2.0This post (v2.3.0)

The security fix: unauthenticated SQL injection via _select

The advisory is GHSA-qvx3-q8vx-9q3c, rated CVSS 9.8 (critical). It is 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.

What went wrong

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

The first was in SelectFields. To keep aggregate expressions like SUM("salary") working, the builder treated any field containing a double-quoted substring as a trusted function expression and appended it 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 \"(.+?)\"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, which interpolated the raw _select value into SELECT COUNT(%s)%s FROM with no validation at all.

Why the blast radius is large

Nothing upstream compensated. columnsByRequest only splits on commas, and FieldsPermissions returns request columns unchanged when access.restrict is off — which is 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'), dumps unrelated tables regardless of your field policy, and runs pg_sleep() as a blind oracle or a cheap denial of service.

The fix

v2.3.0 introduces 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:salarySUM("salary"))
  • Pre-quoted aggregates in the exact FUNC("ident")[ AS "alias"] shape that NormalizeGroupFunction emits, matched by a strict anchored allow-list over six functions: SUM, AVG, MAX, MIN, STDDEV, VARIANCE

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

The regression coverage matters as much as the patch: unit tests reject the PoC payloads through both sinks while keeping 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 visible behavior change: _select values inside the count query are now properly quoted (, celphone becomes , "celphone"). If you assert on generated SQL anywhere, expect that diff.

What is still on you

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 does not turn on your auth or drop your database privileges. Do both.

JWKS moves to jwx v3

v2.3.0 migrates JWT/JWKS handling to github.com/lestrrat-go/jwx/v3, since 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, an empty-kid single-key JWKS still matches, and the jwksMatched guard that prevents the empty-HMAC-key auth bypass stays in place. JWKS fetching also gained rejection of non-2xx responses and URL redaction in logs.

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

The adapter registry lands properly

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

The rule 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 is a TimescaleDB extension function. Now:

  • adapters/postgres/postgres.goTimeBucketClause() is an explicit no-op stub
  • adapters/timescaledb/adapter.go — full implementation with interval validation and SQL generation, tested across 5m, 15m, 1h, 6h, 1d, 7d, 30d, and 1y

Supporting this, adapters/registry.go is new, middlewares/adapter_selector.go routes requests to the right adapter per database, and 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, not on adapters/postgres.

There is also a real bug fix in app/app.go: the registry now falls back to the default "prest" alias when cfg.PGDatabase is empty, which previously caused 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 in this release too — chunks, compression, continuous aggregates, joins, ACL, schema filtering, and performance paths each got their own test file under integration/timescaledb/controllers/.

Better integration logs

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, then exits non-zero if either did — so one run gives you the full picture instead of a partial one.

Upgrading

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 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.

Full changelog: github.com/prest/prest/releases/tag/v2.3.0. Docs: docs.prestd.com.