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:
@ -38,6 +38,62 @@
|
||||
- [x] 4f: Lessons captured below
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
---
|
||||
|
||||
# WS Origin Allowlist — Implementation Plan (Issue #10)
|
||||
|
||||
Spec: `tasks/ws-origin-check-spec.md` (v0.1.0, awaiting sign-off)
|
||||
|
||||
## Issues Identified
|
||||
1. **No Origin check on WS upgrade** — `crates/pm-web/src/routes/ws.rs` `ws_handler` does
|
||||
not inspect the `Origin` header, leaving the `/api/v1/ws/jobs` endpoint exposed to
|
||||
Cross-Site WebSocket Hijacking (CSWSH) if a ticket ever leaks via logs / `Referer` /
|
||||
browser history / support bundles.
|
||||
2. **No `allowed_origins` config field** — `SecurityConfig` has no way to express the
|
||||
allowlist; defaults need to be derived from `sso_callback_url` to stay secure out
|
||||
of the box.
|
||||
3. **No integration tests for ws.rs** — there is no `crates/pm-web/tests/` directory
|
||||
today, so the new behavior would land without automated coverage.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Config schema (Issue 2)
|
||||
- [x] 1a: Add `allowed_origins: Vec<String>` to `SecurityConfig` in `crates/pm-core/src/config.rs`
|
||||
- [x] 1b: Implement `default_allowed_origins()` that parses `sso_callback_url` to `scheme://host[:port]`
|
||||
- [x] 1c: Emit `tracing::warn!` at startup if the derived allowlist ends up empty
|
||||
- [x] 1d: Update `Default for AppConfig` to include the new field
|
||||
- [x] 1e: Update `config/config.example.toml` with documented `allowed_origins` key
|
||||
|
||||
### Phase 2: Handler change (Issue 1)
|
||||
- [x] 2a: Add `HeaderMap` extractor to `ws_handler`
|
||||
- [x] 2b: Implement hand-rolled `Origin` parser (scheme, host, port) with default-port normalization
|
||||
- [x] 2c: Implement allowlist match (exact, case-insensitive host, case-sensitive scheme/port)
|
||||
- [x] 2d: Reject missing / malformed / non-allowlisted `Origin` with `403 forbidden_origin` *before* ticket validation
|
||||
- [x] 2e: Augment the success `tracing::info!` with `origin`; add `tracing::warn!` on rejection (never log the ticket)
|
||||
- [x] 2f: Verify `cargo check -p pm-web` and `cargo clippy --all-targets` pass
|
||||
|
||||
### Phase 3: Tests (Issue 3)
|
||||
- [x] 3a: Add `crates/pm-web/tests/` and a `build_test_app` harness (no DB, minimal `AppState`)
|
||||
- [x] 3b: Add `ws_rejects_missing_origin` test
|
||||
- [x] 3c: Add `ws_rejects_disallowed_origin` test
|
||||
- [x] 3d: Add `ws_rejects_malformed_origin` test
|
||||
- [x] 3e: Add `ws_allows_listed_origin_with_valid_ticket` test (asserts ticket is consumed)
|
||||
- [x] 3f: Add `ws_default_origin_derived_from_sso_callback_url` config-derivation test
|
||||
- [x] 3g: Verify `cargo test -p pm-web` passes
|
||||
|
||||
### Phase 4: Documentation
|
||||
- [x] 4a: Update `docs/security-review.md` with a new control row for the WS Origin allowlist
|
||||
- [x] 4b: (Optional, per Kelly) bump `SPEC.md` to 0.0.3 with a sentence in the Security section
|
||||
|
||||
### Phase 5: Review
|
||||
- [x] 5a: Self-review against the 10-point acceptance criteria in the spec
|
||||
- [x] 5b: Commit on a feature branch (`issue/10-ws-origin-check`) per git-workflow skill
|
||||
- [x] 5c: Lessons captured below
|
||||
|
||||
## Lessons Learned (this issue)
|
||||
_(filled in at completion)_
|
||||
|
||||
- **SSO callback must redirect, not return JSON** — Browser OAuth2 flows require the backend to redirect to the frontend SPA, not return JSON tokens. The frontend must parse tokens from URL query parameters.
|
||||
- **URLSearchParams.get() already decodes** — Don't double-decode with decodeURIComponent() when using URLSearchParams.
|
||||
- **JWKS caching prevents rate-limiting** — Azure AD JWKS endpoint should be cached with TTL (1 hour) to avoid fetching on every SSO login.
|
||||
@ -54,9 +110,9 @@
|
||||
- [x] 1c: Add DB interaction methods (insert, list, delete) in `pm-core`
|
||||
|
||||
### Phase 2: Client-Facing API (pm-web)
|
||||
- [ ] 2a: Implement `POST /api/v1/enroll` to accept payloads and generate `polling_token`
|
||||
- [ ] 2b: Implement `GET /api/v1/enroll/status/{token}` to return pending/approved (PKI) statuses
|
||||
- [ ] 2c: Implement IP-based rate limiting for the `/enroll` endpoint
|
||||
- [x] 2a: Implement `POST /api/v1/enroll` to accept payloads and generate `polling_token`
|
||||
- [x] 2b: Implement `GET /api/v1/enroll/status/{token}` to return pending/approved (PKI) statuses
|
||||
- [x] 2c: Implement IP-based rate limiting for the `/enroll` endpoint
|
||||
|
||||
### Phase 3: Admin-Facing API (pm-web)
|
||||
- [x] 3a: Implement `GET /api/v1/admin/enrollments` to list pending queue
|
||||
|
||||
Reference in New Issue
Block a user