Skip to content

API keys leak into logs when a websocket upgrade is rejected (6 sites still unguarded after #6740) #7031

Description

@aniketwaghh

Bug

WSServerHandshakeError extends aiohttp.ClientResponseError, so it is neither an aiohttp.ClientConnectorError nor an asyncio.TimeoutError. Six credentialed ws_connect sites still wrap the connect in except (aiohttp.ClientConnectorError, asyncio.TimeoutError), so a rejected upgrade (401/403 from a bad, expired or rate-limited key) matches neither handler and escapes uncaught. It surfaces as an unretrieved task exception whose repr embeds RequestInfo, including the auth header.

That is the same failure #6739 reported against cartesia and #6740 fixed there. The reporter on that issue wondered whether it was "the cartesia plugin, or more g[enerally]"; these are the sites where it is still reachable.

plugin file
deepgram stt.py, stt_v2.py
simplismart stt.py
smallestai stt.py
xai stt.py, tts.py

Each sends the key on the websocket (deepgram, for example, headers={"Authorization": f"Token {self._api_key}"} in stt.py) and then catches only the two error types above. I checked the other credentialed ws_connect sites by walking each try block's handlers; the remaining ten already catch the response error or re-raise with from None.

Steps to reproduce

Deepgram's exact call shape against a server that refuses the upgrade:

import asyncio
import aiohttp
from aiohttp import web

FAKE_KEY = "dg_live_SUPERSECRET_abcdef0123456789"


async def reject(request):
    return web.Response(status=401, text="Unauthorized")


async def main():
    app = web.Application()
    app.router.add_get("/v1/listen", reject)
    runner = web.AppRunner(app)
    await runner.setup()
    await web.TCPSite(runner, "127.0.0.1", 8781).start()

    session = aiohttp.ClientSession()
    try:
        await asyncio.wait_for(
            session.ws_connect(
                "http://127.0.0.1:8781/v1/listen?model=nova-2",
                headers={"Authorization": f"Token {FAKE_KEY}"},
            ),
            10,
        )
    except (aiohttp.ClientConnectorError, asyncio.TimeoutError):
        print("caught by the plugin's handler")
    except Exception as e:
        print(type(e).__name__, "escaped;", "key in repr:", FAKE_KEY in repr(e))
        print(repr(e)[:220])
    finally:
        await session.close()
        await runner.cleanup()


asyncio.run(main())
WSServerHandshakeError escaped; key in repr: True
WSServerHandshakeError(RequestInfo(url=URL('http://127.0.0.1:8781/v1/listen?model=nova-2'), method='GET',
headers=<CIMultiDictProxy('Host': '127.0.0.1:8781', 'Authorization': 'Token dg_live_SUPERSECRET_abcdef0123456789',

A 401 is the ordinary case here, not a contrived one: any wrong, rotated or rate-limited key produces it, and the reconnect path retries, so it can repeat.

Expected

No credential material in logs or exception output.

The shape #6740 used for cartesia ports directly: catch the response error separately and re-raise with from None, surfacing type(e).__name__ rather than str(e) or repr(e).

Note

Filing publicly because #6739 was handled that way and there is no SECURITY.md in this repo or on the livekit org. Happy to move it if you would rather it went through a private channel. I have the patch and can open a PR.

Version

main @ cdb37ad

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions