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 GA → MCP tutorial → AI plugins → v2.2.0 → This 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:salary→SUM("salary")) - Pre-quoted aggregates in the exact
FUNC("ident")[ AS "alias"]shape thatNormalizeGroupFunctionemits, 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=falseandaccess.restrictunset (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.go—TimeBucketClause()is an explicit no-op stubadapters/timescaledb/adapter.go— full implementation with interval validation and SQL generation, tested across5m,15m,1h,6h,1d,7d,30d, and1y
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:
- 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 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.
Full changelog: github.com/prest/prest/releases/tag/v2.3.0. Docs: docs.prestd.com.