Private
Public Access
1
0

fix(ws): add Origin allowlist to browser WebSocket upgrade (CSWSH hardening)

Closes Draco-Lunaris/Linux-Patch-Manager#10

The browser WebSocket endpoint at GET /api/v1/ws/jobs previously
authenticated solely via a single-use, 60-second ticket passed as a query
parameter. A leaked ticket (browser history, Referer, proxy logs, support
bundles) could be redeemed from any origin, enabling Cross-Site WebSocket
Hijacking (CSWSH).

This change adds a second gate: the Origin header must match an explicit
allowlist. The check runs BEFORE ticket validation so that rejected
cross-origin probes do not consume the legitimate users ticket.

Changes:
- pm-core: new security.allowed_origins config field; default derived
  from sso_callback_url; startup warning if both are unparseable
- pm-web: ws_handler extracts HeaderMap and calls check_origin first;
  returns 403 on missing/malformed/disallowed origins
- config: documented allowed_origins key in config.example.toml
- docs: security-review.md section 1.4 (WebSocket Origin Allowlist)
- tests: 40 unit tests (7 pm-core, 33 pm-web)
This commit is contained in:
Draco Lunaris
2026-06-02 10:45:38 -05:00
parent 80709d48a7
commit ed5df26140
8 changed files with 925 additions and 12 deletions

View File

@ -108,6 +108,21 @@ web_tls_key_path = "/etc/patch-manager/tls/web.key"
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
sso_callback_url = "http://localhost:5173/auth/sso/callback"
# Allowlist of browser `Origin` values permitted to open the
# `/api/v1/ws/jobs` WebSocket upgrade. Each entry is an exact
# `scheme://host[:port]` string (no wildcards, no paths). When this list is
# empty, the server derives a single-entry default from `sso_callback_url`
# at startup (the host of the SSO callback). If the derivation also fails,
# a warning is logged and the WS endpoint rejects all browser upgrades
# (fail-closed).
#
# Add additional origins here if your SPA and API are served from different
# hosts (e.g. SPA on https://app.example.com talking to API on
# https://api.example.com). For typical single-host deployments the derived
# default is correct and this line should be left commented out.
#
# allowed_origins = ["https://patch-manager.example.com"]
# ============================================================
# Rate Limiting
# ============================================================