[Audit v2 #346+#349] WS hardening: duplicate-ID reject + send_command leak#377
[Audit v2 #346+#349] WS hardening: duplicate-ID reject + send_command leak#377dsarno wants to merge 1 commit into
Conversation
… leak Two transport-layer audit fixes in src/godot_ai/transport/websocket.py. Cribbed from abandoned PR #366 (which targeted main); this re-targets beta. The #348 (errno) portion of #366 already shipped via PR #373 and is omitted here. #346 (P1, security): reject a second handshake whose session_id is already registered (close code 4001) instead of silently overwriting the routing map. session_id is `<slug>@<4hex>` — 16 bits of suffix is locally guessable, so without this any local peer could hijack an active session by impersonating its ID. Legitimate plugin reconnect after editor_reload_plugin first triggers ConnectionClosed → unregister, so the new connect still lands. #349 (P2, reliability): wrap send_command's ws.send + wait_for in a try/finally that always pops _pending. Pre-fix, a ws.send raise (ConnectionClosed mid-send, transport error) leaked the Future entry forever; under churn the dict grew unbounded. Happy path still pops via the response receiver, so the finally pop is a no-op there. Tests: tests/integration/test_websocket.py - TestDuplicateHandshake pins the reject + reconnect-after-clean- disconnect paths. - TestPendingFutureCleanup pins the timeout-pop and send-failure-pop behaviors. Closes #346 Closes #349
|
Closing as duplicate of #374, which merged the identical fix to Background: I picked this up per the maintainer's bundle direction, which said PR #366 was closed and a beta-targeted PR was still needed. While I was working, PR #374 was opened/merged from the same source branch ( Releasing the claim on #346 and #349; will pick up the next item from the audit-v2 queue. Generated by Claude Code |
There was a problem hiding this comment.
Pull request overview
This PR hardens the WebSocket transport layer (GodotWebSocketServer) to prevent session hijacking via duplicate session_id handshakes and to eliminate a pending-Future leak when ws.send() raises during send_command. These changes directly address audit-v2 findings (#346 security, #349 reliability) and add integration tests to lock in the new behavior.
Changes:
- Reject duplicate
session_idhandshakes with an application-defined WS close code (4001) and a warning log that identifies the existing peer. - Ensure
send_command()always cleans up_pending[request_id]viatry/finally, preventing leaked Futures on send failures / cancellations / timeouts. - Add integration regression tests covering duplicate-handshake rejection, clean reconnect, and
_pendingcleanup on timeout and send failure.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/godot_ai/transport/websocket.py |
Rejects duplicate session_id connections (close 4001) and wraps send_command pending tracking in try/finally to prevent leaks. |
tests/integration/test_websocket.py |
Adds integration tests for duplicate handshake rejection + reconnect behavior and for _pending cleanup on timeout and send exceptions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Two transport-layer audit fixes in
src/godot_ai/transport/websocket.py. Cribbed from abandoned PR #366 (which targetedmain); this re-targetsbetaper the maintainer's bundle direction in #346 / #349. The #348 (errno) portion of #366 already shipped via PR #373 and is omitted here.session_idpreviously overwrote both_sessions[id]and_connections[id]silently, so a local peer could brute-force the 16-bit hex suffix and steal command routing. Now rejects with WS close code 4001 + warning log naming the existing peer's pid/project. Legitimate reconnect aftereditor_reload_pluginis unaffected —ConnectionClosed → unregisterhappens before the new connect, so the slot is free.send_commandFuture leak:ws.sendraising mid-call left the registered Future stranded in_pendingforever. Wrapped thesend + wait_forintry/finallythat always pops. Happy path still pops via the response receiver, so the finally pop is a no-op there.Test plan
ruff check src/ tests/— passes (the 6 unrelated unformatted files were already that way onbeta)ruff format --checkon changed files (src/godot_ai/transport/websocket.py,tests/integration/test_websocket.py) — cleanpytest -v— 867 passedscript/ci-check-gdscript— all GDScript files OKTestDuplicateHandshake::test_duplicate_session_id_handshake_is_rejected— attack-shape regression: round-trips a command through the original WS after the duplicate is rejected to prove the routing map wasn't hijackedTestDuplicateHandshake::test_reconnect_after_clean_disconnect_succeeds— guards the legitimate reconnect-after-editor_reload_pluginpathTestPendingFutureCleanup::test_timeout_pops_pending_entry— pins the existing timeout-pop behavior so the refactor doesn't regress itTestPendingFutureCleanup::test_send_failure_pops_pending_entry— covers the new send-time-exception path that was previously leakingtest_runskipped — diff is Python-only (transport layer), no GDScript or handler changes. PR Harden WebSocket transport: errno portability, Future leak, duplicate-ID reject #366 already live-smoked an identical diff against Godot 4.6 (1037/1040 GDScript suite green).Deviations from "Fix shape"
None. #346's fix shape called for "reject the second handshake with a warning log naming the colliding peer" — implemented. #349's fix shape called for "try/finally pattern that pops on any non-success path" — implemented.
Cross-references
Closes #346
Closes #349
Umbrella: #343
Bundled per maintainer comment on #346.
https://claude.ai/code/session_01ERwAhFK6CEZLRigwK1iC2k
Generated by Claude Code