| 1 | # Plan 018: Enforce the `--remote` secret on the server, not just the client router |
| 2 | |
| 3 | > **Executor instructions**: This is a larger security *design + implementation* |
| 4 | > plan touching the dev-server trust boundary. Read it fully first. Follow the |
| 5 | > steps, run every verification, and honor the STOP conditions — several ask you |
| 6 | > to pause and confirm the approach before writing broad changes. When done, |
| 7 | > update the status row in `plans/README.md`. |
| 8 | > |
| 9 | > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/client/setup/routes.ts packages/slidev/node/vite/loaders.ts packages/slidev/node/vite/monacoWrite.ts packages/slidev/node/vite/serverRef.ts` |
| 10 | > On a mismatch with the excerpts below, treat it as a STOP condition. |
| 11 | |
| 12 | ## Status |
| 13 | |
| 14 | - **Priority**: P3 (high value, larger lift — schedule deliberately) |
| 15 | - **Effort**: M-L |
| 16 | - **Risk**: MED |
| 17 | - **Depends on**: none; **subsumes** the network-exposure parts of 015/017 |
| 18 | (still land 015's path validation as defense-in-depth) |
| 19 | - **Category**: security |
| 20 | - **Planned at**: commit `c63cb120`, 2026-07-10 |
| 21 | |
| 22 | ## Why this matters |
| 23 | |
| 24 | The `--remote <password>` feature's only access gate is a **client-side** Vue |
| 25 | Router `beforeEnter` guard. The dev server's privileged surface has no |
| 26 | server-side check: |
| 27 | |
| 28 | - `GET /__slidev/slides/<n>.json` returns full slide content **and rendered |
| 29 | speaker notes**. |
| 30 | - `POST /__slidev/slides/<n>.json` mutates slide content/frontmatter/notes and |
| 31 | **persists `slides.md` to disk** (`parser.save`). |
| 32 | - ws handlers write files (Monaco) and persist drawings/snapshots. |
| 33 | |
| 34 | So anyone who can reach the server — the LAN when `--remote` binds `0.0.0.0`, the |
| 35 | internet when `--tunnel` is added, or a malicious page via CSRF in default |
| 36 | localhost mode — can read private notes and overwrite the presenter's deck, |
| 37 | bypassing the password prompt entirely (it only hides SPA routes). This plan adds |
| 38 | a real server-side authorization boundary. |
| 39 | |
| 40 | ## Current state |
| 41 | |
| 42 | - Client-only guard — `packages/client/setup/routes.ts:8-20`: |
| 43 | ```ts |
| 44 | function passwordGuard(to: RouteLocationNormalized) { |
| 45 | if (!configs.remote || configs.remote === to.query.password) return true |
| 46 | if (configs.remote && to.query.password === undefined) { |
| 47 | const password = prompt('Enter password') |
| 48 | if (configs.remote === password) return true |
| 49 | } |
| 50 | // redirect away |
| 51 | } |
| 52 | ``` |
| 53 | Applied only as `beforeEnter` on presenter/notes/print/export routes. |
| 54 | - Unauthenticated privileged HTTP middleware — `packages/slidev/node/vite/loaders.ts:79-141` |
| 55 | (GET returns `withRenderedNote(data.slides[idx])`; POST mutates + `parser.save`). |
| 56 | - Unauthenticated ws sinks — `vite/monacoWrite.ts:23-32`, `vite/serverRef.ts:28-36`. |
| 57 | - The shared secret is `configs.remote` (the `--remote` password), already |
| 58 | surfaced to the client via the `#slidev/configs` virtual module. |
| 59 | |
| 60 | ## Design decisions to confirm BEFORE broad implementation |
| 61 | |
| 62 | Use the `question` flow or report back to the operator on these (each has a |
| 63 | recommended default). **Do not** write the full change until these are settled: |
| 64 | |
| 65 | 1. **Scope of enforcement** — recommended: require auth on all **mutating** |
| 66 | endpoints (POST slides, monaco-write, drawings/snapshot persist) and on |
| 67 | **notes** content always; gate read of slide JSON only when `remote` is set. |
| 68 | 2. **Credential mechanism** — recommended: a per-session token minted by the |
| 69 | server on startup (not the raw password), delivered to the trusted client via |
| 70 | the `#slidev/configs` virtual module, sent on each privileged request |
| 71 | (`Authorization: Bearer` / a header for HTTP, a field in ws messages). The |
| 72 | `--remote` password remains the human gate to *obtain* a session. |
| 73 | 3. **Default-localhost behavior** — recommended: keep mutation enabled locally |
| 74 | but require the token (defeats CSRF); do not silently disable the editor. |
| 75 | 4. **Backward compatibility** — recommended: when neither `remote` nor editor |
| 76 | features are enabled, behavior is unchanged for read paths. |
| 77 | |
| 78 | ## Commands you will need |
| 79 | |
| 80 | | Purpose | Command | Expected | |
| 81 | |---------|---------|----------| |
| 82 | | Install | `pnpm install` | exit 0 | |
| 83 | | Build | `pnpm build` | exit 0 | |
| 84 | | Typecheck | `pnpm typecheck` | exit 0 | |
| 85 | | Lint | `pnpm lint` | exit 0 | |
| 86 | | Test | `pnpm test` | pass (add auth-helper tests) | |
| 87 | |
| 88 | ## Scope |
| 89 | |
| 90 | **In scope** (after design confirmation): |
| 91 | - `packages/slidev/node/vite/loaders.ts` — auth check in the `/__slidev/*` middleware |
| 92 | - `packages/slidev/node/vite/monacoWrite.ts`, `vite/serverRef.ts` — auth on privileged ws messages |
| 93 | - Token minting/plumbing: a new small module (e.g. `node/auth.ts`) + the config |
| 94 | virtual module (`node/virtual/configs.ts`) to expose the token to the client |
| 95 | - `packages/client/**` editor/presenter/remote code that calls these endpoints, |
| 96 | to send the token |
| 97 | - A unit test for the token verify helper |
| 98 | |
| 99 | **Out of scope**: |
| 100 | - Redesigning the presenter/remote UX beyond passing a token. |
| 101 | - TLS/transport security (that's the tunnel/user's responsibility). |
| 102 | - The path-traversal validations (plan 015) — land those independently. |
| 103 | |
| 104 | ## Git workflow |
| 105 | |
| 106 | - Branch: `feat/server-side-remote-auth`. |
| 107 | - Conventional commit(s): `feat(security): authorize privileged dev-server endpoints`. |
| 108 | - Do NOT push/PR unless instructed. This is a security-sensitive change — expect |
| 109 | review. |
| 110 | |
| 111 | ## Steps |
| 112 | |
| 113 | ### Step 1: Confirm the design (STOP-gated) |
| 114 | |
| 115 | Resolve the four decisions above with the operator. Record the chosen answers at |
| 116 | the top of the branch's first commit message or in a short note. **STOP** and ask |
| 117 | if any answer is unclear — do not improvise a security protocol. |
| 118 | |
| 119 | ### Step 2: Mint and expose a session token |
| 120 | |
| 121 | Add `node/auth.ts` that, when `remote`/editor is enabled, generates a random |
| 122 | token at server start (`crypto.randomUUID()` or `crypto.randomBytes`). Expose it |
| 123 | to the trusted client through the config virtual module so the app can attach it; |
| 124 | never log the token value. |
| 125 | |
| 126 | ### Step 3: Enforce on the HTTP middleware |
| 127 | |
| 128 | In `loaders.ts`, before serving/ mutating in the `/__slidev/*` handler, verify |
| 129 | the token per the confirmed scope (Step 1 decision 1). Return `401` when missing/ |
| 130 | wrong. Keep the bounds check from plan 011 if present. |
| 131 | |
| 132 | ### Step 4: Enforce on the ws sinks |
| 133 | |
| 134 | Require the token field in `slidev:monaco-write` and in the server-ref persistence |
| 135 | path; reject otherwise. Combine with plan 017's origin check if that landed. |
| 136 | |
| 137 | ### Step 5: Update the client callers |
| 138 | |
| 139 | Make the editor save, presenter sync, and remote-control code send the token with |
| 140 | each privileged call. Verify HMR and normal navigation are unaffected. |
| 141 | |
| 142 | ### Step 6: Unit-test the verifier |
| 143 | |
| 144 | Test the pure token-compare helper (constant-time compare if feasible) for |
| 145 | match/mismatch/missing. |
| 146 | |
| 147 | **Verify**: `pnpm build && pnpm typecheck && pnpm lint && pnpm test` all pass; |
| 148 | manual check with `pnpm demo:dev` that editing/notes still work in-app and that a |
| 149 | request without the token to `POST /__slidev/slides/1.json` is rejected (`401`). |
| 150 | |
| 151 | ## Test plan |
| 152 | |
| 153 | - Unit-test the token verifier (deterministic). |
| 154 | - Manual/integration: in-app editor save works; an unauthenticated |
| 155 | `POST /__slidev/slides/<n>.json` and an unauthenticated `monaco-write` are |
| 156 | rejected. If plan 022 introduces a server test harness, add these as automated |
| 157 | cases. |
| 158 | |
| 159 | ## Done criteria |
| 160 | |
| 161 | - [ ] Design decisions (Step 1) are recorded |
| 162 | - [ ] Privileged HTTP endpoints reject unauthenticated requests (per chosen scope) |
| 163 | - [ ] Privileged ws handlers reject unauthenticated messages |
| 164 | - [ ] The trusted client attaches the token; in-app editor/notes/presenter still work |
| 165 | - [ ] Token value is never logged or written to disk in plaintext logs |
| 166 | - [ ] Token verifier is unit-tested |
| 167 | - [ ] `pnpm build && pnpm typecheck && pnpm lint && pnpm test` pass |
| 168 | - [ ] `plans/README.md` status row updated |
| 169 | |
| 170 | ## STOP conditions |
| 171 | |
| 172 | Stop and report (do not improvise) if: |
| 173 | |
| 174 | - Any Step 1 decision is unresolved. |
| 175 | - Enforcing auth breaks HMR, cross-device remote control, or the browser |
| 176 | exporter in a way that can't be fixed by passing the token. |
| 177 | - The change starts sprawling beyond the in-scope files (e.g. requiring a new |
| 178 | transport) — re-scope with the operator. |
| 179 | |
| 180 | ## Maintenance notes |
| 181 | |
| 182 | - Treat the client `passwordGuard` as cosmetic UX after this lands; the server is |
| 183 | the real boundary. |
| 184 | - Keep plan 015 (path validation) and 017 (origin) as defense-in-depth even with |
| 185 | auth in place. |
| 186 | - Reviewer: scrutinize the token lifecycle (generation, exposure only to the |
| 187 | trusted client, comparison), and confirm no privileged endpoint is left ungated. |
| 188 |