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:
+43
-12
@@ -9,6 +9,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -359,6 +360,38 @@ def create_app() -> web.Application:
|
|||||||
return app
|
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
|
# WebSocket subscribers
|
||||||
_ws_subscribers: set[web.WebSocketResponse] = set()
|
_ws_subscribers: set[web.WebSocketResponse] = set()
|
||||||
_ws_tasks: set[asyncio.Task[None]] = 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.
|
updated subsystem versions. Clients disconnect to unsubscribe.
|
||||||
|
|
||||||
Authentication: JWT access token passed via:
|
Authentication: JWT access token passed via:
|
||||||
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
|
1. WebSocket subprotocol header — the bundled client sends the raw JWT
|
||||||
— the bundled client path.
|
as the subprotocol name (Sec-WebSocket-Protocol must carry a valid
|
||||||
2. X-Auth-Token header — fallback for custom nginx setups that inject it
|
RFC 6455 token; a JWT is one, "Bearer <token>" is not).
|
||||||
(not set by the bundled nginx config).
|
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
|
from aiohttp import hdrs
|
||||||
|
|
||||||
@@ -386,16 +421,12 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
token_param = None
|
token_param = None
|
||||||
matched_proto = None
|
matched_proto = None
|
||||||
|
|
||||||
# Prefer subprotocol header (client JS sends "Bearer <token>").
|
# Prefer the subprotocol header. Sec-WebSocket-Protocol is a
|
||||||
# Sec-WebSocket-Protocol is a comma-separated list; parse it the same
|
# comma-separated list; parse it the same way aiohttp's own handshake
|
||||||
# way aiohttp's own handshake does (Request has no subprotocol helper).
|
# does (Request has no subprotocol helper).
|
||||||
protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "")
|
protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "")
|
||||||
subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()]
|
subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()]
|
||||||
for proto in subprotocols:
|
token_param, matched_proto = _extract_ws_token(subprotocols)
|
||||||
if proto.startswith("Bearer "):
|
|
||||||
token_param = proto[7:]
|
|
||||||
matched_proto = proto
|
|
||||||
break
|
|
||||||
|
|
||||||
if token_param is None:
|
if token_param is None:
|
||||||
token_param = request.headers.get("X-Auth-Token")
|
token_param = request.headers.get("X-Auth-Token")
|
||||||
|
|||||||
+1
-1
@@ -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).
|
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()`
|
### `disconnect()`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ Token theft protection:
|
|||||||
- Token blacklist prevents reuse after logout or password change
|
- Token blacklist prevents reuse after logout or password change
|
||||||
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
|
- 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
|
### WebAuthn Security
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,11 @@ else
|
|||||||
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
|
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
|
||||||
'{zone: $zone, interfaces: [$iface]}')" \
|
'{zone: $zone, interfaces: [$iface]}')" \
|
||||||
"WAN interface assigned to public zone"
|
"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
|
fi
|
||||||
|
|
||||||
if [[ -n "$LAN_IFACES" ]]; then
|
if [[ -n "$LAN_IFACES" ]]; then
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -134,8 +134,21 @@ export function setProp(el, key, value) {
|
|||||||
el.className = Object.keys(value).filter(k => value[k]).join(' ');
|
el.className = Object.keys(value).filter(k => value[k]).join(' ');
|
||||||
return;
|
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;
|
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
|
||||||
|
} else {
|
||||||
|
el.style.cssText = String(value);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ function _wsUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Attempt a WebSocket connection.
|
/** 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.
|
* 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
|
* No token: no socket is created — the daemon 401s unauthenticated WS
|
||||||
* connections and connect() only runs while authenticated.
|
* connections and connect() only runs while authenticated.
|
||||||
*/
|
*/
|
||||||
@@ -46,7 +50,7 @@ function _wsConnect() {
|
|||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
_wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]);
|
_wsConn = new WebSocket(_wsUrl(), [token]);
|
||||||
|
|
||||||
_wsConn.onopen = () => {
|
_wsConn.onopen = () => {
|
||||||
_wsReconnectMs = 0;
|
_wsReconnectMs = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user