fix: send WS JWT as bare subprotocol name; CSSOM inline styles; open mgmt services on public zone

- websocket.js passes the raw JWT as the Sec-WebSocket-Protocol subprotocol (no 'Bearer ' prefix): subprotocol names must be valid RFC 6455 tokens, and the space in 'Bearer <token>' made the browser reject the constructor with a SyntaxError.
- daemon accepts a JWT-shaped subprotocol plus the legacy 'Bearer <token>' form via _extract_ws_token; unit tests in tests/test_ws_auth.py.
- vdom.js applies inline styles through el.style (CSSOM) instead of setAttribute, which the management-domain CSP (no 'unsafe-inline') blocks.
- install.sh opens http/https/ssh on the public zone alongside WAN setup.
- docs (hoover.md, security.md) updated to match.
This commit is contained in:
2026-08-18 13:36:55 +00:00
parent 183904faad
commit 4bd4c374fd
7 changed files with 118 additions and 18 deletions
+43 -12
View File
@@ -9,6 +9,7 @@ import hashlib
import json
import logging
import os
import re
import signal
import time
from collections.abc import Callable
@@ -359,6 +360,38 @@ def create_app() -> web.Application:
return app
# JWTs are dot-joined base64url segments — a subset of the RFC 6455 token
# character set. Browsers therefore carry the access token as the
# Sec-WebSocket-Protocol subprotocol name itself (see websocket.js); the
# "Bearer <token>" form is not a valid subprotocol (space is not a token
# character) and is rejected by the WebSocket constructor.
_JWT_SUBPROTOCOL_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
def _extract_ws_token(subprotocols: list[str]) -> tuple[str | None, str | None]:
"""Extract the auth JWT from parsed Sec-WebSocket-Protocol names.
Accepts the raw JWT as a subprotocol name (bundled client) or the
legacy "Bearer <token>" form (non-browser clients that can send spaces).
Args:
subprotocols: Parsed subprotocol names (already stripped/split).
Returns:
``(token, matched_subprotocol)`` or ``(None, None)`` if no usable
auth subprotocol was found.
"""
for proto in subprotocols:
if _JWT_SUBPROTOCOL_RE.fullmatch(proto):
return proto, proto
for proto in subprotocols:
if proto.startswith("Bearer "):
token = proto[7:].strip()
if token:
return token, proto
return None, None
# WebSocket subscribers
_ws_subscribers: set[web.WebSocketResponse] = set()
_ws_tasks: set[asyncio.Task[None]] = set()
@@ -374,10 +407,12 @@ async def _handle_ws(request: web.Request) -> web.Response:
updated subsystem versions. Clients disconnect to unsubscribe.
Authentication: JWT access token passed via:
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
the bundled client path.
2. X-Auth-Token header — fallback for custom nginx setups that inject it
(not set by the bundled nginx config).
1. WebSocket subprotocol header — the bundled client sends the raw JWT
as the subprotocol name (Sec-WebSocket-Protocol must carry a valid
RFC 6455 token; a JWT is one, "Bearer <token>" is not).
2. "Bearer <token>" subprotocol — legacy form for non-browser clients.
3. X-Auth-Token header — fallback for custom nginx setups that inject
it (not set by the bundled nginx config).
"""
from aiohttp import hdrs
@@ -386,16 +421,12 @@ async def _handle_ws(request: web.Request) -> web.Response:
token_param = None
matched_proto = None
# Prefer subprotocol header (client JS sends "Bearer <token>").
# Sec-WebSocket-Protocol is a comma-separated list; parse it the same
# way aiohttp's own handshake does (Request has no subprotocol helper).
# Prefer the subprotocol header. Sec-WebSocket-Protocol is a
# comma-separated list; parse it the same way aiohttp's own handshake
# does (Request has no subprotocol helper).
protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "")
subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()]
for proto in subprotocols:
if proto.startswith("Bearer "):
token_param = proto[7:]
matched_proto = proto
break
token_param, matched_proto = _extract_ws_token(subprotocols)
if token_param is None:
token_param = request.headers.get("X-Auth-Token")