Skip to content

HTTP and WebSocket endpoints accept unauthenticated requests (WS RPC / stream poll / stream SSE) (3 sub-items) #55

@hollanf

Description

@hollanf

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

  • 1. handle_ws_connection must resolve identity before processing any method. Reject the upgrade (or all non-auth messages) until a valid token is presented. Remove the TenantId::new(1) default.
  • 2. poll_stream must resolve identity and reject tenant_id parameters that mismatch the caller's tenant. Remove .unwrap_or(1) fallback.
  • 3. stream_sse same 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 / websocat against a default-configured node on a network reachable port.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions