Skip to content

HTTP Query Endpoints Bypass Database-Access and `_system` Catalog Authorization Enforced on pgwire → Cross-Database Read/Write and System-Catalog Disclosure

High
farhan-syah published GHSA-rgrf-mv99-jwgv Jul 22, 2026

Package

cargo nodedb (Rust)

Affected versions

<= 0.3.0

Patched versions

0.4.0

Description

Summary

NodeDB enforces authorization for a query in three layers on the pgwire transport: a database-access gate (enforce_database_access → identity.can_access_database(db), which models the entire GRANT … ON DATABASE axis), a system-catalog block that denies non-superusers access to _system.* collections and consults PermissionStore::check for collection-level grants, and row-level filters injected by the shared planner. The HTTP query handlers (POST /v1/query and POST /v1/query/stream) run only the third layer plus a coarse, database-blind role_grants_permission loop. They never call can_access_database and never apply the _system.* superuser-only block.

The target database is taken verbatim from client input — the X-NodeDB-Database header or the ?database= query parameter — and resolved against the global catalog with no access check. Any authenticated non-superuser can therefore direct queries over HTTP at any database in their tenant regardless of what they were granted, and reach _system.* catalog collections that pgwire refuses them. The entire database-isolation axis and the system-catalog gate are absent on the HTTP transport.

Technical Detail

1. The target database is taken from client input with no access check:

// nodedb/src/control/server/http/routes/query.rs  (resolve_database_id, :47)
let name = headers
    .get("x-nodedb-database")
    .and_then(|v| v.to_str().ok())
    .filter(|s| !s.is_empty())
    .map(|s| s.to_string())
    .or_else(|| param.database.clone().filter(|s| !s.is_empty()));   // client-controlled
...
match catalog.get_database_id_by_name(&db_name) {                    // :65 GLOBAL catalog lookup
    Ok(Some(id)) => Ok(id),                                          // no can_access_database(id)
    ...
}

resolve_database_id resolves the name against the global catalog and returns the DatabaseId. Neither query nor query_ndjson calls identity.can_access_database(database_id) anywhere.

2. The only per-task authz on HTTP is a coarse, database-blind role check:

// nodedb/src/control/server/http/routes/query.rs  (query, :168)
let required = required_permission(&task.plan);
if !identity.is_superuser
    && !identity.roles.iter().any(|r| role_grants_permission(r, required))
{
    return Err(ApiError::Forbidden(...));
}

There is no _system.* block and no PermissionStore::check. role_grants_permission returns true for ReadOnly/ReadWrite/TenantAdmin and for any database-scoped role regardless of which database — permission.rs:81-88 explicitly documents that "the database match is enforced at the call site," and this call site does not enforce it. query_ndjson has no per-task role check at all; only the shared planner runs.

3. pgwire enforces the two missing layers — HTTP does not:

// nodedb/src/control/server/pgwire/handler/core.rs
pub(super) fn enforce_database_access(&self, identity, addr) -> PgWireResult<()> {
    if identity.is_superuser { return Ok(()); }
    let db = self.sessions.get_current_database(addr)...;
    if !identity.can_access_database(db) {                    // :174  ← absent on HTTP
        ... return Err(... "permission denied for database" ...);
    }
    Ok(())
}

pub(super) fn check_permission(&self, identity, plan) -> PgWireResult<()> {
    if identity.is_superuser { return Ok(()); }
    let collection = extract_collection(plan);
    if let Some(coll) = collection && coll.starts_with("_system") {   // :221  ← absent on HTTP
        ... return Err(... "system catalog access requires superuser" ...);
    }
    if let Some(coll) = collection
        && self.state.permissions.check(identity, required, coll, &self.state.roles, ...) // :246
    { return Ok(()); }
    // role fallback ...
}

enforce_database_access is invoked at core.rs:313 (simple query) and prepared/execute.rs:45 (extended); check_permission per task at routing/execute.rs:291.

