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)
10 KiB
WS Origin Allowlist — Specification
Issue: #10
Component: crates/pm-web/src/routes/ws.rs
Spec version: 0.1.0 (draft)
Status: Awaiting Kelly sign-off
1. Goal
Harden the browser-facing WebSocket upgrade endpoint (GET /api/v1/ws/jobs) against
Cross-Site WebSocket Hijacking (CSWSH) by validating the Origin header against a
configured allowlist. The existing single-use, 60-second ticket mechanism is retained
as the first credential factor; the Origin check is the second factor.
2. Non-Goals
- Replacing or weakening the ticket mechanism.
- Binding the ticket to a specific WebSocket connection at issuance time (separate, larger change).
- Origin/CORS checks on the JWT-authenticated ticket-issuance endpoint
(
POST /api/v1/ws/ticket) — it is already protected by JWT + RBAC middleware and is not a browser-only entry point. - Changes to
pm-worker/ws_relay.rs— that module is an outbound mTLS WS client to agents, not a server, and is not reachable from a browser.
3. Design
3.1 Handler change (crates/pm-web/src/routes/ws.rs)
The ws_handler function (lines 96–137) gains a new HeaderMap extractor and
performs an Origin allowlist check before ticket validation.
Order of operations in the new handler:
- Extract
HeaderMap(new). - Read
Originheader. If absent → reject403 forbidden_origin("Origin header required"). - Normalize and compare against the configured allowlist. If not matched → reject
403 forbidden_origin("Origin not allowed"). - Existing ticket validation (lines 101–127) runs unchanged.
- Existing upgrade (line 136) runs unchanged.
Rationale for Origin-first ordering: a CSWSH probe from a malicious page will not
have a valid ticket. If we validated the ticket first and then checked Origin, an
attacker who has obtained a leaked ticket (from logs, Referer, history, support
bundle) could mount a denial-of-service against the legitimate user by burning their
tickets with 403 responses. Origin-first means rejected cross-origin attempts do
not consume the user's ticket.
3.2 Origin parsing and comparison
- Read the raw
Originheader value as a string. - Strip any trailing
/. - Use a hand-rolled parser (no new dependency) to extract
scheme,host, and optionalport:schememust behttporhttps(lowercased).hostmust be non-empty and contain no whitespace.port, if present, must parse asu16.
- Apply default-port normalization:
httpsimplies port443;httpimplies port80. If the explicit port equals the default for the scheme, drop it from the comparison. This matches browser behavior (browsers do not include default ports inOrigin). - Case-insensitive host comparison; case-sensitive scheme and port.
- Match each configured allowlist entry against the parsed Origin. Exact match only — no subdomain wildcards, no regex, no path.
- If the
Originheader is malformed (does not parse) → reject403 forbidden_origin("Malformed Origin header").
3.3 Config schema (crates/pm-core/src/config.rs)
Add a new field to SecurityConfig:
/// Allowlist of browser `Origin` values permitted to open the
/// `/api/v1/ws/jobs` WebSocket. Entries are exact `scheme://host[:port]`
/// strings. If empty, the server derives the default from `sso_callback_url`.
#[serde(default = "default_allowed_origins")]
pub allowed_origins: Vec<String>,
Default value (default_allowed_origins): a single-element vector containing the
scheme://host[:port] form of sso_callback_url parsed at config-load time. This
keeps the default config secure out of the box — a fresh install will only accept
WS upgrades from the same origin the SSO callback redirects to.
If sso_callback_url cannot be parsed (e.g., a custom dev value), fall back to
vec![] and emit a tracing::warn! at startup. An empty allowlist with no
parseable default would make the WS endpoint reject all upgrades; this is the safe
direction (fail-closed), but the warning makes it loud.
Add a corresponding ConfigError if allowed_origins is manually set to an empty
vector and sso_callback_url is also unparseable, since that combination produces a
non-functional WS endpoint. The user must explicitly opt in to "no origins allowed"
by providing a list.
3.4 Logging
- On allowed upgrade: existing
tracing::info!continues (lines 129–133), augmented withorigin = %origin_strfor audit context. - On rejected upgrade: new
tracing::warn!withorigin = %origin_strandreason = %reason. Never log the ticket value.
3.5 Response shape
Reuse the existing err helper (lines 48–58):
{ "error": { "code": "forbidden_origin", "message": "…" } }
Status: 403 Forbidden for all Origin rejections. Do not differentiate between
"missing", "malformed", and "not in allowlist" in the response — they all map to the
same code, and the specific reason is logged server-side only.
4. Acceptance Criteria
- A WS upgrade request with a missing
Originheader is rejected with403and codeforbidden_origin. The ticket is not consumed. - A WS upgrade request with a malformed
Originheader is rejected with403and codeforbidden_origin. The ticket is not consumed. - A WS upgrade request with an
Originnot in the allowlist is rejected with403and codeforbidden_origin. The ticket is not consumed. - A WS upgrade request with an allowlisted
Originand a valid, unconsumed ticket succeeds (101 Switching Protocols / upgrade accepted) and the ticket is consumed atomically (existing behavior preserved). - A WS upgrade request with an allowlisted
Originand an invalid/expired/ already-used ticket is rejected with the existing401 invalid_ticket/401 ticket_expired(existing behavior preserved). - The allowlist is configurable via
security.allowed_originsinconfig/config.example.tomland is documented in that file. - When
allowed_originsis not set, the default is derived fromsso_callback_urland the service starts cleanly. cargo checkandcargo clippy --all-targetspass.cargo test -p pm-webpasses with the new integration tests added.docs/security-review.mddocuments the new control with an evidence row pointing tocrates/pm-web/src/routes/ws.rs.
5. Test Plan
Unit tests in crates/pm-web/src/routes/ws.rs (cfg(test) module) — 33 tests
covering the security-critical logic. The end-to-end wiring (HeaderMap
extractor, axum handler, response shape) is verified by cargo check,
cargo clippy --all-targets, and the manual scripts/integration-test.sh
end-to-end check.
Why unit tests instead of tests/ws_origin.rs integration tests: the
handler is wired to State<AppState>, and AppState contains a
sqlx::PgPool and a pm_ca::CertAuthority that cannot be cheaply
constructed in a test (the CA requires a real Postgres connection and
on-disk key material, both unavailable in cargo test). Refactoring
ws_handler to take a smaller state struct is out of scope for this
hardening change.
5.1 pm-core unit tests (crates/pm-core/src/config.rs, 7 tests)
derive_allowed_origins — the sso_callback_url → allowlist derivation:
derive_strips_default_https_port—https://x:443/...→https://xderive_keeps_non_default_port—https://x:8443/...→https://x:8443derive_strips_default_http_port—http://x:80/x→http://xderive_handles_trailing_slash—https://x/→https://xderive_handles_no_path—https://x→https://xderive_returns_empty_for_garbage— rejects "not a url", "", "ftp://x"derive_lowercases_scheme—HTTPS://...→https://...
5.2 pm-web unit tests (crates/pm-web/src/routes/ws.rs, 33 tests)
parse_origin_header (14 tests): basic, with-port, scheme/host lowercasing,
path/query/fragment ignored, trailing-slash stripped, empty/whitespace
rejected, unsupported schemes rejected, empty host rejected, host with
whitespace rejected, malformed port rejected, IPv6 literal rejected, no
scheme separator rejected.
Origin::canonical (3 tests): default-port normalization in both directions
(http:80, https:443), non-default port kept.
is_origin_allowed (11 tests): exact match, default-port normalization in
both directions, case-insensitive host, different host rejected, different
scheme rejected, different port rejected, empty allowlist rejected,
unparseable allowlist entry rejected, multi-entry allowlist.
check_origin (7 tests): missing header, malformed header, disallowed
origin, empty allowlist, allowed origin, default-port normalization allowed,
case-insensitive host allowed. This function returns the (StatusCode, Json)
error tuple used by the handler, so these tests cover the response shape.
6. Risk Analysis
- Risk: a config that breaks the WS endpoint in production. Mitigation: default
is derived from
sso_callback_url(already required for SSO to function), and startup logs a warning if the allowlist ends up empty. - Risk: legitimate cross-origin frontend (e.g., SPA on
app.foo.comtalking to API onapi.foo.com). Out of scope for this fix — the WS endpoint is on the same origin as the SPA. If a future deployment splits them, the operator adds the API origin toallowed_origins. Documented inconfig/config.example.toml. - Risk: a non-browser caller (CLI, integration test) cannot connect. Such
callers should use the REST API for state mutations; the WS is documented as the
browser job-stream channel. If a non-browser WS client is needed, document it in
docs/REST_API.mdand tell the operator to add its origin. - Risk: false sense of security. The Origin check is defense-in-depth, not a replacement for the ticket. Documented as such in the security review update.
7. Files Touched
crates/pm-web/src/routes/ws.rs— handler change.crates/pm-core/src/config.rs— new field + default.config/config.example.toml— new field documented.crates/pm-web/tests/ws_origin.rs— new integration tests (and harness).docs/security-review.md— control documentation row.tasks/todo.md— plan section (added by the orchestrator).
8. Out of Scope (Future Work)
- Bind ticket issuance to the WS upgrade in a single round-trip (eliminates query-string ticket leakage from logs/history).
- Per-message MAC on WS frames (defense against in-process tampering).
- Rate limiting on the WS endpoint itself.