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
- What the v2.3.0 fix accepts
- How to upgrade, in order
- What patching doesn’t fix
- Smoke-test auth after the jwx v3 move
- What changes in the adapter registry
- How to keep full integration test logs
- FAQ
- Links
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 thatNormalizeGroupFunctionemits. 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:
- Patch now. The injection is unauthenticated and trivially exploitable on defaults.
- Check your exposure. Grep access logs for
_selectvalues containing(orpg_. Superuser Docker images plus a public port means you should assume compromise and rotate credentials. - Verify aggregates. If you use
_selectwith pre-quoted aggregate expressions, confirm they match the acceptedFUNC("ident")[ AS "alias"]shape. Anything outside it now returns400. - Smoke-test auth if you use JWKS, because of the jwx v3 migration.
- 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=falseandaccess.restrictunset (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 across5m,15m,1h,6h,1d,7d,30dand1y.
Three files support this:
adapters/registry.gois new.middlewares/adapter_selector.goroutes requests to the right adapter per database.controllers/adapter_helper.golets 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.
Links
- Full changelog: github.com/prest/prest/releases/tag/v2.3.0
- Docs: docs.prestd.com