diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index 3d10745..e22dcb8 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -19,6 +19,19 @@ logger = get_logger(__name__) security = HTTPBearer(auto_error=False) +# Exceptions that legitimately fire when we try to send / close a WebSocket +# that is already shutting down: the peer dropped, the connect-state moved +# under us, the underlying socket is gone, the JSON encoder choked, etc. +# Keeping this tuple narrow means a genuine programming error (AttributeError, +# TypeError) bubbles up to the caller instead of silently disappearing. +_WS_SEND_BENIGN_EXC: tuple[type[BaseException], ...] = ( + WebSocketDisconnect, + RuntimeError, + ConnectionError, + OSError, +) + + def is_auth_enabled() -> bool: """Return True when at least one API key is configured.""" return bool(get_config().auth.api_keys) @@ -181,7 +194,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) ) try: await websocket.close(code=WS_ORIGIN_CLOSE_CODE) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None @@ -190,7 +203,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) if label is None: try: await websocket.close(code=WS_AUTH_CLOSE_CODE) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None return label @@ -254,20 +267,29 @@ async def verify_ws_auth( # Loopback anonymous: no auth message arrived, but none is required. try: await websocket.send_json({"type": "auth_ok"}) - except Exception: + except _WS_SEND_BENIGN_EXC: return None return "anonymous" logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host) try: await websocket.send_json({"type": "auth_error", "reason": "auth timeout"}) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None except WebSocketDisconnect: return None - except Exception as exc: + except (RuntimeError, ConnectionError, OSError) as exc: + # The peer hung up mid-handshake or the underlying socket is gone. + # Promote anything outside this set to a hard failure with a stack + # trace so we can see real bugs (decode errors, type errors, …). logger.debug("WebSocket auth receive error: %s", exc) return None + except Exception: + # Unexpected — log the full traceback so we can see what we missed + # without leaving the connection half-open. Re-raise nothing; the + # caller will close on the None return. + logger.exception("Unexpected error during WebSocket auth handshake") + return None # Parse the auth message. try: @@ -277,7 +299,7 @@ async def verify_ws_auth( await websocket.send_json( {"type": "auth_error", "reason": "invalid JSON in auth message"} ) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None @@ -286,7 +308,7 @@ async def verify_ws_auth( await websocket.send_json( {"type": "auth_error", "reason": "first message must be {type:'auth'}"} ) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None @@ -296,7 +318,7 @@ async def verify_ws_auth( await websocket.send_json( {"type": "auth_error", "reason": "token must be a string or null"} ) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None @@ -313,7 +335,7 @@ async def verify_ws_auth( "reason": "LAN access requires an API key", } ) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None @@ -323,13 +345,13 @@ async def verify_ws_auth( logger.warning("Invalid WebSocket auth attempt from %s", client_host) try: await websocket.send_json({"type": "auth_error", "reason": "invalid token"}) - except Exception: + except _WS_SEND_BENIGN_EXC: pass return None try: await websocket.send_json({"type": "auth_ok"}) - except Exception: + except _WS_SEND_BENIGN_EXC: return None logger.debug("WebSocket authenticated as: %s", label) return label