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")
+1 -1
View File
@@ -551,7 +551,7 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
The JWT is read from the auth model and sent in the WebSocket subprotocol header (`Bearer <token>`). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
### `disconnect()`
+1 -1
View File
@@ -104,7 +104,7 @@ Token theft protection:
- Token blacklist prevents reuse after logout or password change
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled nginx config passes the token via the `Sec-WebSocket-Protocol` subprotocol header (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the `Sec-WebSocket-Protocol` subprotocol name (a JWT is a valid RFC 6455 token; the `Bearer ` prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
### WebAuthn Security
+5
View File
@@ -416,6 +416,11 @@ else
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
'{zone: $zone, interfaces: [$iface]}')" \
"WAN interface assigned to public zone"
# Open management services (HTTP, HTTPS, SSH) on the public/WAN zone
_daemon_post "/firewall/zones/services" \
"$(jq -n '{zone: "public", services: ["http", "https", "ssh"]}')" \
"Management services opened on public zone (http, https, ssh)"
fi
if [[ -n "$LAN_IFACES" ]]; then
+47
View File
@@ -0,0 +1,47 @@
"""Tests for WebSocket subprotocol token extraction (daemon.server)."""
import pytest
from daemon.server import _extract_ws_token
# Realistic-looking access token (base64url header.payload.signature).
TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.Y3WNO0CTxb4dlcVCPjUnQi"
class TestExtractWsToken:
def test_raw_jwt_subprotocol(self):
"""Bundled client path: the JWT is sent as the subprotocol name itself."""
assert _extract_ws_token([TOKEN]) == (TOKEN, TOKEN)
def test_raw_jwt_among_other_subprotocols(self):
assert _extract_ws_token(["vacuum-wall", TOKEN]) == (TOKEN, TOKEN)
@pytest.mark.parametrize(
"raw",
["none", "vacuum-wall", "a.b", "a.b.c.d"],
)
def test_no_auth_subprotocol(self, raw):
assert _extract_ws_token([raw]) == (None, None)
def test_empty_list(self):
assert _extract_ws_token([]) == (None, None)
def test_bearer_prefix_form(self):
"""Legacy non-browser form: 'Bearer <token>' subprotocol."""
assert _extract_ws_token([f"Bearer {TOKEN}"]) == (TOKEN, f"Bearer {TOKEN}")
def test_bearer_with_non_jwt_token(self):
assert _extract_ws_token(["Bearer abc123"]) == ("abc123", "Bearer abc123")
def test_bearer_without_token_ignored(self):
assert _extract_ws_token(["Bearer ", "Bearer"]) == (None, None)
def test_jwt_like_name_preferred_over_bearer(self):
"""A JWT-shaped subprotocol wins even when listed after a Bearer one."""
result = _extract_ws_token([f"Bearer {TOKEN}", TOKEN])
assert result == (TOKEN, TOKEN)
def test_subprotocol_with_space_rejected(self):
"""A space is not an RFC 6455 token character — 'Bearer <token>' must
go through the Bearer branch, never the JWT-shape branch."""
assert _extract_ws_token(["a b.c.d"]) == (None, None)
+14 -1
View File
@@ -134,8 +134,21 @@ export function setProp(el, key, value) {
el.className = Object.keys(value).filter(k => value[k]).join(' ');
return;
}
if (key === 'style' && typeof value === 'object' && value !== null) {
if (key === 'style') {
// Inline styles go through the CSSOM (el.style), never through
// setAttribute('style', ...) — the latter applies an inline style
// attribute, which the management-domain CSP (style-src 'self',
// no 'unsafe-inline') blocks.
if (value == null || value === false) {
el.removeAttribute('style');
return;
}
if (typeof value === 'object') {
el.style.cssText = ''; // clear stale props from a previous style
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
} else {
el.style.cssText = String(value);
}
return;
}
+6 -2
View File
@@ -34,8 +34,12 @@ function _wsUrl() {
}
/** Attempt a WebSocket connection.
* Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol)
* Passes the JWT as the WebSocket subprotocol name (Sec-WebSocket-Protocol)
* instead of a query parameter, keeping it out of logs and browser history.
* The token is sent as-is, WITHOUT a "Bearer " prefix: subprotocol names
* must be valid RFC 6455 tokens and a JWT (base64url + dots) is one, but
* the space in "Bearer <token>" is not a token character — the browser
* rejects the whole constructor with a SyntaxError.
* No token: no socket is created — the daemon 401s unauthenticated WS
* connections and connect() only runs while authenticated.
*/
@@ -46,7 +50,7 @@ function _wsConnect() {
const token = getAuthToken();
if (!token) return;
_wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]);
_wsConn = new WebSocket(_wsUrl(), [token]);
_wsConn.onopen = () => {
_wsReconnectMs = 0;