Skip to content

fix(server): enforce CORS origin policy on /v1/realtime WebSocket upgrade #2042

Closed

Description

@inureyes

Follow-up of epic #1372 (PRs #2037, #2039, #2041).

Problem / Background

GET /v1/realtime accepts a WebSocket upgrade from any Origin, whatever --cors-origins or --allowed-origins says. Browsers do not apply CORS to WebSocket upgrades: a page on any site can open new WebSocket("ws://127.0.0.1:8080/v1/realtime"), and the browser sends the upgrade with that site's Origin and completes the handshake regardless of Access-Control-Allow-Origin. The server has to check Origin itself (cross-site WebSocket hijacking). Today an operator who sets --cors-origins localhost or --allowed-origins https://app.example.com believes browser access is restricted, but a page on any other site can still take the only realtime slot, push audio, and read every transcript, text, and audio event.

Priority is medium, not high: the default bind is 127.0.0.1 (src/bin/mlx_server.rs:421), and with --api-key set a browser cannot present a credential on an upgrade (browsers cannot set Authorization or X-API-Key on new WebSocket, see presented_credential at src/server/auth.rs:227-241), so the exposed case is a server without an API key reached from a page the user visits. The impact is occupation of the single session slot (DoS for the real client) and compute use; the attacker sees only output generated from its own audio.

Current Behavior

  • The route is mounted in build_routes at src/server/app.rs:559-564 via routes::realtime::realtime_router(engine), inside the API-key and --api-prefix layers.
  • realtime_ws (src/server/routes/realtime.rs:59-70) goes straight to ws.on_upgrade(...); run_connection (src/server/routes/realtime.rs:154-190) then reserves the engine slot with engine.try_reserve. Nothing reads Origin.
  • cors_middleware / apply_cors_policy (src/server/cors.rs:222-268) only stamps Access-Control-Allow-Origin on the response; it never rejects a request. Router-mode sub-apps use create_app_without_cors (src/server/app.rs:174-176), so even that stamp is absent there.
  • The origin policy is CorsPolicy { origins: OriginPolicy, .. } (src/server/cors.rs:52-81), resolved once in cli_input.rs:957-971 and stored in ServerConfig::cors_policy (src/server/config.rs:993). origin_is_localhost (src/server/cors.rs:191-211) already implements upstream's host check.

Proposed Solution

Policy check. Add pub(crate) fn permits_websocket_origin(&self, origin: Option<&HeaderValue>) -> bool to CorsPolicy in src/server/cors.rs, with this matrix:

OriginPolicy Origin absent Origin present
Wildcard (--cors-origins *, the default) accept accept any value
Localhost accept accept only when origin_is_localhost(value)
Literal(v) accept accept only when the value is byte-equal to v
AllowList(list) accept accept only when list.contains(value)

Wildcard accepts everything because HTTP routes under * already reflect any Origin, so this fix does not change the default posture; it makes the realtime socket obey an operator's restriction. An absent Origin is accepted because browsers always send it on a WebSocket upgrade, and non-browser clients (websocat, examples/voicechat_microphone.rs, tests/realtime_ws.rs) do not. Origin: null is an ordinary string and goes through the same rules (rejected under Localhost and AllowList, accepted under Literal only if the literal is null). The check is independent of credentials.

Handler. Change realtime_router (src/server/routes/realtime.rs:50-57) to pub fn realtime_router<S>(engine: Arc<RealtimeVoiceChatEngine>, cors: Arc<CorsPolicy>) -> Router<S>, with router state #[derive(Clone)] struct RealtimeRouteState { engine: Arc<RealtimeVoiceChatEngine>, cors: Arc<CorsPolicy> }. realtime_ws takes headers: HeaderMap and, before touching ws, returns 403 Forbidden with a JSON body {"error":{"message":"origin not allowed for /v1/realtime","type":"invalid_request_error"}} when permits_websocket_origin(headers.get(ORIGIN)) is false, and logs one tracing::warn! with the rejected origin. The rejection happens before on_upgrade, so no reservation is taken and no session is created.

Wiring. Update the call site at src/server/app.rs:563 to pass Arc::new(state.config.cors_policy.clone()).

Rejected alternative: rejecting in cors_middleware. It is skipped for router-mode sub-apps and would change HTTP behavior that follows b10621; the check belongs to the one route that needs it.

Scope

In scope: src/server/cors.rs (new method), src/server/cors_tests.rs (matrix test), src/server/routes/realtime.rs (state, signature, 403), src/server/app.rs:563 (call site), tests/realtime_ws.rs (helper and tests), one sentence in docs/nemotron-voicechat.md:96 next to the --api-key note stating that --cors-origins / --allowed-origins also gate the upgrade by Origin.

Out of scope: changing the default --cors-origins * posture, and HTTP-route CORS behavior. Router mode does not proxy upgrades today, so no router change.

Implementation Notes

  • Reuse: origin_is_localhost and the OriginPolicy variants; do not parse origins a second way.
  • Ordering: api_key_auth stays outer, so a request that fails both checks gets 401, not 403. Keep that.
  • Edge cases: a non-UTF-8 Origin is rejected under Localhost (as origin_is_localhost already does) and compared as bytes under Literal / AllowList.

Acceptance Criteria

  • With --allowed-origins https://app.example.com, an upgrade carrying Origin: https://evil.example gets HTTP 403 and no session.created; the engine slot stays free.
  • With the same policy, Origin: https://app.example.com and a request with no Origin both upgrade and receive session.created.
  • Under --cors-origins localhost, Origin: http://localhost:3000 upgrades and Origin: https://localhost.evil.com gets 403.
  • Default policy (CorsPolicy::default()) accepts any Origin, so existing behavior is unchanged.
  • Unit test websocket_origin_matrix in src/server/cors_tests.rs covers every row and column of the table above, including Origin: null.
  • Integration tests in tests/realtime_ws.rs: add start_server_with_policy(policy: CorsPolicy) -> String (existing start_server calls it with CorsPolicy::default(); build restricted policies with the public CorsPolicy::resolve), and disallowed_origin_is_rejected_before_session which connects with an Origin header via IntoClientRequest, asserts tokio_tungstenite::tungstenite::Error::Http(resp) with resp.status() == 403, then connects with an allowed Origin and with no Origin in turn and receives session.created each time (proving the rejected attempt held no reservation). This test fails on current code, where the disallowed origin upgrades.
  • The existing test at src/server/routes/realtime_route_tests.rs:106 still passes (route absent without an engine, 401 under --api-key).

Verification

cargo test --profile test-fast --features metal,accelerate --test realtime_ws
cargo test --profile test-fast --features metal,accelerate --lib server::cors
cargo test --profile test-fast --features metal,accelerate --lib server::routes::realtime
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check

Manual check against a real server: start mlxcel-server -m <VoiceChat checkpoint> --allowed-origins https://app.example.com, then websocat -H 'Origin: https://evil.example' ws://127.0.0.1:8080/v1/realtime must fail with 403 and websocat ws://127.0.0.1:8080/v1/realtime must print session.created.

Activity

  1. added
    type:bugBug fixes, error corrections, or issue resolutions
    type:securitySecurity vulnerability or fix
    and removed
    type:bugBug fixes, error corrections, or issue resolutions
    on Sep 29, 2026
  2. added a commit that references this issue on Sep 29, 2026
    b3d642e
  3. added a commit that references this issue on Sep 29, 2026
    f5c9415
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions