feat(security): authenticate WebSocket clients with shared token

Without auth, the WS server at 0.0.0.0:6789 exposed every device's
metrics to anyone on the network — useful reconnaissance for an
attacker (saturated nodes are easier DoS targets) and trivial pivot
from a compromised host.

Server side:
  - WS_AUTH_TOKEN env defaults to empty (open mode for local dev),
  - when set, ws_handler reads ?token=... from the handshake target
    and rejects with WS close 1008 unless secrets.compare_digest
    matches; the comparison is constant-time to avoid timing oracles.

Client side:
  - frontend reads VITE_WS_URL and VITE_WS_TOKEN, so the same build
    works in dev (localhost, no token) and prod (proxied wss, token).
  - frontend/.env.sample documents the variables; .gitignore extended
    to keep .env / .env.* out of the repo while allowing .env.sample.

env_sample also documents ALERT_COOLDOWN, MAX_PAYLOAD_BYTES and
MAX_DEVICES that the previous commits introduced.
This commit is contained in:
authentik Default Admin 2026-05-17 13:35:00 +00:00
parent 4bfa8e6d81
commit 3ca228cc15
5 changed files with 47 additions and 1 deletions

View file

@ -9,7 +9,10 @@ import asyncio
import json
import os
import re
import secrets
import time
from urllib.parse import parse_qs, urlsplit
import aiohttp
from gmqtt import Client as MQTTClient
from websockets import serve # websockets >= 10
@ -26,6 +29,9 @@ TOPIC = os.getenv("TOPIC", "devices/+/metrics")
# WebSocket configs
WS_HOST = os.getenv("WS_HOST", "0.0.0.0")
WS_PORT = int(os.getenv("WS_PORT", "6789"))
# Shared token required as ?token=... on the WS handshake. When empty the
# server accepts any client (dev mode); set this in production.
WS_AUTH_TOKEN = os.getenv("WS_AUTH_TOKEN", "")
# App configs
PRUNE_SECONDS = int(os.getenv("PRUNE_SECONDS", "30"))
@ -57,8 +63,29 @@ def active_snapshot():
}
def _ws_token_ok(websocket) -> bool:
"""Return True if the handshake carries a valid shared token.
When WS_AUTH_TOKEN is empty the server is in open mode and accepts any
client (useful for local dev). When set, the client must connect to
``ws://host:port/?token=<value>`` and the comparison is constant-time.
"""
if not WS_AUTH_TOKEN:
return True
# websockets >= 11 exposes the full request-target (path + query) via
# connection.request.path; older versions kept it on connection.path.
request = getattr(websocket, "request", None)
target = getattr(request, "path", None) or getattr(websocket, "path", "")
token = (parse_qs(urlsplit(target).query).get("token") or [""])[0]
return bool(token) and secrets.compare_digest(token, WS_AUTH_TOKEN)
async def ws_handler(websocket):
"""Handle WebSocket connections and broadcast initial snapshot."""
if not _ws_token_ok(websocket):
print("[WS] rejecting unauthorized connection")
await websocket.close(code=1008, reason="unauthorized")
return
clients.add(websocket)
print(f"[WS] connected. total={len(clients)}")
try:

View file

@ -5,8 +5,16 @@ TOPIC=devices/+/metrics
#websocket from backend
WS_HOST=0.0.0.0
WS_PORT=6789
# Shared secret required by dashboards on the WS handshake (?token=...).
# Leave empty for local dev; set to a long random value in production.
WS_AUTH_TOKEN=
PRUNE_SECONDS=30
CPU_ALERT_TH=90
# Alert cooldown (s) per device for Slack notifications.
ALERT_COOLDOWN=60
# Hard caps on untrusted MQTT input.
MAX_PAYLOAD_BYTES=16384
MAX_DEVICES=1000
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXXX/YYYYYY
DEVICE_ID=YourDeviceID

3
frontend/.env.sample Normal file
View file

@ -0,0 +1,3 @@
VITE_WS_URL=ws://localhost:6789
# Must match WS_AUTH_TOKEN in the backend .env. Leave empty in local dev.
VITE_WS_TOKEN=

3
frontend/.gitignore vendored
View file

@ -11,6 +11,9 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.sample
# Editor directories and files
.vscode/*

View file

@ -44,8 +44,13 @@ export default function App() {
const reconnectTimer = useRef(null);
useEffect(() => {
const base = import.meta.env.VITE_WS_URL || "ws://localhost:6789";
const token = import.meta.env.VITE_WS_TOKEN || "";
const url = token
? `${base}${base.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`
: base;
const connect = () => {
const ws = new WebSocket("ws://localhost:6789");
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {