← Back to Blog

pREST v2.4.1 and v2.4.2: You Cannot Blacklist Your Way Out of SQL Injection

You cannot make an allow-list safe by adding keywords to it. pREST v2.4.1 tried — closing an unauthenticated 8.6 SQL injection in _QUERIES templates by rejecting SQL keyword tokens — and silently blanked 17.5% of one production catalog in the process. v2.4.2 drops screening for the primitive that was correct all along: bind the value so it never becomes SQL text. Patch straight to v2.4.2.

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

Table of Contents

Two advisories closed in v2.4.1

The _QUERIES allow-list that could still compose SQL

GHSA-5rwc-2hg5-2hmc (CVSS 8.6). v2.4.0 routed both _QUERIES script parameters and request headers through sanitizeScriptParam, a character allow-list:

// controllers/script.go (v2.4.0)
var safeScriptParamRegex = regexp.MustCompile(`^[a-zA-Z0-9_.:@/\\ -]+$`)

It keeps out quotes, commas, parentheses and semicolons. What it lets through is letters, digits, space, :: and -- — and that is already a SQL grammar. Every one of these payloads contains zero blocked characters:

0 OR true
0 UNION SELECT users::text FROM users
0 UNION SELECT passwd FROM pg_shadow
0 UNION SELECT table_name FROM information_schema.tables
0 UNION SELECT query FROM pg_stat_activity

The critical primitive is Postgres’s whole-row ::text cast. It packs every column of a relation into a single value with no comma and no concatenation, so even a single-column template exfiltrates an arbitrarily wide table through one UNION arm. $P=0 UNION SELECT users::text FROM users returns (1,alice@corp.example,$2b$12$K1x9...,t) — id, email, bcrypt hash, admin flag.

Writes and code execution stay unreachable: (, ) and ; are genuinely blocked, so pg_read_file('/etc/passwd') and stacked statements fail. This is a read-only injection — and with auth.enabled = false by default and the official Docker image connecting as the Postgres superuser, read-only means the whole database plus pg_shadow.

The precondition is a template that interpolates a request value in an unquoted context — WHERE id = {{.id}}, the exact idiom CVE-2025-58450 flagged as root cause. The quoted form '{{.x}}' was never affected, because the breakout quote is stripped. Remember that; it matters in the next section.

This was an incomplete fix of that CVE. v2.4.0’s advisory sweep hardened the sibling _groupby gate (GHSA-v9v2-98xq-627c) and left this code path untouched — the same class of miss, one release later.

/_mcp ignored [expose] entirely

GHSA-x62p-38px-pp73 (CVSS 5.3, 4.3 with auth enabled), reported by CyberSec42 (DevNest UG). ExposureMiddleware gated exactly three literal path prefixes:

// middlewares/middlewares.go (v2.4.0)
// only "/databases", "/tables", "/schemas" were checked —
// every other path fell through to next(rw, rq)

/_mcp is every other path. And listDatabases, listSchemas and listTables in controllers/mcp.go never referenced ExposeConf at all, so an operator who set expose.databases/schemas/tables = false got this:

GET /databases   → 401 Unauthorized     (correctly denied)
GET /_mcp        → 200 OK, full catalog  (every database, schema and table)

Worse than a tools/call round trip: the bare unauthenticated discovery payload already enumerated the catalog, with column names embedded in each tool’s description. No JSON-RPC needed. And no config key existed to disable /_mcp, so the only mitigation was blocking it at a reverse proxy.

v2.4.1 makes [expose] the single definition of what may be listed — DatabaseListingAllowed(), SchemaListingAllowed(), TableListingAllowed() on ExposeConf — and has both the middleware and the MCP handlers call it. Hidden catalog tools are dropped from the discovery payload entirely rather than failing when invoked.

The screen that blanked real data

To close the injection above, v2.4.1 added a second gate: reject any value whose word tokens match a SQL keyword. Fifty-eight of them, including do, as, or, and, all, any, not, null, set, case, like, when, then, from, group, order, table, values, into, create, limit, offset, distinct.

Those are not SQL fragments. They are words. And “reject” meant replace the value with an empty string, silently — the request kept going. {{if isSet "slug"}} still evaluated true, the query ran with an empty value, and the endpoint returned HTTP 200 with the wrong rows:

curl "$API/_QUERIES/article/getArticle?slug=teste-abc"
# []                                    ← correct

curl "$API/_QUERIES/article/getArticle?slug=teste-do-abc"
# [{"slug": "", "title": ""}]           ← wrong row, HTTP 200

Issue #1030 came from a Brazilian news portal running pREST as its read API. Article URLs are built from slugs derived from Portuguese headlines, and do (“of the”) is everywhere in Portuguese — compra-do-mes, estatua-do-diabo, trombetas-do-apocalipse. Measured against their full catalog:

count share
published articles 226,280 100%
silently blanked 39,501 17.5%
by token do 36,630
by token as 3,203
all other tokens ~270

