← Back to Blog

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

pREST v2.4.0 ships three things at once: nearest-neighbor vector search over pgvector, opt-in OpenTelemetry instrumentation with a local SigNoz stack to view it, and a security patch closing six SQL-injection-adjacent gaps an advisory review turned up in the code paths surrounding the v2.3.0 fix. If you run prestd on the public internet, read the security section first.

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

Table of Contents

Security: six more gaps, all siblings of the v2.3.0 fix

v2.3.0 closed GHSA-qvx3-q8vx-9q3c, the critical unauthenticated _select injection. An advisory review afterward went looking for anything that used the same shape of validation gap elsewhere in the codebase, and found six.

_groupby had the same weak gate _select used to have

GHSA-v9v2-98xq-627c (CVSS 8.1). GroupByClause’s raw-SQL-expression branch 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 but allowed parens inside the arguments — so upper((SELECT 1)) UNION SELECT current_user satisfied it just fine, 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 with no parens anywhere inside args — which is what rules out a nested subquery, an inner pg_sleep(...), or a trailing UNION SELECT from riding along in the same field.

Unauthenticated SQLi via HTTP request headers in query templates

GHSA-r3hj-2fxx-7f3h (CVSS 8.1). If you use _QUERIES custom SQL templates, they interpolate request headers the same way they interpolate query parameters — the shipped get_header.read.sql example does exactly this. Headers, unlike query params, 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 — including ones a reverse proxy forwards unmodified, like X-Forwarded-For — could carry SQL into a template that assumed only trusted params.

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 — which AccessControl reads as “not a table route, skip permission checks.” A /batch/{database}/{schema}/{table} request is a 5-segment split (leading empty + batch + 3 real segments) that 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 / 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, returning every column regardless of what the caller was allowed to select. It now runs through the same selectableColumns permission filter and returns an error rather than an empty/partial schema when the caller has no visible columns.

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, meaning one user’s cached response, built under their own column permissions, could be served verbatim to a different user hitting the identical URL with narrower permissions.

The fix appends a SHA-256 digest of the authenticated username to the cache key — a digest rather than the raw username, since Go preserves an unescaped ? in RawQuery, which would otherwise let an attacker craft a URL whose raw-username 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. If you have _QUERIES templates that reference request headers, or _select/_groupby expressions with nested parens, expect those specific shapes to start returning 400.

pgvector: nearest-neighbor ordering and distance filtering

If your table has a pgvector vector column, v2.4.0 adds two new 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,...]

Format is <column>:<metric>:<vector>. Supported metrics: l2/euclidean (<->), cosine/cos (<=>), ip/inner/dot (<#>), l1/manhattan (<+>) — pgvector’s four native distance operators. It composes with a regular _order, useful for breaking 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

Format is <column>:vecdist=<metric>:<comparison>:<vector>:<threshold>, comparison restricted to =, !=, >, >=, <, <= (a distance is a scalar float — IN/LIKE/IS NULL don’t apply and are rejected).

Both paths are worth noting for how they’re built safely without a bound-parameter path always being available (e.g. inside ORDER BY, which can’t take a placeholder the way a WHERE value can):

  • The column is validated and quoted through the existing ident package — same gate as every other identifier in pREST
  • The metric maps to one of four fixed operator strings from a whitelist switch — never derived 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 reconstructed literal came out of Go’s own float formatter, so 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 that fires SQL injection payloads (URL-encoded ;, quote-breakout attempts) at all three fields — metric, column, and vector — and asserts every one fails closed with 400, with the table verified intact afterward.

OpenTelemetry: traces, metrics, and logs — opt-in, push-only

pREST previously emitted only slog JSON to stdout. There was no way to correlate a slow HTTP request with the SQL round-trip that caused it, or measure latency and DB pool pressure across multiple database aliases, without parsing logs by hand.

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

When enabled = false, there’s zero OTel overhead and no outbound connections — the providers are no-ops. When on, everything pushes over OTLP/gRPC to whatever 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 and keeps serving without telemetry rather than refusing to start.

What gets instrumented:

  • HTTP — one server span per request via otelhttp, named by the matched route template (GET /{database}/{schema}/{table}, not the raw URL, so span names and metric labels stay bounded instead of leaking path values)
  • Postgres — spans wrapped around the lib/pq driver at the connection seam, tagged with the request’s database alias; SQL text is only recorded when db_statement = true
  • MCP — a span per JSON-RPC call and a child span per tool invocation, so a trace shows the full POST /_mcp → mcp.rpc/tools/call → mcp.tool/prest.select → sql.stmt.query chain
  • Logs — existing slog output now fans out to both stdout and an OTLP log exporter, trace-correlated

There’s also a genuinely new capability alongside telemetry: pREST previously had no signal handling at all. prestd now handles SIGINT/SIGTERM and shuts down its http.Server gracefully, draining in-flight requests and flushing telemetry before exit — independent of whether 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

Studio dependency bump

A smaller, maintenance-only change: pREST Studio’s frontend dependencies were upgraded, along with two small UI fixes — the auth dialog now correctly reflects saved credentials and “remember me” state when reopened, and the MCP tool-invocation flow no longer builds an incomplete request when argument construction fails partway through. No server-side or API changes.

Upgrading

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, header-driven _QUERIES templates) are unauthenticated-exploitable on a default config, same attacker model as the v2.3.0 _select bug.
  2. Check exposure the same way as last time: grep access logs for _groupby/_select values containing nested (, and for unusual header values if you have _QUERIES templates that read header.*.
  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 rather than showing full schema.
  5. If you cache GET responses across multiple users with different column permissions, this release is the fix for that leak — no config change needed, just the binary upgrade.
  6. pgvector and OpenTelemetry are both opt-in — nothing changes for existing deployments that don’t touch [otel] or a vector column until you turn them on.

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