4. The shared planner does not backstop either gate. Both transports call plan_sql_with_rls (planner/context/query/planning.rs:212-219), which runs inject_rls and inject_permission_tree. inject_permission_tree (planner/rls_injection.rs:320) injects row-level ACL filters and early-returns for superusers — it does not perform the database-access check or the _system superuser-only block. Those live only in the pgwire handler.

Call path (verified)

POST /v1/query   (X-NodeDB-Database: <any-db-in-tenant>)  {"sql":"SELECT * FROM _system.<...>"}
  -> query()                                   http/routes/query.rs:82
     -> resolve_database_id()                  :89   database taken from client header/param
        (NO identity.can_access_database)
     -> plan_sql_with_rls()                    :145  RLS + permission-tree ROW filters only
     -> per task: role_grants_permission loop  :168  db-blind; NO _system block, NO PermissionStore::check
        *** cross-database query executes; _system.* reachable ***

vs. pgwire do_query() -> enforce_database_access() -> check_permission()  (both gates present)

Attack Scenario

  1. Attacker holds a valid credential for a normal non-superuser account (e.g. a ReadOnly or database-scoped role in database A) and can reach the HTTP API.
  2. Attacker sends POST /v1/query with header X-NodeDB-Database: B (a database in the same tenant they were never granted ON DATABASE) and a SELECT/INSERT/UPDATE/DELETE statement. pgwire would reject this with 42501 permission denied for database; HTTP resolves B and runs the query. The whole GRANT … ON DATABASE model is bypassed on this transport.
  3. Attacker sends SELECT … FROM _system.<catalog> over HTTP. pgwire denies non-superusers the system catalog; HTTP has no such block, so the query is planned and dispatched.

Impact

The database-isolation axis (accessible_databases / GRANT ON DATABASE) and the system-catalog gate are silently absent on the HTTP transport. Any authenticated non-superuser can target any database in their tenant and reach _system.* collections that pgwire refuses. Reads and writes both flow through this path — I:L rather than I:H because the planner's RLS and permission-tree row filters still apply on both transports, limiting which specific rows are returned; the database-access and _system gates are nonetheless fully absent. Note that NODE-004 (collection-grant target drops database_id) compounds this: a grant on a same-named collection in database A also satisfies the row filter for B.

Preconditions / severity notes

  • Any authenticated non-superuser suffices; no config change, no key compromise, no special mode.
  • HTTP is a first-class production transport; the finding is not gated behind a dev or experimental flag.
  • query_ndjson carries no per-task role check at all — a user with no role can reach it and rely entirely on row-level filters.

Remediation

Route the HTTP handlers through the same two gates pgwire uses, rather than the inline role_grants_permission-only check:

  1. Immediately after resolve_database_id, reject inaccessible databases: if !identity.can_access_database(database_id) { return Err(ApiError::Forbidden(...)); }
  2. Replace the per-task inline role loop in both query and query_ndjson with the shared check_permission equivalent (superuser → _system.* block → PermissionStore::check → role_grants_permission fallback), so the system-catalog block and collection-grant checks apply.

The correct fix is to lift pgwire's enforce_database_access + check_permission into a transport-neutral helper called from both pgwire and HTTP, so the two transports cannot drift apart again.

Resolution

Fixed by PR #177, merged into main as commit 9ff09f7d47ffcf3afd1f6525496365eeb5743205.

The fix introduces one transport-neutral authorization boundary across pgwire, HTTP JSON/NDJSON, WebSocket RPC, and native materialized/lazy SQL. It enforces database scope and exhaustively authorizes all resources before metadata exposure, triggers, subscriptions, streams, orchestration, Calvin routing, staging, clone probes, or Data Plane dispatch.

Regression coverage verifies denied reads and writes without mutation, database-scoped API keys, explicit collection grants, multi-resource joins, prepared metadata, cursors, facets, EXPLAIN, LIVE subscriptions, WebSocket upgrade/replay, and native lazy execution. Focused authorization suites and affected transport baselines passed. Please validate against main at the merge commit above or later.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. Learn more on MITRE.

Credits