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:
parent
4bfa8e6d81
commit
3ca228cc15
5 changed files with 47 additions and 1 deletions
27
backend.py
27
backend.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
3
frontend/.env.sample
Normal 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
3
frontend/.gitignore
vendored
|
|
@ -11,6 +11,9 @@ node_modules
|
|||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.sample
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue