返回 slidev
017-ws-origin-validation.md
根目录 / plans / 017-ws-origin-validation.md
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
189 lines MARKDOWN