pREST v2.4.0 closes six SQL-injection-adjacent gaps. Two of them are exploitable without auth on a default config. Upgrading the binary is the whole fix, with no config change.
The release also adds nearest-neighbor search over pgvector and opt-in OpenTelemetry with a local SigNoz stack. Below: what the review found, how to upgrade and check exposure, then the two new features.
pREST v2 series: Architecture (rc6) → v2.0.0 / v2.1.0 GA → MCP tutorial → AI plugins → v2.2.0 → v2.3.0 → This post (v2.4.0)
Upgrade first. If you run
prestdon the public internet, pullprest/prest:v2.4.0before reading on. The_groupbyand header-driven_QUERIESfixes close unauthenticated holes, the same attacker model as the v2.3.0_selectbug.
Table of Contents
- What the advisory review found
- How to upgrade and check exposure
- pgvector search
- OpenTelemetry
- Studio changes
- FAQ
- Sources
What the advisory review found
v2.3.0 closed GHSA-qvx3-q8vx-9q3c, the critical unauthenticated _select injection. An advisory review then searched the codebase for the same shape of validation gap. It found six.
_groupby had the same weak gate _select used to have
GHSA-v9v2-98xq-627c (CVSS 8.1). GroupByClause has a branch for raw SQL expressions. It let a _groupby field through if it was a balanced-parens function call, gated by isSafeSQLExpression.
That gate checked the outermost function name against an allowlist. It still allowed parens inside the arguments. So upper((SELECT 1)) UNION SELECT current_user passed, the same trick the original _select bug used.
// adapters/postgres/postgres.go
func isSafeSQLExpression(expr string) bool {
if strings.Contains(expr, "--") || strings.Contains(expr, ";") || strings.Contains(expr, "/*") {
return false
}
if !strings.HasSuffix(expr, ")") {
return false
}
idx := strings.Index(expr, "(")
// ... function-name allowlist check ...
args := expr[idx+1 : len(expr)-1]
if strings.ContainsAny(args, "()") { // new: no nested parens in the arguments
return false
}
for _, ch := range args {
// character allowlist over args only, not the whole expression
}
return true
}
The fix requires the whole expression to be exactly one allowlisted func(args) call. No parens may appear anywhere inside args. That rules out a nested subquery, an inner pg_sleep(...), or a trailing UNION SELECT riding along in the same field.
Unauthenticated SQLi via HTTP request headers in query templates
GHSA-r3hj-2fxx-7f3h (CVSS 8.1). _QUERIES custom SQL templates interpolate request headers the same way they interpolate query parameters. The shipped get_header.read.sql example does exactly this. Unlike query params, headers never went through any sanitizer:
// controllers/script.go — extractHeaders
for key, value := range rq.Header {
if len(value) == 1 {
headers[key] = sanitizeScriptParam(value[0]) // now routed through the same gate as query params
continue
}
// ...
}
Any header value could carry SQL into a template that assumed only trusted params. That includes headers a reverse proxy forwards unmodified, like X-Forwarded-For.
Two access-control gaps, one via path parsing and one via database validation
AccessControl derives {database}/{schema}/{table} from the request path with getVars. Its length check treated any 4-segment split as ambiguous and returned nil. AccessControl reads that as “not a table route, skip permission checks.”
A /batch/{database}/{schema}/{table} request is a 5-segment split: a leading empty segment, batch, and 3 real segments. It fell into exactly that bucket, so /batch/... inserts bypassed table permissions entirely. The fix strips the recognized batch prefix explicitly instead of guessing from segment count alone.
Separately, ScriptHandler.Execute (the _QUERIES handler) never validated the database name or path segments before reaching the connection layer. An unauthenticated request could force prestd to open a real outbound Postgres connection to an arbitrary database name. It now runs the same validateDatabase and path-segment validation every other controller applies first.
Both gaps, plus the credential-hygiene fixes below, are tracked together under GHSA-rf54-gp27-88rv.
describe_table MCP tool leaked full schema past field permissions
select_table already enforced per-field ACL. The MCP describe_table tool didn’t, and returned every column whatever the caller was allowed to select. It now runs through the same selectableColumns permission filter. When the caller has no visible columns, it returns an error rather than an empty or partial schema.
The GET response cache leaked across users with different permissions
The cache middleware sits downstream of AccessControl but upstream of the handler’s own per-user FieldsPermissions column filtering. So a cache hit skips the handler and its column filtering entirely.
The cache key was just the request URL. One user’s cached response was built under their own column permissions. It could be served verbatim to a different user with narrower permissions hitting the identical URL.
The fix appends a SHA-256 digest of the authenticated username to the cache key. It uses a digest because Go preserves an unescaped ? in RawQuery. With the raw username, an attacker could craft a URL whose suffix collides with a target user’s key.
Three credential-hygiene fixes
prestd migrate --helpno longer bakes a real, password-bearing DSN into a cobra flag default. Cobra prints flag defaults verbatim in--helpoutput.- A raw connection URL logged on config-parse failure now goes through the existing credential-redaction helper.
- The Postgres-URL credential regex was greedy on the wrong side. A password containing
@(e.g.postgres://user:p@ss@word@host/db) left everything after the first@unredacted in logs. The pattern now consumes up to the last@before the host.
None of this requires a config change. Upgrading the binary is the fix.
Two request shapes will start returning 400. One is a _QUERIES template that references request headers. The other is a _select or _groupby expression with nested parens.
How to upgrade and check your exposure
Binaries for Linux, macOS, Windows and BSD are on the release page. Docker users pull prest/prest:v2.4.0.
- Patch now if you’re reachable from untrusted clients. Two of the six fixes (
_groupbyand header-driven_QUERIEStemplates) are exploitable without auth on a default config. That’s the same attacker model as the v2.3.0_selectbug. - Check exposure the same way as last time. Grep access logs for
_groupbyor_selectvalues containing a nested(. If you have_QUERIEStemplates that readheader.*, look for unusual header values too. - Re-audit
/batchusage if you rely on table-level ACL. That route silently bypassed it until this release. - If you use MCP, confirm
describe_tableresponses now match your field permissions instead of showing the full schema. - If you cache GET responses across users with different column permissions, this release fixes that leak. You only need the binary upgrade.
- pgvector and OpenTelemetry are both opt-in. Nothing changes for existing deployments until you touch
[otel]or avectorcolumn.
How to search by vector distance with pgvector
If your table has a pgvector vector column, v2.4.0 adds two query-parameter forms for it.
_korder orders by vector distance, nearest first:
GET /db/public/docs?_korder=embedding:l2:[0.12,0.98,-0.3,...]
The format is <column>:<metric>:<vector>. The metrics are pgvector’s four native distance operators: l2/euclidean (<->), cosine/cos (<=>), ip/inner/dot (<#>) and l1/manhattan (<+>). It composes with a regular _order, which helps break ties:
GET /db/public/docs?_korder=embedding:l2:[0.12,0.98,-0.3]&_order=-created_at
:vecdist filters by a distance threshold:
GET /db/public/docs?embedding:vecdist=l2:lt:[0.12,0.98,-0.3]:0.5
The format is <column>:vecdist=<metric>:<comparison>:<vector>:<threshold>. Comparison is restricted to =, !=, >, >=, < and <=. A distance is a scalar float, so IN, LIKE and IS NULL don’t apply and are rejected.
Both forms stay safe even where a bound parameter isn’t available. ORDER BY, for example, can’t take a placeholder the way a WHERE value can:
- The column is validated and quoted through the existing
identpackage. That’s the same gate as every other identifier in pREST. - The metric maps to one of four fixed operator strings from a whitelist switch. It never comes from request bytes, so it’s safe to interpolate.
- The vector literal is parsed element by element with
strconv.ParseFloatand rebuilt withstrconv.FormatFloat. Every byte in the rebuilt literal came from Go’s own float formatter. No attacker-controlled byte survives into the SQL string, even without a placeholder. - The threshold in
:vecdistis passed as a bound parameter, since it sits inWHEREwhere placeholders are available.
Integration tests include an adversarial suite. It fires SQL injection payloads (URL-encoded ;, quote-breakout attempts) at all three fields: metric, column and vector. Every one must fail closed with 400, and the table is verified intact afterward.
How to turn on OpenTelemetry tracing
pREST previously emitted only slog JSON to stdout. You couldn’t correlate a slow HTTP request with the SQL round trip behind it without parsing logs by hand. Nor could you measure latency and DB pool pressure across multiple database aliases.
v2.4.0 adds instrumentation under a new [otel] config section, disabled by default:
[otel]
enabled = false
service_name = "prestd"
# endpoint = "localhost:4317"
# protocol = "grpc"
# sample_ratio = 1.0
# metrics_interval = "15s"
# insecure = true # local/dev only
# db_statement = false # record SQL text on DB spans — trusted envs only
With enabled = false, there’s zero OTel overhead and no outbound connections; the providers are no-ops. When on, everything pushes over OTLP/gRPC to the collector you point it at. There’s no new /metrics scrape endpoint, so the HTTP attack surface is unchanged.
Setup also fails closed. If the exporter can’t be built at startup, prestd logs a warning. It keeps serving without telemetry instead of refusing to start.
What gets instrumented:
- Each HTTP request gets one server span via
otelhttp. It’s named by the matched route template (GET /{database}/{schema}/{table}) instead of the raw URL. That keeps span names and metric labels bounded, without leaking path values. - Postgres spans wrap the
lib/pqdriver at the connection seam, tagged with the request’s database alias. SQL text is recorded only whendb_statement = true. - MCP gets a span per JSON-RPC call and a child span per tool invocation. A trace shows the full
POST /_mcp → mcp.rpc/tools/call → mcp.tool/prest.select → sql.stmt.querychain. - Existing
slogoutput now fans out to both stdout and an OTLP log exporter, trace-correlated.
One more new capability ships alongside telemetry. pREST previously had no signal handling at all. prestd now handles SIGINT and SIGTERM and shuts down its http.Server gracefully. It drains in-flight requests and flushes telemetry before exit, whether or not OTel is enabled.
To see it without standing up your own backend, the release ships a self-contained SigNoz stack under dev/signoz/:
make signoz-up
PREST_OTEL_ENABLED=true PREST_OTEL_ENDPOINT=localhost:4317 PREST_OTEL_INSECURE=true ./prestd
# generate some traffic, then open http://localhost:8080
make signoz-down # tears down, removes volumes
What changed in pREST Studio
This one is maintenance only. pREST Studio’s frontend dependencies were upgraded, along with two small UI fixes. The auth dialog now reflects saved credentials and “remember me” state when reopened. The MCP tool-invocation flow no longer builds an incomplete request when argument construction fails partway through. There are no server-side or API changes.
FAQ
Do I need to change my config to get the v2.4.0 security fixes?
No. Upgrading the binary is the fix. Expect _QUERIES templates that reference request headers, and _select or _groupby expressions with nested parens, to start returning 400.
Which v2.4.0 fixes are exploitable without authentication?
The _groupby gap (GHSA-v9v2-98xq-627c) and SQL injection through request headers in _QUERIES templates (GHSA-r3hj-2fxx-7f3h). Both work on a default config, the same attacker model as the v2.3.0 _select bug. Both are CVSS 8.1.
How do I search by vector distance in pREST?
Use _korder=<column>:<metric>:<vector> to order rows by distance, nearest first. Use <column>:vecdist=<metric>:<comparison>:<vector>:<threshold> to filter by a distance threshold. Both need a pgvector vector column and pREST v2.4.0 or later.
Is OpenTelemetry on by default in pREST?
No. The [otel] section ships with enabled = false, and the providers are then no-ops with no outbound connections. Turned on, pREST pushes traces, metrics and logs over OTLP/gRPC.
Sources
Full changelog: github.com/prest/prest/releases/tag/v2.4.0. Docs: docs.prestd.com.