Repository navigation
Description
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_routesatsrc/server/app.rs:559-564viaroutes::realtime::realtime_router(engine), inside the API-key and--api-prefixlayers. realtime_ws(src/server/routes/realtime.rs:59-70) goes straight tows.on_upgrade(...);run_connection(src/server/routes/realtime.rs:154-190) then reserves the engine slot withengine.try_reserve. Nothing readsOrigin.cors_middleware/apply_cors_policy(src/server/cors.rs:222-268) only stampsAccess-Control-Allow-Originon the response; it never rejects a request. Router-mode sub-apps usecreate_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 incli_input.rs:957-971and stored inServerConfig::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_localhostand theOriginPolicyvariants; do not parse origins a second way. - Ordering:
api_key_authstays outer, so a request that fails both checks gets 401, not 403. Keep that. - Edge cases: a non-UTF-8
Originis rejected underLocalhost(asorigin_is_localhostalready does) and compared as bytes underLiteral/AllowList.
Acceptance Criteria
- With
--allowed-origins https://app.example.com, an upgrade carryingOrigin: https://evil.examplegets HTTP 403 and nosession.created; the engine slot stays free. - With the same policy,
Origin: https://app.example.comand a request with noOriginboth upgrade and receivesession.created. - Under
--cors-origins localhost,Origin: http://localhost:3000upgrades andOrigin: https://localhost.evil.comgets 403. - Default policy (
CorsPolicy::default()) accepts anyOrigin, so existing behavior is unchanged. - Unit test
websocket_origin_matrixinsrc/server/cors_tests.rscovers every row and column of the table above, includingOrigin: null. - Integration tests in
tests/realtime_ws.rs: addstart_server_with_policy(policy: CorsPolicy) -> String(existingstart_servercalls it withCorsPolicy::default(); build restricted policies with the publicCorsPolicy::resolve), anddisallowed_origin_is_rejected_before_sessionwhich connects with anOriginheader viaIntoClientRequest, assertstokio_tungstenite::tungstenite::Error::Http(resp)withresp.status() == 403, then connects with an allowedOriginand with noOriginin turn and receivessession.createdeach 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:106still 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 -- --checkManual 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
fix(server): enforce CORS origin policy on /v1/realtime upgrade
fix(server): enforce CORS origin policy on /v1/realtime upgrade (labl…