Repository navigation
fix(ws): standardize auth rejection on accept-then-close-with-reason across all WS handlers (#12366) - #12527
Merged
Conversation
…across all WS handlers (#12366)
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
#12366is an owner-decided reconciliation: two WS auth-reject conventions coexisted —websockets.py'swebsocket_endpoint/ws/npu-workers/ws-testandtranscripts.py'sanalyze_transcript_wsclosed withcode=4001beforeaccept()(HTTP 403 on the wire, per the old #2818 convention), whilelive_events.pyalready didaccept()thenclose()(real WS close frame). The close-before-accept form made an auth-rejected socket indistinguishable from a missing route — root cause of the #12340 misdiagnosis.Grepped every
@router.websocketinautobot-backend(28 endpoints) and everyclose(4001/close(code=4001site to find all auth-reject call sites, then checked accept-timing at each:websockets.py×3,transcripts.py×1, and the sharedws_security.enforce_ws_adminhelper (used byadvanced_control.py's/ws/monitoring+/ws/desktop/{id}).live_events.py(the template),task_workspace_ws.py'sworkspace_shell(accepts before its admin-auth close), andautobot-slm-backend/api/websocket.py(already documents the exact same GH#10459 uvicorn-403 rationale — good corroborating precedent).Origincheck (enforce_ws_origin,1008) intentionally stays close-before-accept — it's a different security concern (cross-site WS hijacking), not the Security: add authentication to WebSocket endpoints #2818 JWT-auth convention this issue reconciles, and is deliberately pre-handshake elsewhere too (task_workspace_ws.pyfix(ws): task_workspace_ws — Origin gate requires Authorization header but admin-auth accepts cookies → same-origin cookie-admin lockout #11016).desktop_streaming_manager.py'shandle_websocket_clientuses the rawwebsocketslibrary (not FastAPI) — the handshake completes automatically before the handler runs, so there's no.accept()call to reorder there.Checked the frontend WS clients per the task's client-impact ask:
LiveEventService.tsalready special-cases close code4001as terminal (no reconnect).GlobalWebSocketService.ts(the client for/ws, one of the endpoints converted here) had no handling for4001at all — before this fix the browser never even saw it (close-before-accept collapses to an opaque code-1006 abnormal closure in the browser's native WebSocket API), so this was a latent infinite-reconnect-against-a-bad-token bug that would only start manifesting once the backend actually delivers4001. Fixed alongside the backend change.What Changed
Backend — accept-then-close on auth rejection (same auth logic, only the timing changed):
autobot-backend/api/websockets.py:websocket_test_endpoint(/ws-test),websocket_endpoint(/ws),npu_workers_websocket_endpoint(/ws/npu-workers) — each now doesawait websocket.accept()thenawait websocket.close(code=4001, reason="Authentication required")instead of closing before accept.autobot-backend/api/transcripts.py:analyze_transcript_ws— same conversion,reason="Unauthorized".autobot-backend/api/ws_security.py:enforce_ws_admin(shared helper for/ws/monitoring+/ws/desktop/{session_id}inadvanced_control.py) — same conversion,reason="Authentication required (admin)".Frontend — fail-loud handling for the now-visible 4001 close code:
autobot-frontend/src/services/GlobalWebSocketService.ts:scheduleReconnectnow treats close code4001as terminal (likeLiveEventServicealready does for/ws/live) — setsconnectionStateto'error'with a clear message and stops the reconnect loop, instead of retrying against a rejected token forever.Tests:
autobot-backend/tests/test_websockets_auth_reject_12366.py: parametrized over/ws-test,/ws,/ws/npu-workers— asserts an unauthenticated connect is accepted then closed with4001(not a handshake-level rejection), and that a valid bearer proceeds normally.tests/test_ws_security_admin.py::test_enforce_ws_admin_accepts_then_closes_4001_when_denied(renamed from_closes_4001_when_denied) to assertaccept()is called beforeclose().tests/api/test_transcripts.py::test_ws_analyze_rejects_unauthenticatedandtests/test_websocket_auth_smoke.pydocstrings/assertions for the new accept-then-close semantics (starlette'sTestClientnow needs an explicitreceive_text()/receive_json()to observe the close frame, since the handshake itself no longer fails).Filed (not fixed — different scope): #12526 — discovered a genuine unrelated bug while writing the
/ws/npu-workershappy-path test:NPUWorkerManager._load_workers_from_config(sync, runs viaasyncio.to_thread) calls theasync def _save_workers_to_configwithoutawait, silently discarding the first-run auto-save (RuntimeWarning: coroutine ... was never awaited).Verification
(the 1 warning is the pre-existing, now-filed #12526 npu_worker_manager bug — unrelated to this PR's WS changes)
Frontend:
node_modulesisn't installed in this WSL worktree (frontend build/vue-tsc runs on the Windows host per project convention), sovue-tsccouldn't be run directly here. TheGlobalWebSocketService.tschange is a 2-line addition using the exact samethis.connectionState.value = 'error'/this.state.lastError = <string>patterns already used elsewhere in the same file (lines 434/458), and a brace-balance check on the file passed.Confirmed pre-existing, unrelated failures in
tests/test_cross_worker_registries.py::TestTakeoverManagerCrossWorker(MagicMock-await errors intakeover_manager.py— a file untouched by this PR) reproduce in isolation on this branch; not introduced by this change.Closes #12366
Model Used
Claude Sonnet 5