From 744183e840e1704a266704374dd2d892d9c0095f Mon Sep 17 00:00:00 2001 From: Gareth Morgan Date: Thu, 22 Oct 2026 10:18:38 -0400 Subject: [PATCH 1/3] fix(cloudxr): require a control token on the OOB teleop hub The hub treated an unset CONTROL_TOKEN as "authentication disabled", so anyone who could reach the proxy port could read headset state, rewrite the shared stream config, and register as a headset. - check_token() now fails closed and compares in constant time; WebSocket register goes through it instead of its own variant. - When CONTROL_TOKEN is unset or empty, the proxy uses a random per-process token, shared by the hub, headset URL and startup banner through resolve_control_token(), and prints it once in the banner. - Tests and the standalone test hub now authenticate; add coverage for unauthenticated register/config and the generated token. Signed-off-by: Gareth Morgan Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Gareth Morgan --- .../helpers/controlChannel.test.ts | 6 + docs/source/references/oob_teleop_control.rst | 10 +- .../isaaccapture/cloudxr/oob_teleop_adb.py | 3 +- .../isaaccapture/cloudxr/oob_teleop_env.py | 42 ++++- .../isaaccapture/cloudxr/oob_teleop_hub.py | 17 ++- src/python/isaaccapture/cloudxr/wss.py | 6 +- .../core/cloudxr/oob_hub_test_server.py | 4 +- .../core/cloudxr/test_oob_teleop_env.py | 42 +++++ .../core/cloudxr/test_oob_teleop_hub.py | 143 ++++++++++++++---- 9 files changed, 226 insertions(+), 47 deletions(-) diff --git a/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts b/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts index b699df818b..a7bdbe88eb 100644 --- a/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts +++ b/deps/cloudxr/webxr_client/helpers/controlChannel.test.ts @@ -39,6 +39,9 @@ const SERVER_SCRIPT = path.join( 'oob_hub_test_server.py' ); +// Must match TEST_CONTROL_TOKEN in oob_hub_test_server.py. +const HUB_TOKEN = 'oob-hub-test-token'; + // Safety net for afterEach cleanup: every hub PID spawned goes in here and comes out once // confirmed exited. If a test's afterEach is ever skipped (a Jest hook-ordering edge case, // not something this file's own control flow can prevent), this file's own `exit` handler @@ -156,6 +159,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke channel = new HeadsetControlChannel({ url: `ws://127.0.0.1:${hub.port}`, + token: HUB_TOKEN, deviceLabel: 'jest-test', onConfig: (config, configVersion) => { configs.push({ config, version: configVersion }); @@ -193,6 +197,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke channel = new HeadsetControlChannel({ url: `ws://127.0.0.1:${hub.port}`, + token: HUB_TOKEN, onConfig: () => {}, }); channel.connect(); @@ -216,6 +221,7 @@ describe('HeadsetControlChannel <-> OOBControlHub (real instances, real WebSocke channel = new HeadsetControlChannel({ url: `ws://127.0.0.1:${hub.port}`, + token: HUB_TOKEN, onConfig: () => {}, metricsIntervalMs: 20, getMetricsSnapshot: () => [{ cadence: 'frame', metrics: { StreamingFramerate: 72.5 } }], diff --git a/docs/source/references/oob_teleop_control.rst b/docs/source/references/oob_teleop_control.rst index de86175725..2ec0bffe93 100644 --- a/docs/source/references/oob_teleop_control.rst +++ b/docs/source/references/oob_teleop_control.rst @@ -228,7 +228,7 @@ Registration (first message) "payload": { "role": "headset", "deviceLabel": "Quest 3", - "token": "" + "token": "" } } @@ -497,8 +497,10 @@ if not connected). Authentication -------------- -Set ``CONTROL_TOKEN=`` to require a token on all hub operations. -Pass it as: +Every hub operation requires a token. Set ``CONTROL_TOKEN=`` to choose +it; if it is unset or empty, the hub generates a random token for the session and +prints it in the startup banner. Requests without the token, and WebSocket +``register`` frames without it, are rejected. Pass it as: - WebSocket: ``"token"`` field in the ``register`` payload - HTTP: ``?token=`` query parameter or ``X-Control-Token`` header @@ -551,7 +553,7 @@ Environment variables and signaling share this socket. In ``--usb-local`` mode ``adb reverse`` maps it to the headset. * - ``CONTROL_TOKEN`` - - Optional auth token for hub access + - Auth token for hub access; a random per-session token is generated if unset * - ``TELEOP_STREAM_SERVER_IP`` - Override the auto-detected LAN IP in hub initial config * - ``TELEOP_PROXY_HOST`` diff --git a/src/python/isaaccapture/cloudxr/oob_teleop_adb.py b/src/python/isaaccapture/cloudxr/oob_teleop_adb.py index 63a5d74077..5bf23f2d00 100644 --- a/src/python/isaaccapture/cloudxr/oob_teleop_adb.py +++ b/src/python/isaaccapture/cloudxr/oob_teleop_adb.py @@ -45,6 +45,7 @@ client_ui_fields_from_env, oob_progress, redact_control_token, + resolve_control_token, resolve_lan_host_for_oob, web_client_base_override_from_env, ) @@ -604,7 +605,7 @@ def build_teleop_url( default_base = default_web_client_origin() web_base = ovr if ovr else default_base - token = os.environ.get("CONTROL_TOKEN") or None + token = resolve_control_token() return build_headset_bookmark_url( web_client_base=web_base, stream_config=stream_cfg, diff --git a/src/python/isaaccapture/cloudxr/oob_teleop_env.py b/src/python/isaaccapture/cloudxr/oob_teleop_env.py index 58d766a351..d4d56a34cb 100644 --- a/src/python/isaaccapture/cloudxr/oob_teleop_env.py +++ b/src/python/isaaccapture/cloudxr/oob_teleop_env.py @@ -9,9 +9,11 @@ import math import os import re +import secrets import socket import subprocess import sys +import threading from importlib.metadata import PackageNotFoundError, version from pathlib import Path from urllib.error import URLError @@ -22,6 +24,35 @@ from .oob_teleop_hub import OOB_WS_PATH log = logging.getLogger("isaaccapture.cloudxr.oob_teleop_env") + +_generated_control_token: str | None = None +_generated_control_token_lock = threading.Lock() + + +def control_token_is_generated() -> bool: + """``True`` when ``CONTROL_TOKEN`` is unset and the hub uses a random token.""" + return not os.environ.get("CONTROL_TOKEN") + + +def resolve_control_token() -> str: + """Return the hub control token: ``CONTROL_TOKEN``, else a per-process random one. + + The hub never runs unauthenticated. Every reader (hub, headset URL, banner) + must call this so they agree; it does not modify ``os.environ``. + """ + global _generated_control_token + # An empty CONTROL_TOKEN counts as unset: it must never mean "no auth". + configured = os.environ.get("CONTROL_TOKEN") + if configured: + return configured + # The proxy runs on a thread, so the lazy generation is lock-guarded to + # keep the hub, headset URL and banner on the same token. + with _generated_control_token_lock: + if _generated_control_token is None: + _generated_control_token = secrets.token_urlsafe(32) + return _generated_control_token + + progress_log = logging.getLogger("isaaccapture.cloudxr.oob_teleop_env.oob-progress") WSS_PROXY_DEFAULT_PORT = 48322 @@ -598,7 +629,7 @@ def print_oob_hub_startup_banner( port = wss_proxy_port() backend_port = usb_backend_port() turn_port = usb_turn_port() - token = os.environ.get("CONTROL_TOKEN") or None + token = resolve_control_token() if not lan_host: lan_host = resolve_lan_host_for_oob() if not usb_local else "127.0.0.1" @@ -729,11 +760,10 @@ def print_oob_hub_startup_banner( ) print(f" State: {primary_base}/api/oob/v1/state") print(f" Config: {primary_base}/api/oob/v1/config?{cfg_q}") - if token: - print() - print( - " CONTROL_TOKEN is set: add ?token=... or header X-Control-Token on OOB HTTP requests." - ) + print() + print(" Add header X-Control-Token (or ?token=...) on OOB HTTP requests.") + if control_token_is_generated(): + print(f" CONTROL_TOKEN was not set; this session's token is: {token}") print(bar) print() diff --git a/src/python/isaaccapture/cloudxr/oob_teleop_hub.py b/src/python/isaaccapture/cloudxr/oob_teleop_hub.py index 0b3a9ca3fb..36f79feae9 100644 --- a/src/python/isaaccapture/cloudxr/oob_teleop_hub.py +++ b/src/python/isaaccapture/cloudxr/oob_teleop_hub.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +import hmac import json import logging import time @@ -213,10 +214,16 @@ async def wait_for_streaming( await asyncio.sleep(poll_seconds) def check_token(self, token: str | None) -> bool: - """Return True if token satisfies the hub's auth requirement.""" - if not self._token: - return True - return token == self._token + """Return True only if *token* matches the hub's control token. + + Fails closed: a hub built without a token rejects every caller. + """ + # No configured token or a non-string payload value (JSON can carry ints, + # lists) is never a match; compare_digest would raise on non-str input. + if not self._token or not isinstance(token, str): + return False + # Constant-time compare so response timing does not leak the token. + return hmac.compare_digest(token.encode(), self._token.encode()) async def http_oob_set_config(self, payload: dict) -> tuple[int, dict]: """Merge stream config; for OOB HTTP ``GET /api/oob/v1/config``.""" @@ -260,7 +267,7 @@ async def http_oob_set_config(self, payload: dict) -> tuple[int, dict]: async def _handle_register(self, ws: Any, client_id: str, payload: dict) -> bool: """Validate and register a headset. Returns True on success.""" - if self._token and payload.get("token") != self._token: + if not self.check_token(payload.get("token")): await self._send_error(ws, "UNAUTHORIZED", "Invalid or missing token") try: await ws.close(1008, "Unauthorized") diff --git a/src/python/isaaccapture/cloudxr/wss.py b/src/python/isaaccapture/cloudxr/wss.py index c76b271c6e..43e7f52d2f 100755 --- a/src/python/isaaccapture/cloudxr/wss.py +++ b/src/python/isaaccapture/cloudxr/wss.py @@ -22,8 +22,10 @@ from .env_config import get_env_config from .oob_teleop_env import ( client_ui_fields_from_env, + control_token_is_generated, default_initial_stream_config, redact_control_token, + resolve_control_token, wss_proxy_port, ) from .oob_teleop_hub import OOB_WS_PATH @@ -666,7 +668,7 @@ async def report_fatal(error: Exception, circumstance: str) -> None: if setup_oob: from .oob_teleop_hub import OOBControlHub # noqa: PLC0415 - control_token = os.environ.get("CONTROL_TOKEN") or None + control_token = resolve_control_token() initial = { **default_initial_stream_config(resolved_port), **client_ui_fields_from_env(), @@ -674,7 +676,7 @@ async def report_fatal(error: Exception, circumstance: str) -> None: hub = OOBControlHub(control_token=control_token, initial_config=initial) log.info( "Teleop control hub enabled (token=%s) OOB_WS=%s initial_stream=%s", - "set" if control_token else "none", + "generated" if control_token_is_generated() else "set", OOB_WS_PATH, initial, ) diff --git a/tests/python/core/cloudxr/oob_hub_test_server.py b/tests/python/core/cloudxr/oob_hub_test_server.py index b397be450d..1c9b481dae 100644 --- a/tests/python/core/cloudxr/oob_hub_test_server.py +++ b/tests/python/core/cloudxr/oob_hub_test_server.py @@ -48,6 +48,8 @@ from oob_teleop_hub import OOBControlHub # noqa: E402 +# controlChannel.test.ts passes the same token. +TEST_CONTROL_TOKEN = "oob-hub-test-token" POLL_INTERVAL_S = 0.05 _WS_HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" _OPCODE_TEXT, _OPCODE_CLOSE, _OPCODE_PING, _OPCODE_PONG = 0x1, 0x8, 0x9, 0xA @@ -178,7 +180,7 @@ def _fingerprint(snapshot: dict) -> tuple: async def main() -> None: """Start the hub, announce readiness, then print a snapshot on every state change.""" - hub = OOBControlHub() + hub = OOBControlHub(control_token=TEST_CONTROL_TOKEN) async def handle_tcp( reader: asyncio.StreamReader, writer: asyncio.StreamWriter diff --git a/tests/python/core/cloudxr/test_oob_teleop_env.py b/tests/python/core/cloudxr/test_oob_teleop_env.py index ab799716e2..c9138c214a 100644 --- a/tests/python/core/cloudxr/test_oob_teleop_env.py +++ b/tests/python/core/cloudxr/test_oob_teleop_env.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging +import os from urllib.parse import parse_qs, urlparse import cloudxr_py_test_ns.oob_teleop_env as oob_teleop_env_under_test @@ -612,6 +613,47 @@ def test_startup_banner_hides_control_and_turn_credentials( assert "turnCredential=" in out +def test_resolve_control_token_prefers_configured( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CONTROL_TOKEN", "configured") + assert oob_teleop_env_under_test.resolve_control_token() == "configured" + assert oob_teleop_env_under_test.control_token_is_generated() is False + + +def test_resolve_control_token_generates_stable_random_token( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + first = oob_teleop_env_under_test.resolve_control_token() + assert len(first) >= 32 + assert oob_teleop_env_under_test.resolve_control_token() == first + assert oob_teleop_env_under_test.control_token_is_generated() is True + assert "CONTROL_TOKEN" not in os.environ + + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + assert oob_teleop_env_under_test.resolve_control_token() != first + + +def test_resolve_control_token_treats_empty_env_as_unset( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + monkeypatch.setenv("CONTROL_TOKEN", "") + assert oob_teleop_env_under_test.resolve_control_token() + assert oob_teleop_env_under_test.control_token_is_generated() is True + + +def test_startup_banner_shows_generated_token_once( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + monkeypatch.setattr(oob_teleop_env_under_test, "_generated_control_token", None) + + print_oob_hub_startup_banner(lan_host="127.0.0.1", usb_local=True) + out = capsys.readouterr().out + assert oob_teleop_env_under_test.resolve_control_token() in out + + def test_oob_progress_logs_at_info( monkeypatch: pytest.MonkeyPatch, capsys, caplog ) -> None: diff --git a/tests/python/core/cloudxr/test_oob_teleop_hub.py b/tests/python/core/cloudxr/test_oob_teleop_hub.py index a1c7924df5..173783d568 100644 --- a/tests/python/core/cloudxr/test_oob_teleop_hub.py +++ b/tests/python/core/cloudxr/test_oob_teleop_hub.py @@ -21,6 +21,8 @@ from oob_teleop_hub import OOBControlHub +TOKEN = "test-control-token" + class QueueWS: """Minimal async-iterable WebSocket stand-in for :meth:`OOBControlHub.handle_connection`.""" @@ -56,10 +58,12 @@ def _loads_sent(ws: QueueWS) -> list[dict]: return [json.loads(s) for s in ws.sent] -def test_check_token_no_requirement() -> None: +def test_check_token_without_configured_token_fails_closed() -> None: hub = OOBControlHub(control_token=None) - assert hub.check_token(None) is True - assert hub.check_token("anything") is True + assert hub.check_token(None) is False + assert hub.check_token("") is False + assert hub.check_token("anything") is False + assert OOBControlHub(control_token="").check_token("") is False def test_check_token_required() -> None: @@ -67,11 +71,13 @@ def test_check_token_required() -> None: assert hub.check_token(None) is False assert hub.check_token("wrong") is False assert hub.check_token("secret") is True + assert hub.check_token(123) is False # type: ignore[arg-type] + assert hub.check_token("sécret") is False @pytest.mark.asyncio async def test_get_snapshot_empty() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) snap = await hub.get_snapshot() assert snap["configVersion"] == 0 assert snap["config"] == {} @@ -81,13 +87,18 @@ async def test_get_snapshot_empty() -> None: @pytest.mark.asyncio async def test_headset_register_hello_and_snapshot() -> None: - hub = OOBControlHub(initial_config={"serverIP": "1.2.3.4", "port": 1111}) + hub = OOBControlHub( + control_token=TOKEN, initial_config={"serverIP": "1.2.3.4", "port": 1111} + ) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) await ws.inject( json.dumps( - {"type": "register", "payload": {"role": "headset", "deviceLabel": "Q3"}} + { + "type": "register", + "payload": {"role": "headset", "deviceLabel": "Q3", "token": TOKEN}, + } ) ) await asyncio.sleep(0) @@ -109,10 +120,12 @@ async def test_headset_register_hello_and_snapshot() -> None: @pytest.mark.asyncio async def test_baseline_client_can_register_stream_without_health_probe_reply() -> None: """The pre-healthProbe client still sends its existing OOB messages.""" - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) await ws.inject( json.dumps({"type": "streamStatus", "payload": {"streaming": True}}) @@ -130,10 +143,12 @@ async def test_baseline_client_can_register_stream_without_health_probe_reply() @pytest.mark.asyncio async def test_generation_probe_requires_matching_fresh_client() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() handler = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) registered_at = (await hub.get_snapshot())["headsets"][0]["registeredAt"] / 1000 assert await hub.probe_browser(1, registered_at + 1, timeout=0.02) is None @@ -201,11 +216,15 @@ async def test_register_rejects_bad_token() -> None: @pytest.mark.asyncio async def test_register_rejects_non_headset_role() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "dashboard"}})) + await ws.inject( + json.dumps( + {"type": "register", "payload": {"role": "dashboard", "token": TOKEN}} + ) + ) await asyncio.sleep(0) err = json.loads(ws.sent[0]) assert err["type"] == "error" @@ -217,7 +236,7 @@ async def test_register_rejects_non_headset_role() -> None: @pytest.mark.asyncio async def test_first_message_must_be_register() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) @@ -232,11 +251,13 @@ async def test_first_message_must_be_register() -> None: @pytest.mark.asyncio async def test_client_metrics_stored_in_snapshot() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) await ws.inject( json.dumps( @@ -263,11 +284,13 @@ async def test_client_metrics_stored_in_snapshot() -> None: @pytest.mark.asyncio async def test_stream_status_updates_snapshot() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) # Snapshot before any streamStatus: defaults to streaming=False, since=None. @@ -309,10 +332,12 @@ async def test_stream_status_updates_snapshot() -> None: @pytest.mark.asyncio async def test_structured_stream_status_retains_terminal_until_new_attempt() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) await ws.inject( @@ -375,11 +400,13 @@ async def test_structured_stream_status_retains_terminal_until_new_attempt() -> @pytest.mark.asyncio async def test_wait_for_streaming_returns_on_rising_edge() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) waiter = asyncio.create_task(hub.wait_for_streaming(poll_seconds=0.01)) @@ -400,11 +427,13 @@ async def test_wait_for_streaming_returns_on_rising_edge() -> None: @pytest.mark.asyncio async def test_unknown_message_type_returns_error() -> None: - hub = OOBControlHub() + hub = OOBControlHub(control_token=TOKEN) ws = QueueWS() task = asyncio.create_task(hub.handle_connection(ws)) - await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) ws.sent.clear() @@ -419,9 +448,11 @@ async def test_unknown_message_type_returns_error() -> None: @pytest.mark.asyncio async def test_http_oob_set_config_noop_returns_changed_false() -> None: - hub = OOBControlHub(initial_config={"serverIP": "10.0.0.1", "port": 9000}) + hub = OOBControlHub( + control_token=TOKEN, initial_config={"serverIP": "10.0.0.1", "port": 9000} + ) status, body = await hub.http_oob_set_config( - {"config": {"serverIP": "10.0.0.1", "port": 9000}, "token": None} + {"config": {"serverIP": "10.0.0.1", "port": 9000}, "token": TOKEN} ) assert status == 200 assert body.get("changed") is False @@ -429,15 +460,19 @@ async def test_http_oob_set_config_noop_returns_changed_false() -> None: @pytest.mark.asyncio async def test_http_oob_set_config_updates_and_pushes() -> None: - hub = OOBControlHub(initial_config={"serverIP": "127.0.0.1", "port": 49100}) + hub = OOBControlHub( + control_token=TOKEN, initial_config={"serverIP": "127.0.0.1", "port": 49100} + ) hw = QueueWS() th = asyncio.create_task(hub.handle_connection(hw)) - await hw.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await hw.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": TOKEN}}) + ) await asyncio.sleep(0) hw.sent.clear() status, body = await hub.http_oob_set_config( - {"config": {"serverIP": "10.0.0.2"}, "token": None} + {"config": {"serverIP": "10.0.0.2"}, "token": TOKEN} ) assert status == 200 assert body.get("changed") is True @@ -447,3 +482,55 @@ async def test_http_oob_set_config_updates_and_pushes() -> None: await hw.end_stream() await th + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hub_token", [None, ""]) +async def test_register_rejected_when_hub_has_no_token(hub_token) -> None: + hub = OOBControlHub(control_token=hub_token) + ws = QueueWS() + task = asyncio.create_task(hub.handle_connection(ws)) + + await ws.inject(json.dumps({"type": "register", "payload": {"role": "headset"}})) + await asyncio.sleep(0) + assert json.loads(ws.sent[0])["payload"]["code"] == "UNAUTHORIZED" + assert ws.close_calls + assert (await hub.get_snapshot())["headsets"] == [] + + await ws.end_stream() + await task + + +@pytest.mark.asyncio +async def test_register_rejects_non_string_token() -> None: + hub = OOBControlHub(control_token="123") + ws = QueueWS() + task = asyncio.create_task(hub.handle_connection(ws)) + + await ws.inject( + json.dumps({"type": "register", "payload": {"role": "headset", "token": 123}}) + ) + await asyncio.sleep(0) + assert json.loads(ws.sent[0])["payload"]["code"] == "UNAUTHORIZED" + + await ws.end_stream() + await task + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hub_token", [None, TOKEN]) +@pytest.mark.parametrize("caller_token", [None, "", "wrong"]) +async def test_http_oob_set_config_rejects_unauthenticated( + hub_token, caller_token +) -> None: + hub = OOBControlHub( + control_token=hub_token, initial_config={"serverIP": "10.0.0.1", "port": 9000} + ) + status, body = await hub.http_oob_set_config( + {"config": {"serverIP": "attacker.example"}, "token": caller_token} + ) + assert status == 401 + assert body == {"error": "Unauthorized"} + snapshot = await hub.get_snapshot() + assert snapshot["config"] == {"serverIP": "10.0.0.1", "port": 9000} + assert snapshot["configVersion"] == 0 From 093595c7fd60a7499be61a75ecaf4256d8defea5 Mon Sep 17 00:00:00 2001 From: Gareth Morgan Date: Thu, 22 Oct 2026 10:50:18 -0400 Subject: [PATCH 2/3] docs(cloudxr): show the control token in OOB hub quick-start examples The hub now always requires a token, so the manual headset URL and the state/config curl examples failed without one. Add controlToken to the manual bookmark and X-Control-Token to every hub HTTP example, and note that the banner redacts the generated token in its bookmark. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Gareth Morgan --- docs/source/references/oob_teleop_control.rst | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/source/references/oob_teleop_control.rst b/docs/source/references/oob_teleop_control.rst index 2ec0bffe93..467c8f087e 100644 --- a/docs/source/references/oob_teleop_control.rst +++ b/docs/source/references/oob_teleop_control.rst @@ -85,14 +85,18 @@ You should see output confirming the hub is running: If the adb automation fails (e.g. headset not paired), you can manually open the client URL on the headset browser with **all three** required query -parameters — ``oobEnable``, ``serverIP``, and ``port``: +parameters — ``oobEnable``, ``serverIP``, and ``port`` — plus the hub's +``controlToken``: .. parsed-literal:: - |web_client_url|\ ?oobEnable=1&serverIP=&port=48322 + |web_client_url|\ ?oobEnable=1&serverIP=&port=48322&controlToken= Replace ```` with the streaming host's LAN IP. The ``port`` must -match the proxy port (default 48322). +match the proxy port (default 48322). ```` is the value of +``CONTROL_TOKEN``, or the generated token printed in the startup banner; the +banner's bookmark shows it as ````, so substitute it before pasting. +Without the token the hub rejects the headset and the client does not retry. .. note:: @@ -107,7 +111,7 @@ self-signed certificate check): .. code-block:: bash - curl -k https://:48322/api/oob/v1/state + curl -k -H "X-Control-Token: " https://:48322/api/oob/v1/state You should see the headset listed under ``"headsets"`` with ``"connected": true``: @@ -143,7 +147,7 @@ configuration overrides via the HTTP config API: .. code-block:: bash - curl -k "https://:48322/api/oob/v1/config?serverIP=&port=48322&codec=av1" + curl -k -H "X-Control-Token: " "https://:48322/api/oob/v1/config?serverIP=&port=48322&codec=av1" See ``GET /api/oob/v1/config`` below for all supported keys. @@ -157,7 +161,7 @@ from a PC to collect them: .. code-block:: bash # Poll every 2 seconds (adjust to taste) - watch -n 2 'curl -sk https://:48322/api/oob/v1/state | python3 -m json.tool' + watch -n 2 'curl -sk -H "X-Control-Token: " https://:48322/api/oob/v1/state | python3 -m json.tool' The ``metricsByCadence`` field on each headset entry will now contain live streaming metrics. @@ -444,7 +448,7 @@ version. .. code-block:: bash - curl -k https://localhost:48322/api/oob/v1/state + curl -k -H "X-Control-Token: " https://localhost:48322/api/oob/v1/state Example response: @@ -477,7 +481,7 @@ Push config to connected headsets via query parameters: .. code-block:: bash - curl -k "https://localhost:48322/api/oob/v1/config?serverIP=10.0.0.5&port=48322" + curl -k -H "X-Control-Token: " "https://localhost:48322/api/oob/v1/config?serverIP=10.0.0.5&port=48322" Example response: @@ -513,7 +517,7 @@ The web client connects to the hub when the page URL contains .. parsed-literal:: - |web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322 + |web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322&controlToken= The client builds ``wss://{serverIP}:{port}/oob/v1/ws`` and: From e643f624be096a41f09bc09aea336d459d6cca2d Mon Sep 17 00:00:00 2001 From: Gareth Morgan Date: Thu, 22 Oct 2026 11:24:06 -0400 Subject: [PATCH 3/3] docs(cloudxr): note that the headset bookmark carries the control token Make explicit that the token is part of the bookmark URL query, so URLs should be treated as secrets and the host-served client preferred. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Gareth Morgan --- docs/source/references/oob_teleop_control.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/references/oob_teleop_control.rst b/docs/source/references/oob_teleop_control.rst index 467c8f087e..25d69ee59f 100644 --- a/docs/source/references/oob_teleop_control.rst +++ b/docs/source/references/oob_teleop_control.rst @@ -509,6 +509,10 @@ prints it in the startup banner. Requests without the token, and WebSocket - WebSocket: ``"token"`` field in the ``register`` payload - HTTP: ``?token=`` query parameter or ``X-Control-Token`` header +The headset bookmark carries the token as ``controlToken`` in the URL query, so +the hosting server and any proxy logs can see it. Treat bookmark URLs as +secrets and prefer a client served from the streaming host (``--host-client``). + Web client integration ----------------------