Three HTTP-layer routes skip identity resolution entirely and either pin tenant_id to 1 or read it verbatim from the query string. Any network-reachable client can execute arbitrary SQL as tenant 1 via the WS RPC endpoint, or read any tenant's change-stream events via the HTTP poll / SSE endpoints.
1. WebSocket /ws handler: no authentication; tenant_id hard-coded to 1
File: nodedb/src/control/server/http/routes/ws_rpc.rs:40-89
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws_connection(socket, state))
}
async fn handle_ws_connection(socket: WebSocket, state: AppState) {
let (mut sender, mut receiver) = socket.split();
let shared = Arc::clone(&state.shared);
let tenant_id = TenantId::new(1); // Default tenant; auth can be added via first message
let trace_id = crate::control::trace_context::generate_trace_id();
...
}
The ws_handler calls ws.on_upgrade(...) with no token/cookie/session check. Inside handle_ws_connection, tenant_id is pinned at TenantId::new(1) with a comment explicitly saying "auth can be added via first message". The "auth" method handler (lines 191-239) merely records a caller-supplied session_id for replay-LSN lookup — it validates nothing. Every other method ("query", "live", etc.) then calls execute_sql(..., tenant_id=1, sql) regardless.
Repro:
$ websocat ws://host:port/ws
< {"id":1,"method":"query","params":{"sql":"SELECT * FROM t"}}
> { ... results for tenant 1 ... }
No token, no session, no identity. Full read/write as tenant 1.
2. GET /v1/streams/{stream}/poll: no authentication; tenant_id read from query string
File: nodedb/src/control/server/http/routes/stream_poll.rs:42-68
pub async fn poll_stream(
Path(stream_name): Path<String>,
Query(params): Query<PollParams>,
State(state): State<AppState>,
) -> impl IntoResponse {
let group = match params.group { Some(g) => g.to_lowercase(), None => { return BAD_REQUEST; } };
let tenant_id = params.tenant_id.unwrap_or(1);
let limit = params.limit.unwrap_or(100).min(10_000);
let stream_name = stream_name.to_lowercase();
let consume_params = ConsumeParams {
tenant_id,
stream_name: &stream_name,
group_name: &group,
partition: params.partition,
limit,
};
let result = match consume_stream(&state.shared, &consume_params) { ... };
No call to resolve_identity anywhere. tenant_id comes straight from the ?tenant_id= query parameter (defaulting to 1 if omitted). An unauthenticated caller iterates tenant IDs and reads any tenant's change-stream contents:
curl 'http://host:port/v1/streams/stream1/poll?group=group1&tenant_id=42'
curl 'http://host:port/v1/streams/stream1/poll?group=group1&tenant_id=7'
curl 'http://host:port/v1/streams/stream1/poll?group=group1&tenant_id=99'
3. GET /v1/streams/{stream}/events (SSE): same pattern + holds consumer-group slot per request
File: nodedb/src/control/server/http/routes/stream_sse.rs:56-82
Same unauthenticated tenant_id = params.tenant_id.unwrap_or(1) shape as #2. Additionally, SSE joins a consumer-group partition assignment for the supplied (tenant_id, group) pair — an attacker can poison partition assignment for legitimate consumers by claiming slots in their group, causing delivery starvation.
Repro:
curl -N 'http://host:port/v1/streams/stream1/events?group=group1&tenant_id=42'
Why these are important
- 1 is unauthenticated SQL execution end-to-end. Read, write, DDL — everything that
execute_sql dispatches — as tenant 1, from any network reachability.
- 2 and 3 break tenant isolation end-to-end on the CDC surface, and 3 additionally enables a DoS against legitimate consumers by claiming their group slots.
- The existence of a working auth layer elsewhere in pgwire and the native listeners makes this a plain "route forgot to call
resolve_identity" class, not a fundamental design gap — fix is localised but the exposure is full.
Checklist
All three are independently reproducible with curl / websocat against a default-configured node on a network reachable port.
Three HTTP-layer routes skip identity resolution entirely and either pin
tenant_idto1or read it verbatim from the query string. Any network-reachable client can execute arbitrary SQL as tenant 1 via the WS RPC endpoint, or read any tenant's change-stream events via the HTTP poll / SSE endpoints.1. WebSocket
/wshandler: no authentication;tenant_idhard-coded to 1File:
nodedb/src/control/server/http/routes/ws_rpc.rs:40-89The
ws_handlercallsws.on_upgrade(...)with no token/cookie/session check. Insidehandle_ws_connection,tenant_idis pinned atTenantId::new(1)with a comment explicitly saying "auth can be added via first message". The"auth"method handler (lines 191-239) merely records a caller-suppliedsession_idfor replay-LSN lookup — it validates nothing. Every other method ("query","live", etc.) then callsexecute_sql(..., tenant_id=1, sql)regardless.Repro:
No token, no session, no identity. Full read/write as tenant 1.
2.
GET /v1/streams/{stream}/poll: no authentication;tenant_idread from query stringFile:
nodedb/src/control/server/http/routes/stream_poll.rs:42-68No call to
resolve_identityanywhere.tenant_idcomes straight from the?tenant_id=query parameter (defaulting to 1 if omitted). An unauthenticated caller iterates tenant IDs and reads any tenant's change-stream contents:3.
GET /v1/streams/{stream}/events(SSE): same pattern + holds consumer-group slot per requestFile:
nodedb/src/control/server/http/routes/stream_sse.rs:56-82Same unauthenticated
tenant_id = params.tenant_id.unwrap_or(1)shape as #2. Additionally, SSE joins a consumer-group partition assignment for the supplied(tenant_id, group)pair — an attacker can poison partition assignment for legitimate consumers by claiming slots in their group, causing delivery starvation.Repro:
Why these are important
execute_sqldispatches — as tenant 1, from any network reachability.resolve_identity" class, not a fundamental design gap — fix is localised but the exposure is full.Checklist
handle_ws_connectionmust resolve identity before processing any method. Reject the upgrade (or all non-auth messages) until a valid token is presented. Remove theTenantId::new(1)default.poll_streammust resolve identity and rejecttenant_idparameters that mismatch the caller's tenant. Remove.unwrap_or(1)fallback.stream_ssesame treatment as 2, plus: do not register a consumer-group partition claim unless the caller is authenticated and authorised for that stream/group.All three are independently reproducible with
curl/websocatagainst a default-configured node on a network reachable port.