Every one of those articles started 404-ing, because the front end asked pREST for an article by slug, got a row belonging to a different record, and concluded it did not exist.

Two things make this more than an over-eager filter.

The screen was reading data as if it were SQL. Token matching ran on whole alphanumeric runs, so -do-, do-, -do and do all tripped it while ado, doo and dodo sailed through. There is no theory of the value under which that distinction is meaningful — only a theory of the grammar, applied to something that was never grammar.

The site that broke was never vulnerable. Their template was AND slug = '{{.slug}}' — quoted, the form the advisory explicitly rules out. They took the full cost of a fix for an exposure they did not have. That is the real failure: a screen strong enough to be safe in the unquoted case is necessarily strong enough to destroy data in every other case, because it cannot tell them apart.

v2.4.2: bind the value, don’t screen it

A screened value still becomes part of the SQL text. A bound value never does — the driver sends it separately, over the wire, as data. pREST has shipped sqlVal and sqlList since v2.4.0 to do exactly that, but they bound the screened string, which discarded the caller’s data for no safety gain at all.

In v2.4.2 they bind the raw value and skip the screen entirely:

// template/funcregistry.go
func (fr *FuncRegistry) sqlVal(key string) string {
	fr.Args = append(fr.Args, fr.boundValue(key))   // raw, unscreened
	fr.next++
	return fmt.Sprintf("$%d", fr.next)              // renders as $1, $2, ...
}

So the template idiom changes from quoting to binding:

-- before: interpolated into the SQL text, subject to the screen
AND slug = '{{.slug}}'

-- after: bound as a parameter, screen bypassed, any bytes allowed
AND slug = {{sqlVal "slug"}}

sqlList does the same for repeated parameters, expanding to ($1,$2,$3) for IN clauses.

For templates that still interpolate inline, the keyword screen now runs only on values containing a space:

// controllers/script.go — sanitizeScriptParam
if strings.Contains(value, "--") || strings.Contains(value, "::") {
	return ""
}
if !strings.Contains(value, " ") {
	return value                    // single token: nothing to compose
}
// ... keyword screen over each word ...

The reasoning is that composing SQL takes more than one token, and space is the only separator the character allow-list permits — ; , ( ) * ' ", tab and newline are all rejected above it, and /**/ is unreachable without *. A single token is inert: WHERE 1 = teste-do-abc is a syntax error, not an injection, exactly as WHERE 1 = some_column already is. That one condition un-breaks the 39,501 articles.

And when a value is rejected, it no longer fails silently. rejectedParam renders as the empty string but records that it reached the SQL text; the handler then fails the request:

invalid value for parameter slug: it contains SQL syntax that cannot be
interpolated safely; use the sqlVal template helper to bind free-form values

The decision has to be deferred to render time rather than taken when the parameter is read, because the same template might pass that value to sqlVal — where screening is unwarranted and rejecting it would be pure data loss. The error names the parameter and never echoes its value, which may carry a credential.

Rejected headers still don’t fail the request, deliberately: every inbound header is screened, not only the ones a template reads, and an ordinary User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) fails the allow-list on ( and ;. Erroring would reject nearly every browser request to every script endpoint. Those are blanked and logged by name.

Why the raw values are hidden from the template

Binding raw values creates an obvious hole: if the unscreened map sits in the template’s data, a template author can just reach in and interpolate it.

{{index ._param "id"}}    ← would put an unscreened value back into the SQL text

So NewFuncRegistry moves the _param and _header maps out of TemplateData — the map that becomes the template’s dot — the moment it is constructed. They exist only in transit, reachable through sqlVal and sqlList and nowhere else. Headers are addressed with a prefix: {{sqlVal "header.X-Application"}}.

Credential headers get a stronger treatment. Authorization, Proxy-Authorization, Cookie, X-Api-Key, X-Auth-Token and X-Access-Token are blanked in both maps, screened and raw. A bearer token is plain base64url text that passes the character allow-list untouched, so a template referencing one would previously interpolate the caller’s credential straight into a SQL string. Blanking them is about secrecy, not SQL composition — which is precisely why binding must not become a way around it. The keys are kept with an empty value so existing templates render an empty literal rather than Go’s <no value>.

Scripts can no longer read outside the queries directory

getScriptPath builds a filesystem path from {folder} and {scriptName} in the request. v2.4.2 adds containment in two passes:

// adapters/postgres/script_runner.go
if !withinBase(base, script) {          // lexical: filepath.Rel, rejects ".."
	return "", fmt.Errorf("invalid script path: %s/%s", folder, scriptName)
}
if _, err := os.Stat(script); os.IsNotExist(err) {
	return "", fmt.Errorf("could not load script: %w", err)
}
if !resolvedWithinBase(base, script) {  // repeats the check through EvalSymlinks
	return "", fmt.Errorf("invalid script path: %s/%s", folder, scriptName)
}

The symlink pass runs after os.Stat because filepath.EvalSymlinks fails on a path that does not exist — a link inside the queries directory pointing outside it passes the lexical check, so both are needed. The controller already gates these segments through validatePathSegments; the adapter repeats the check so it cannot be made to read outside its directory by a caller that skips that gate.

Three smaller hardening changes ship alongside:

  • SQL text is out of the error logs. WriteSQL and WriteSQLCtx no longer log the statement — scripts render headers and query parameters directly into the SQL those two execute, so that text is caller-controlled data, not a fixed pREST-built statement. A new logFailedSQL helper logs statements at debug level only, and is called solely from the CRUD builders a template cannot reach.
  • JSON errors are actually JSON. jsonError now json.Marshals the message instead of interpolating it raw; a message containing a " or a control character previously emitted a body no client could parse.
  • Database execution errors are wrapped with %w rather than %v, so errors.Is and errors.As work against them.

JWT keys now fail at startup, not at request time

v2.4.2 finally retires the long-deprecated gopkg.in/square/go-jose.v2 for github.com/go-jose/go-jose/v4, deleting a // todo: upgrade go-jose that had been sitting in go.mod. It takes the opportunity to validate key material at startup instead of discovering it is unusable mid-request. Undersized HMAC keys are now rejected against the RFC 7518 minimums:

jwt.algo minimum jwt.key
HS256 (default) 32 bytes
HS384 48 bytes
HS512 64 bytes

An undersized key is logged and treated as unusable verification material rather than being handed to go-jose to reject later. Unsupported algorithms now fail initialization outright, request verification is limited to the configured algorithm, and JWT middleware setup surfaces configuration errors instead of continuing silently. This is a breaking config change if you are running a short jwt.key.

Also in the release: lestrrat-go/jwx/v3 3.1.1 → 3.2.0 and google.golang.org/grpc 1.81.1 → 1.82.1.

Upgrading

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

  1. Patch now if _QUERIES is reachable from untrusted clients. Every release from v2.0.0 through v2.4.0 is vulnerable to the 8.6 injection, unauthenticated on a default config.
  2. Go straight to v2.4.2, not v2.4.1. v2.4.1 carries the silent-blanking regression above. If you are already on v2.4.1, treat this as urgent for correctness, not just security — your endpoints may be returning wrong rows with a 200.
  3. Audit your templates for unquoted interpolation (WHERE id = {{.id}}) and move every free-form value to {{sqlVal "..."}}. Quoted interpolation was never exploitable, but binding is the only form that is safe and lossless.
  4. Check jwt.key length before restarting. Under 32 bytes on the default HS256 and the key is treated as unusable at startup.
  5. Re-check [expose] if you were blocking /_mcp at a proxy. The MCP endpoint now honours the same listing configuration as the REST catalog routes, so the proxy rule may be redundant.
  6. If you saw parameters mysteriously matching nothing on v2.4.1, that was this bug. No config change is needed — the binary upgrade is the fix.

FAQ

Did pREST v2.4.1 break my query parameters?

If any _QUERIES script parameter value contained a SQL keyword as a whole word — do, as, or, and, from, order, group, and ~50 others — v2.4.1 replaced it with an empty string and ran the query anyway, returning HTTP 200 with the wrong rows. One reporter measured 17.5% of a 226,280-article catalog affected. v2.4.2 restores those values by running the keyword screen only on multi-token values.

How do I bind a value in a pREST _QUERIES script?

Use the sqlVal template helper: AND slug = {{sqlVal "slug"}} instead of AND slug = '{{.slug}}'. It appends the caller’s raw value to the query arguments and renders a $1-style placeholder, so the value travels to Postgres as data and never becomes part of the SQL text. sqlList does the same for repeated parameters, expanding to ($1,$2,$3) for IN clauses. Headers bind as {{sqlVal "header.X-Application"}}.

Is pREST v2.4.0 still vulnerable?

Yes. GHSA-5rwc-2hg5-2hmc affects every version up to and including v2.4.0 and is patched in v2.4.1. Exploitation requires a deployed script template that interpolates a request value in an unquoted context; templates using the quoted form '{{.x}}' are not affected by that advisory, though they are affected by the v2.4.1 blanking regression.

Does the expose configuration now cover the MCP endpoint?

Yes, as of v2.4.1. Before that, expose.databases/schemas/tables = false was enforced only on GET /databases, /tables and /schemas; /_mcp returned the full catalog regardless, including from its unauthenticated discovery payload. The listing rules now live on ExposeConf and are applied by both the middleware and the MCP handlers, with hidden tools removed from discovery.

Do I need to change my pREST jwt.key?

Only if it is shorter than the RFC 7518 minimum for your algorithm — 32 bytes for HS256, 48 for HS384, 64 for HS512. v2.4.2 checks this at startup and treats an undersized key as unusable rather than failing at request time. Asymmetric algorithms (RS*, ES*, PS*, EdDSA) are unaffected, since jwt.key is not used as a MAC key there.

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