fix: add ASGI middleware to prevent WS reaching StaticFiles
_WSInterceptMiddleware intercepts /ws WebSocket scopes at the outermost
ASGI layer before Starlette routing is consulted, so Mount('/') can never
hand a WS connection to _SPAStaticFiles regardless of route ordering.
Also downgrade the _SPAStaticFiles non-HTTP fallback log from WARNING to
DEBUG — the graceful close still fires as a safety net, but no longer
spams the log since the middleware handles the normal case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
47
web/app.py
47
web/app.py
@@ -73,6 +73,26 @@ class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
response.headers["Content-Security-Policy"] = self._CSP
|
response.headers["Content-Security-Policy"] = self._CSP
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
class _WSInterceptMiddleware:
|
||||||
|
"""Short-circuit /ws WebSocket connections before Starlette routing.
|
||||||
|
|
||||||
|
In Starlette 0.52+, Mount('/') returns Match.FULL for every WebSocket
|
||||||
|
scope, so a WS connection can reach _SPAStaticFiles if the dedicated /ws
|
||||||
|
WebSocketRoute is somehow not matched first. Wrapping the whole ASGI app
|
||||||
|
here guarantees /ws is handled before any route or mount is consulted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, ws_handler) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._ws = ws_handler
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send) -> None:
|
||||||
|
if scope["type"] == "websocket" and scope.get("path") == "/ws":
|
||||||
|
await self._ws(scope, receive, send)
|
||||||
|
else:
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
class _SPAStaticFiles(StaticFiles):
|
class _SPAStaticFiles(StaticFiles):
|
||||||
"""StaticFiles with SPA fallback: serve index.html for unknown paths.
|
"""StaticFiles with SPA fallback: serve index.html for unknown paths.
|
||||||
|
|
||||||
@@ -83,15 +103,13 @@ class _SPAStaticFiles(StaticFiles):
|
|||||||
|
|
||||||
async def __call__(self, scope, receive, send) -> None:
|
async def __call__(self, scope, receive, send) -> None:
|
||||||
# StaticFiles only handles HTTP. In Starlette 0.52+, Mount('/') returns
|
# StaticFiles only handles HTTP. In Starlette 0.52+, Mount('/') returns
|
||||||
# Match.FULL for ALL WebSocket scopes, so a WebSocket connection can
|
# Match.FULL for ALL WebSocket scopes; _WSInterceptMiddleware should
|
||||||
# reach here if the dedicated /ws route somehow doesn't match first.
|
# prevent any WS connection from reaching here, but close gracefully
|
||||||
# Close gracefully instead of asserting.
|
# as a last-resort safety net rather than raising AssertionError.
|
||||||
if scope.get("type") != "http":
|
if scope.get("type") != "http":
|
||||||
from starlette.websockets import WebSocketClose
|
from starlette.websockets import WebSocketClose
|
||||||
logger.warning(
|
logger.debug(
|
||||||
"WebSocket or non-HTTP scope reached StaticFiles "
|
"non-HTTP scope reached StaticFiles (path=%r, type=%r) — closing gracefully",
|
||||||
"(path=%r, type=%r) — closing gracefully. "
|
|
||||||
"This indicates a routing issue; /ws route did not match.",
|
|
||||||
scope.get("path"),
|
scope.get("path"),
|
||||||
scope.get("type"),
|
scope.get("type"),
|
||||||
)
|
)
|
||||||
@@ -170,14 +188,11 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(workflow_router, prefix="/api/workflow", tags=["workflow"])
|
app.include_router(workflow_router, prefix="/api/workflow", tags=["workflow"])
|
||||||
app.include_router(ws_router, tags=["ws"])
|
app.include_router(ws_router, tags=["ws"])
|
||||||
|
|
||||||
|
from web.routers.ws_router import websocket_endpoint as _ws_endpoint
|
||||||
|
|
||||||
# Belt-and-suspenders: register /ws directly on the app so it sits at the
|
# Belt-and-suspenders: register /ws directly on the app so it sits at the
|
||||||
# top of the route list and is guaranteed to be checked before the
|
# top of the route list and is guaranteed to be checked before the
|
||||||
# StaticFiles catch-all mount below. In Starlette 0.52+, Mount('/')
|
# StaticFiles catch-all mount below.
|
||||||
# returns Match.FULL for every WebSocket scope, which means if the route
|
|
||||||
# from include_router is somehow not matched first, the mount wins and
|
|
||||||
# StaticFiles crashes with AssertionError. Having the route registered
|
|
||||||
# twice is harmless — the first Match.FULL in the route list wins.
|
|
||||||
from web.routers.ws_router import websocket_endpoint as _ws_endpoint
|
|
||||||
app.add_websocket_route("/ws", _ws_endpoint)
|
app.add_websocket_route("/ws", _ws_endpoint)
|
||||||
|
|
||||||
# Serve frontend static files (if built)
|
# Serve frontend static files (if built)
|
||||||
@@ -185,6 +200,12 @@ def create_app() -> FastAPI:
|
|||||||
app.mount("/", _SPAStaticFiles(directory=str(_WEB_STATIC), html=True), name="static")
|
app.mount("/", _SPAStaticFiles(directory=str(_WEB_STATIC), html=True), name="static")
|
||||||
logger.info("Serving frontend from %s", _WEB_STATIC)
|
logger.info("Serving frontend from %s", _WEB_STATIC)
|
||||||
|
|
||||||
|
# Wrap the entire ASGI app so /ws WebSocket connections are intercepted
|
||||||
|
# before Starlette routing can hand them to the StaticFiles Mount('/').
|
||||||
|
# add_middleware places this as the outermost layer — it runs before any
|
||||||
|
# route or mount is consulted.
|
||||||
|
app.add_middleware(_WSInterceptMiddleware, ws_handler=_ws_endpoint)
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def _startup():
|
async def _startup():
|
||||||
asyncio.create_task(_status_ticker())
|
asyncio.create_task(_status_ticker())
|
||||||
|
|||||||
Reference in New Issue
Block a user