Skip to content

WebSocket connections bypass every SecurityMiddleware check, including active IP bans

High
rennf93 published GHSA-63qv-gh36-52qf Aug 26, 2026

Package

pip fastapi-guard (pip)

Affected versions

<= 7.7.0

Patched versions

7.8.0

Description

Summary

SecurityMiddleware extends Starlette's BaseHTTPMiddleware
(guard/middleware.py:46). BaseHTTPMiddleware.__call__ opens with
if scope["type"] != "http": return await self.app(scope, receive, send), so a
websocket scope is handed straight to the router. Nothing in guard runs: no IP
allow/deny list, no ban check, no rate limit, no penetration detection, no
emergency lockdown. Route decorators on WebSocket handlers are inert too.

An address that is banned for HTTP can still open a socket and be served.

PoC

import asyncio
from fastapi import FastAPI, WebSocket
from starlette.testclient import TestClient
from guard.middleware import SecurityMiddleware
from guard_core.handlers.ipban_handler import ip_ban_manager
from guard_core.models import SecurityConfig

BANNED = ("203.0.113.66", 1234)
app = FastAPI()
app.add_middleware(SecurityMiddleware, config=SecurityConfig(enable_redis=False))

@app.get("/")
async def root():
    return {"ok": True}

@app.websocket("/ws")
async def socket(sock: WebSocket):
    await sock.accept()
    await sock.send_text("data the ban was supposed to withhold")
    await sock.close()

async def main():
    await ip_ban_manager.ban_ip(BANNED[0], 3600)
    with TestClient(app, client=BANNED) as client:
        print("GET  /  ->", client.get("/").status_code)
        with client.websocket_connect("/ws") as sock:
            print("WS  /ws ->", sock.receive_text())

asyncio.run(main())

On 7.7.0 / 3.13.0:

GET  /  -> 403
WS  /ws -> data the ban was supposed to withhold

Impact

Any control the operator configured is absent on the WebSocket half of the
application. For an app that puts its streaming or realtime surface on
WebSockets - chat, notifications, live queries - that surface has no rate limit
and no ban enforcement at all, and the ban that the operator can see in the
admin state does not apply to it. Brute-forcing an authenticated socket, or
continuing an attack after being banned over HTTP, both work.

examples/simple_app advertises WebSocket protection, so an operator reading
the examples has positive reason to believe this is covered.

Suggested fix

Either implement __call__ at the ASGI level and run the identity, ban and rate
checks before the handshake is accepted, or say plainly in the README and the
example that the middleware is HTTP-only and ship a dependency
(Depends(guard_ws_check)) that a WebSocket route can use. The documentation
route is fine as a first step; the current state, where the example claims
coverage that does not exist, is the part that needs to change either way.

Fixed in 7.8.0

Fixed in fastapi-guard 7.8.0 (PyPI), merged in #121 (eb5f284). guard_websocket is a FastAPI dependency that runs the ban list, the IP and country allow lists and the rate limit against the resolved client before accept(), closing with 1008 on a policy violation; a client-less socket is rejected under fail_secure=True; Redis failures during the handshake follow redis_fail_open / fail_secure like the HTTP pipeline (close code 1013). BaseHTTPMiddleware never runs for websocket scopes, so protection is opt-in per route: Depends(guard_websocket). Documented in the websockets tutorial; the example app wires it on /ws.

Severity

High

CVE ID

No known CVE

Weaknesses

Protection Mechanism Failure

The product does not use or incorrectly uses a protection mechanism that provides sufficient defense against directed attacks against the product. Learn more on MITRE.

Credits