| 1 | # Plan 017: Validate WebSocket Origin before running privileged ws handlers |
| 2 | |
| 3 | > **Executor instructions**: Follow this plan step by step. Run every |
| 4 | > verification command and confirm the expected result. If anything in "STOP |
| 5 | > conditions" occurs, stop and report. When done, update the status row in |
| 6 | > `plans/README.md`. Security-hardening change: code + tests only. |
| 7 | > |
| 8 | > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/slidev/node/vite/monacoWrite.ts packages/slidev/node/vite/serverRef.ts` |
| 9 | > On a mismatch with the excerpts below, treat it as a STOP condition. |
| 10 | |
| 11 | ## Status |
| 12 | |
| 13 | - **Priority**: P2 |
| 14 | - **Effort**: M |
| 15 | - **Risk**: MED |
| 16 | - **Depends on**: none (complements 015; subsumed by 018 if that lands first) |
| 17 | - **Category**: security |
| 18 | - **Planned at**: commit `c63cb120`, 2026-07-10 |
| 19 | |
| 20 | ## Why this matters |
| 21 | |
| 22 | The privileged ws handlers (Monaco write-back; drawings/snapshot persistence via |
| 23 | server-ref) run on Vite's shared HMR WebSocket. WebSocket handshakes are **not** |
| 24 | subject to the same-origin policy, so any web page the presenter visits while a |
| 25 | Slidev dev server is running can open a socket to `ws://localhost:<port>` and |
| 26 | drive these handlers — a drive-by, even in default localhost mode with no |
| 27 | `--remote`. Checking the connection's `Origin`/host against the known dev-server |
| 28 | origins before acting on privileged messages closes the cross-origin path. |
| 29 | |
| 30 | ## Current state |
| 31 | |
| 32 | - `packages/slidev/node/vite/monacoWrite.ts:13-35`: |
| 33 | ```ts |
| 34 | configureServer(server) { |
| 35 | server.ws.on('connection', (socket) => { |
| 36 | socket.on('message', async (data) => { |
| 37 | // parse JSON; if event === 'slidev:monaco-write' → fs.writeFile(...) |
| 38 | }) |
| 39 | }) |
| 40 | } |
| 41 | ``` |
| 42 | No inspection of the connection's origin/host. |
| 43 | - `packages/slidev/node/vite/serverRef.ts:28-36`: `onChanged` persists `drawings` |
| 44 | and `snapshots` from synced state, driven by the same ws, also without origin |
| 45 | checks. |
| 46 | - `server.ws.on('connection', (socket, request) => …)` provides the upgrade |
| 47 | `request` (an `http.IncomingMessage`) as the second argument; its |
| 48 | `request.headers.origin` / `request.headers.host` are what to validate. **Confirm |
| 49 | this signature against the installed Vite version before relying on it** (see |
| 50 | STOP conditions). |
| 51 | |
| 52 | ## Commands you will need |
| 53 | |
| 54 | | Purpose | Command | Expected | |
| 55 | |---------|---------|----------| |
| 56 | | Install | `pnpm install` | exit 0 | |
| 57 | | Build | `pnpm build` | exit 0 | |
| 58 | | Test | `pnpm test -- origin` (new helper test) | pass | |
| 59 | | Typecheck | `pnpm typecheck` | exit 0 | |
| 60 | |
| 61 | ## Scope |
| 62 | |
| 63 | **In scope**: |
| 64 | - `packages/slidev/node/vite/monacoWrite.ts` (origin gate on the privileged handler) |
| 65 | - `packages/slidev/node/vite/serverRef.ts` (origin gate on privileged persistence), |
| 66 | if reachable via the same connection hook |
| 67 | - A shared `isAllowedWsOrigin` helper + its unit test (likely in `node/utils.ts`) |
| 68 | |
| 69 | **Out of scope**: |
| 70 | - Full request authentication / tokens (plan 018). |
| 71 | - The path-traversal validation in the same sinks (plan 015) — independent. |
| 72 | - HMR / non-privileged ws messages (must keep working across devices). |
| 73 | |
| 74 | ## Git workflow |
| 75 | |
| 76 | - Branch: `fix/ws-origin-validation`. |
| 77 | - Conventional commit: `fix(security): validate ws origin for privileged handlers`. |
| 78 | - Do NOT push/PR unless instructed. |
| 79 | |
| 80 | ## Steps |
| 81 | |
| 82 | ### Step 1: Add an origin allow-list helper |
| 83 | |
| 84 | Add a pure helper that decides whether an origin/host is allowed. The allow-set |
| 85 | is: the dev server's own origins (localhost + the LAN host when `--remote` binds |
| 86 | `0.0.0.0`) plus any explicitly configured remote hosts. Keep it conservative and |
| 87 | testable: |
| 88 | ```ts |
| 89 | export function isAllowedWsOrigin( |
| 90 | origin: string | undefined, |
| 91 | allowedHosts: string[], // e.g. ['localhost', '127.0.0.1', '[::1]', <configured host>] |
| 92 | ): boolean { |
| 93 | if (!origin) return false // no Origin header → treat as untrusted for privileged ops |
| 94 | try { |
| 95 | const { hostname } = new URL(origin) |
| 96 | return allowedHosts.includes(hostname) |
| 97 | } |
| 98 | catch { |
| 99 | return false |
| 100 | } |
| 101 | } |
| 102 | ``` |
| 103 | |
| 104 | ### Step 2: Gate the Monaco write handler |
| 105 | |
| 106 | In `monacoWrite.ts`, capture the upgrade request and check origin before |
| 107 | performing the write: |
| 108 | ```ts |
| 109 | server.ws.on('connection', (socket, request) => { |
| 110 | socket.on('message', async (data) => { |
| 111 | // ... parse json ... |
| 112 | if (json.type === 'custom' && json.event === 'slidev:monaco-write') { |
| 113 | if (!isAllowedWsOrigin(request.headers.origin, buildAllowedHosts(server, options))) { |
| 114 | console.error('[slidev] Rejected monaco-write from disallowed origin') |
| 115 | return |
| 116 | } |
| 117 | // ... existing whitelist + path checks + write ... |
| 118 | } |
| 119 | }) |
| 120 | }) |
| 121 | ``` |
| 122 | `buildAllowedHosts` derives the list from the resolved server config |
| 123 | (`server.config.server.host`, the resolved port/host, and any Slidev `remote` |
| 124 | host). Keep non-privileged messages unaffected. |
| 125 | |
| 126 | ### Step 3: Gate server-ref persistence if applicable |
| 127 | |
| 128 | If `serverRef.ts`'s `onChanged` can be triggered cross-origin through the same |
| 129 | socket, apply the same origin gate (or route persistence through a checked |
| 130 | channel). If server-ref does not expose the origin at `onChanged`, document that |
| 131 | limitation and rely on plan 018 for that sink. |
| 132 | |
| 133 | **Verify**: reading the handlers, a privileged action only runs when the |
| 134 | connection origin is in the allow-list. |
| 135 | |
| 136 | ### Step 4: Unit test the helper |
| 137 | |
| 138 | `packages/slidev/node/vite/origin.test.ts` (or near `utils.ts`): |
| 139 | ```ts |
| 140 | import { describe, expect, it } from 'vitest' |
| 141 | import { isAllowedWsOrigin } from '../utils' |
| 142 | |
| 143 | describe('isAllowedWsOrigin', () => { |
| 144 | const hosts = ['localhost', '127.0.0.1'] |
| 145 | it('allows localhost', () => expect(isAllowedWsOrigin('http://localhost:3030', hosts)).toBe(true)) |
| 146 | it('rejects foreign origin', () => expect(isAllowedWsOrigin('https://evil.example', hosts)).toBe(false)) |
| 147 | it('rejects missing origin', () => expect(isAllowedWsOrigin(undefined, hosts)).toBe(false)) |
| 148 | }) |
| 149 | ``` |
| 150 | |
| 151 | **Verify**: `pnpm build && pnpm test -- origin` passes. |
| 152 | |
| 153 | ## Test plan |
| 154 | |
| 155 | - Unit-test `isAllowedWsOrigin` (deterministic). |
| 156 | - The live ws wiring is verified by code review + typecheck (no ws integration |
| 157 | harness exists). If plan 018 later adds a test server, extend it with a |
| 158 | foreign-origin rejection case. |
| 159 | - Manual sanity (optional): with `pnpm demo:dev`, confirm the Monaco live-coding |
| 160 | save and drawings still work from the app's own origin. |
| 161 | |
| 162 | ## Done criteria |
| 163 | |
| 164 | - [ ] Privileged ws handlers reject connections whose `Origin` is not in the allow-list |
| 165 | - [ ] Non-privileged HMR messages are unaffected (dev server + cross-device remote still work) |
| 166 | - [ ] `isAllowedWsOrigin` is unit-tested |
| 167 | - [ ] `pnpm build && pnpm typecheck` exit 0 |
| 168 | - [ ] Only in-scope files modified (`git status`) |
| 169 | - [ ] `plans/README.md` status row updated |
| 170 | |
| 171 | ## STOP conditions |
| 172 | |
| 173 | Stop and report (do not guess the API) if: |
| 174 | |
| 175 | - The installed Vite version's `server.ws.on('connection', …)` does not surface |
| 176 | the upgrade `request`/origin — then origin validation must move to the ws |
| 177 | `upgrade`/`verifyClient` layer, which is a different integration point; report |
| 178 | the Vite version and the available hook. |
| 179 | - A conservative allow-list would break legitimate cross-device remote control |
| 180 | (`--remote`) — the configured remote host must be included; if it can't be |
| 181 | derived, coordinate with plan 018 (token-based auth) instead. |
| 182 | |
| 183 | ## Maintenance notes |
| 184 | |
| 185 | - Origin checks are a same-site defense; they do not replace authentication for |
| 186 | networked (`--remote`/`--tunnel`) use — that's plan 018. |
| 187 | - Reviewer: confirm the allow-list includes every origin the app legitimately |
| 188 | serves itself from, and nothing else. |
| 189 |