Myth: you can make interpolated SQL safe by rejecting SQL keywords. A keyword list can’t tell grammar from data, so it destroys real values. pREST v2.4.1 tried it to close an unauthenticated 8.6 SQL injection in _QUERIES templates.
The screen silently blanked 17.5% of one production catalog. v2.4.2 binds the value instead, so it never becomes SQL text. Patch straight to v2.4.2.
pREST v2 series: Architecture (rc6) → v2.0.0 / v2.1.0 GA → MCP tutorial → AI plugins → v2.2.0 → v2.3.0 → v2.4.0 → This post (v2.4.1 + v2.4.2)
Upgrade first. Every release from v2.0.0 through v2.4.0 has the 8.6 injection, unauthenticated on a default config. v2.4.1 adds a regression that returns wrong rows with HTTP 200. Pull
prest/prest:v2.4.2.
Table of Contents
- Does blocking SQL keywords stop injection?
- How the screen blanked 17.5% of a catalog
- Why a blacklist can’t tell data from SQL
- How to bind values with sqlVal
- Where pREST still screens values
- The /_mcp catalog fix
- Script path containment
- JWT key sizes
- How to upgrade
- FAQ
- Sources
Does blocking SQL keywords stop SQL injection?
v2.4.1 bet that it does. The injection it had to close came from a character allow-list that still let SQL through.
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. It lets through 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. But auth.enabled = false is the default, and the official Docker image connects as the Postgres superuser. So 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}} is 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 below.
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. It was the same class of miss, one release later.
The keyword screen v2.4.1 added
To close the injection, v2.4.1 added a second gate: reject any value whose word tokens match a SQL keyword. The list had fifty-eight of them. They include do, as, or, and, all, any, not, null, set, case, like, when, then, from, group, order, table, values, into, create, limit, offset and distinct.
How the keyword screen blanked 17.5% of a catalog
Those keywords are also ordinary words. And “reject” meant replace the value with an empty string, silently, while 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. Its article URLs are built from slugs derived from Portuguese headlines. 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. The front end asked pREST for an article by slug and got a row belonging to a different record. So it concluded the article did not exist.
Why a keyword blacklist can’t tell data from SQL
Two things make this more than an over-eager filter.
The screen read 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. No theory of the value makes that distinction meaningful. Only a theory of the grammar does, 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 for the unquoted case is strong enough to destroy data in every other case. It cannot tell them apart.
How to bind values with sqlVal instead
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 for exactly that since v2.4.0. 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.
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 moment it is constructed. TemplateData is the map that becomes the template’s dot. The raw maps 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. Before, a template referencing one would interpolate the caller’s credential straight into a SQL string. These headers are blanked to keep secrets, and binding must not become a way around that. The keys stay with an empty value, so existing templates render an empty literal instead of Go’s <no value>.
Where pREST still screens values
The screen still exists as a backstop. 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 ...
Composing SQL takes more than one token. 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 just a syntax error, exactly as WHERE 1 = some_column already is. That one condition un-breaks the 39,501 articles.
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 waits until render time instead of happening when the parameter is read. The same template might pass that value to sqlVal. There, screening is unwarranted, and rejecting the value 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, on purpose. Every inbound header is screened, including the ones no template reads. 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 headers are blanked and logged by name.
How v2.4.1 hides the catalog from /_mcp
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. 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)
The bare unauthenticated discovery payload already enumerated the catalog, with column names embedded in each tool’s description. No JSON-RPC tools/call round trip was needed. 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() and TableListingAllowed() on ExposeConf. Both the middleware and the MCP handlers call it. Hidden catalog tools are dropped from the discovery payload entirely instead of failing when invoked.
How v2.4.2 keeps scripts inside 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 a caller that skips that gate still can’t make it read outside its directory.
Three smaller hardening changes ship alongside:
- SQL text is out of the error logs.
WriteSQLandWriteSQLCtxno longer log the statement. Scripts render headers and query parameters directly into the SQL those two execute, so that text is caller-controlled data. A newlogFailedSQLhelper logs statements at debug level only. It’s called solely from the CRUD builders a template cannot reach. - JSON errors are now valid JSON.
jsonErrorrunsjson.Marshalon 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
%winstead of%v, soerrors.Isanderrors.Aswork against them.
Check your JWT key length before upgrading
v2.4.2 finally retires the long-deprecated gopkg.in/square/go-jose.v2 for github.com/go-jose/go-jose/v4. That deletes a // todo: upgrade go-jose that had been sitting in go.mod.
The release also validates key material at startup, instead of discovering mid-request that it is unusable. 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, instead of being handed to go-jose to reject later. Unsupported algorithms now fail initialization outright. Request verification is limited to the configured algorithm. JWT middleware setup surfaces configuration errors instead of continuing silently. This is a breaking config change if you run 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.
How to upgrade
Binaries for Linux, macOS, Windows and BSD are on the release page. Docker users pull prest/prest:v2.4.2.
- Patch now if
_QUERIESis reachable from untrusted clients. Every release from v2.0.0 through v2.4.0 has the 8.6 injection, unauthenticated on a default config. - Go straight to v2.4.2 and skip v2.4.1, which carries the silent-blanking regression above. If you’re already on v2.4.1, treat this as urgent for correctness as well as security. Your endpoints may be returning wrong rows with a 200.
- Audit your templates for unquoted interpolation (
WHERE id = {{.id}}). Move every free-form value to{{sqlVal "..."}}. Quoted interpolation was never exploitable, but binding is the only form that is safe and lossless. - Check
jwt.keylength before restarting. Under 32 bytes on the default HS256, the key is treated as unusable at startup. - Re-check
[expose]if you were blocking/_mcpat a proxy. The MCP endpoint now honours the same listing configuration as the REST catalog routes. The proxy rule may be redundant. - If parameters mysteriously matched nothing on v2.4.1, that was this bug. No config change is needed; the binary upgrade is the fix.
A step-by-step install of this release, from Docker Compose to the MCP endpoint, is in pREST in 10 minutes. The full release history is in the pREST v2 guide.
FAQ
Can a SQL keyword blacklist prevent SQL injection?
Not without destroying data. pREST v2.4.1 screened values against 58 SQL keywords and silently blanked 17.5% of one news portal’s catalog. The screen can’t tell an unsafe unquoted value from ordinary words. Binding with sqlVal is the only form that is safe and lossless.
Did pREST v2.4.1 break my query parameters?
It did if a _QUERIES script parameter contained a SQL keyword as a whole word: do, as, or, and, from, order, group and ~50 others. v2.4.1 replaced the value 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. 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 v2.4.1 patches it. Exploitation needs a deployed script template that interpolates a request value in an unquoted context. Templates using the quoted form '{{.x}}' are outside that advisory. They are still hit 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 both the middleware and the MCP handlers apply them. Hidden tools are 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, instead of failing at request time. Asymmetric algorithms (RS*, ES*, PS*, EdDSA) are unaffected, since jwt.key is not used as a MAC key there.
Sources
Full changelog: github.com/prest/prest/releases/tag/v2.4.2. Docs: docs.prestd.com.