fix(ws): accept same-origin WebSocket connections in default Origin allow-list

When `cors_origins` was unset, the WS endpoint only allowed
`http://localhost:<port>` and `http://127.0.0.1:<port>` as origins, so a
browser opening the UI via the LAN IP (e.g. `http://192.168.2.100:8765`
when bound to `0.0.0.0`) had its WebSocket closed with code 4003 and
never recovered — leaving the Web UI in a permanent reconnect loop.

Also accept any `Origin` whose authority matches the request's `Host`
header (both `http://` and `https://` schemes). Same-origin is by
definition not CSWSH, so the cross-origin defence added in v0.3.0
remains intact for genuine third-party LAN pages.
This commit is contained in:
2026-05-25 23:44:57 +03:00
parent b023d72165
commit 9b9a2b5c9f
+18 -4
View File
@@ -414,8 +414,13 @@ async def websocket_endpoint(
accept_subprotocol = proto accept_subprotocol = proto
break break
effective_token = subprotocol_token or token effective_token = subprotocol_token or token
# Origin check — block CSWSH from third-party LAN pages. We accept the same # Origin check — block CSWSH from third-party LAN pages. Accept the same
# set of origins as CORS plus the default localhost loopback. # set of origins as CORS plus the default localhost loopback, AND any
# same-origin connection (where Origin matches the request's Host header).
# Same-origin is inherently safe from CSWSH because CSWSH is a *cross*-
# origin attack — without this, binding to 0.0.0.0 and accessing the UI
# via a LAN IP would have its WebSocket rejected by the browser-sent
# Origin, which the static allowlist can't anticipate.
allowed_origins = set( allowed_origins = set(
settings.cors_origins settings.cors_origins
or [ or [
@@ -427,8 +432,17 @@ async def websocket_endpoint(
# Same-origin connections from native apps may omit Origin entirely; only # Same-origin connections from native apps may omit Origin entirely; only
# reject when an Origin is present AND not in the allow-list. # reject when an Origin is present AND not in the allow-list.
if origin is not None and origin not in allowed_origins: if origin is not None and origin not in allowed_origins:
await websocket.close(code=4003, reason="Origin not allowed") host_header = websocket.headers.get("host", "")
return # Origin uses http/https; match against both scheme variants of Host
# so HTTPS deployments without an explicit cors_origins still work.
same_origin_candidates = (
{f"http://{host_header}", f"https://{host_header}"}
if host_header
else set()
)
if origin not in same_origin_candidates:
await websocket.close(code=4003, reason="Origin not allowed")
return
# Verify token # Verify token
from ..auth import auth_enabled, get_token_label, token_label_var from ..auth import auth_enabled, get_token_label, token_label_var