pREST v2.4.0: pgvector Search, OpenTelemetry, and Six More SQL Injection Fixes

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 prestd on the public internet, pull prest/prest:v2.4.0 before reading on. The _groupby and header-driven _QUERIES fixes close unauthenticated holes, the same attacker model as the v2.3.0 _select bug.

Table of Contents

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 --help no longer bakes a real, password-bearing DSN into a cobra flag default. Cobra prints flag defaults verbatim in --help output.
  • 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.

  1. Patch now if you’re reachable from untrusted clients. Two of the six fixes (_groupby and header-driven _QUERIES templates) are exploitable without auth on a default config. That’s the same attacker model as the v2.3.0 _select bug.
  2. Check exposure the same way as last time. Grep access logs for _groupby or _select values containing a nested (. If you have _QUERIES templates that read header.*, look for unusual header values too.
  3. Re-audit /batch usage if you rely on table-level ACL. That route silently bypassed it until this release.
  4. If you use MCP, confirm describe_table responses now match your field permissions instead of showing the full schema.
  5. If you cache GET responses across users with different column permissions, this release fixes that leak. You only need the binary upgrade.
  6. pgvector and OpenTelemetry are both opt-in. Nothing changes for existing deployments until you touch [otel] or a vector column.

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 ident package. 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.ParseFloat and rebuilt with strconv.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 :vecdist is passed as a bound parameter, since it sits in WHERE where 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/pq driver at the connection seam, tagged with the request’s database alias. SQL text is recorded only when db_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.query chain.
  • Existing slog output 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.