From c81821b890a1f27b96db596998d75c93a7263f41 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 13 Jul 2026 04:37:24 +0200 Subject: [PATCH] docs(plans): Wave 5 implementation plans (fan-out board, access token, android parity) --- docs/plans/w5-access-token.md | 155 +++++++++++++++++++++++++++++++ docs/plans/w5-android-parity.md | 153 +++++++++++++++++++++++++++++++ docs/plans/w5-fanout-board.md | 157 ++++++++++++++++++++++++++++++++ 3 files changed, 465 insertions(+) create mode 100644 docs/plans/w5-access-token.md create mode 100644 docs/plans/w5-android-parity.md create mode 100644 docs/plans/w5-fanout-board.md diff --git a/docs/plans/w5-access-token.md b/docs/plans/w5-access-token.md new file mode 100644 index 0000000..b3e1d63 --- /dev/null +++ b/docs/plans/w5-access-token.md @@ -0,0 +1,155 @@ +# App-level access token (leave-the-LAN bar-raiser) + +> **Feature id:** `w5-access-token` · **Effort: M** · Branch: `develop` +> Adds an **optional** shared secret (`WEBTERM_TOKEN`) that gates remote HTTP + the WS handshake with a constant-time-compared, `HttpOnly`/`SameSite=Strict` cookie. **Unset ⇒ auth disabled**, preserving today's LAN zero-config. The token is **additive** — it sits *in front of* the existing Origin/CSRF model (`isOriginAllowed` at `src/http/origin.ts:22`, `requireAllowedOrigin` at `src/server.ts:480`), never replaces it. + +--- + +## Honest tradeoff (read first — belongs in the PR description too) + +This is a **bar-raiser, not a TLS/Tailscale substitute.** On bare LAN the terminal stream is still `ws://` (plaintext) — see `buildWsUrl` at `public/terminal-session.ts:47`, which only upgrades to `wss` when the *page* is HTTPS. When the token travels over `ws://`/`http://`, **anyone sniffing the LAN sees the cookie/token in cleartext.** The token only meaningfully hardens the **relay/tunnel path**, where the edge terminates TLS and the browser speaks `wss://`/`https://`. It is a *single shared secret* (no per-user identity, no revocation except changing the env var + restart, no lockout beyond rate-limiting). Ship it with that framing; do not let it read as "now it's safe on the public internet." + +--- + +## Contract (routes / messages / env / types) + +### Env (new — `src/config.ts`) + +| Env var | Type | Default | Meaning | +|---|---|---|---| +| `WEBTERM_TOKEN` | string \| undefined | **unset** | Shared access token. **Unset/empty ⇒ auth DISABLED** (everything open, exactly as today). When set: **validated at load** — must match `^[A-Za-z0-9._~+/=-]{16,512}$` (cookie/URL-safe charset, min 16 chars). Invalid ⇒ **throw** (fail-fast, like `parsePort`). | +| `WEBTERM_TOKEN_TTL` | number (sec) | `2592000` (30d) | *(optional, YAGNI-dial)* Cookie `Max-Age`. Parsed via the existing `parseNonNegativeInt` helper (`src/config.ts:83`). Ship the constant first; only wire the env if trivial. | + +Charset validation is **not cosmetic**: it blocks `Set-Cookie` header/response-splitting injection and query-string ambiguity, and the ≥16 floor multiplies brute-force cost against the rate limiter. + +### Config type (`src/types.ts`) + +Add one field to `Config` (interface at `src/types.ts:21`, alongside the other secret-ish fields near lines 44–47 / 82): + +``` +readonly webtermToken: string | undefined; // WEBTERM_TOKEN; undefined ⇒ auth disabled (SECRET — never log/expose) +``` + +Follow the `vapidPrivateKey` precedent (`src/config.ts:349`): read as `env['WEBTERM_TOKEN'] || undefined`, **never** log it, **never** return it over `/config/ui`. + +### New pure module `src/http/auth.ts` (mirrors the shape/discipline of `src/http/origin.ts`) + +``` +export const AUTH_COOKIE_NAME = 'webterm_auth' +export function isAuthEnabled(cfg: Config): boolean // webtermToken != null && != '' +export function parseCookieHeader(header: string | undefined): Record +export function constantTimeEqual(a: string, b: string): boolean // SHA-256 both → timingSafeEqual (fixed-length guard) +export function cookieIsAuthed(cfg: Config, cookieHeader: string | undefined): boolean +export function buildSetCookie(cfg: Config, opts: { secure: boolean }): string // the Set-Cookie value +export function isHttpsRequest(req: IncomingMessage): boolean // x-forwarded-proto==='https' || socket.encrypted +``` + +- **`constantTimeEqual`** hashes *both* inputs with `crypto.createHash('sha256')` to a fixed 32 bytes, then `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard": it removes the length side-channel **and** avoids `timingSafeEqual`'s throw-on-length-mismatch. (Present-vs-absent, i.e. undefined/empty cookie, short-circuits to `false` — a missing cookie is not a secret-comparison oracle.) +- **`buildSetCookie`** returns: + `webterm_auth=; Path=/; Max-Age=; HttpOnly; SameSite=Strict` **+ `; Secure`** only when `opts.secure` is true. Dynamic `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`, so forcing it would silently break LAN-over-HTTP auth; over the relay (`x-forwarded-proto: https`) it must be present. + +### New/changed HTTP routes (`src/server.ts`) + +| Route | Guard | Behavior | +|---|---|---| +| `GET /login` | **always reachable** (registered *before* the gate) | Serves the self-contained `public/login.html`. | +| `POST /auth` | **always reachable**, rate-limited (`authLimiter`, 10/min/IP) | Body `token` (accepts `urlencoded` *and* `json`, `limit:'1kb'`). Valid ⇒ `Set-Cookie` (`buildSetCookie`) + **302 → `/`** (native-form path) or **204** for XHR. Invalid ⇒ **401** (+ `302 → /login?e=1` for form navigations). Over rate limit ⇒ **429**. | +| `GET /?token=` | handled **inside the gate**, rate-limited | Bootstrap link. Valid ⇒ `Set-Cookie` + **302 → same path with `token` stripped** (no token left in history; `Referrer-Policy: no-referrer` already set at `src/server.ts:307`). Invalid ⇒ **302 → /login**. | +| **The global auth gate** (`app.use`, new) | — | Runs after the security-headers middleware (`src/server.ts:304`) and **before** `express.static` (`src/server.ts:317`). See allow-list below. | + +### WS handshake (`src/server.ts` upgrade handler, `:1130`) + +After the existing Origin check passes (`:1141–1146`) and **before** `wss.handleUpgrade` (`:1149`), insert: + +> if `isAuthEnabled(cfg)` and **not** `cookieIsAuthed(cfg, req.headers['cookie'])` → `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return`. + +The browser auto-sends the `HttpOnly` cookie on the same-origin WS handshake — **no frontend change to `buildWsUrl`/`connect()` is required** for the authed path. + +### The gate's allow-list (single central policy point — the `origin.ts:13` idiom) + +In order, the gate: +1. `!isAuthEnabled(cfg)` → `next()` *(zero-config LAN unchanged)*. +2. `isLoopback(req.socket.remoteAddress)` (`src/server.ts:160`) → `next()` *(loopback hook ingest — `POST /hook` `:533`, `/hook/permission` `:561`, `/hook/status` `:890` — has no cookie and must keep working; the token is about **remote** access).* +3. `GET` with `?token=` → validate (rate-limited) → set-cookie+redirect, or → `/login`. +4. `cookieIsAuthed(...)` → `next()`. +5. else unauthed: `Accept: text/html` navigation → **302 → /login**; otherwise → **401** JSON `{ error: 'authentication required' }`. + +**Scope note (decision to surface at review):** this gate is a **superset** of the ROADMAP's stated "WS + `requireAllowedOrigin` routes." It *also* gates the read-only GET side-channels (`/live-sessions`, `/projects`, **`/projects/diff` — which leaks source**, `/sessions` — which leaks prompts, `/config/ui`, etc.). That is deliberate: for a "don't-expose-a-shell-off-LAN" bar-raiser, leaving source/prompt reads open off-LAN is the bigger hole, and one central gate is simpler + DRYer than threading a token check through ~19 route handlers. `requireAllowedOrigin` is left **untouched** so the CSRF layer stays independent (defense in depth: gate = "are you authorized to be here", Origin = "is this request forged cross-site"). If a reviewer wants the strictly-minimal scope instead, the fallback is a `requireToken(req,res)` helper called only inside `requireAllowedOrigin` + the WS check — but the global gate is recommended. + +--- + +## Files to change + +| Path | Concrete change | +|---|---| +| `src/types.ts` | Add `readonly webtermToken: string \| undefined` to `Config` (interface `:21`, near the secret fields `:44–47`). **Coordination point** — this is the frozen shared contract; do it here, not locally. Optionally add `authRequired?: boolean` to `UiConfig` (`:656`) for the FE expiry hint. | +| `src/config.ts` | In `loadConfig` (`:271`): read `WEBTERM_TOKEN` (`env['WEBTERM_TOKEN'] \|\| undefined`), validate charset+min-length when present (throw on bad, like `parsePort` `:170`), add `webtermToken` to the frozen return (`:474`). Never log it. *(Optional: parse `WEBTERM_TOKEN_TTL`.)* | +| `src/http/auth.ts` | **NEW.** Pure, dependency-light helpers listed in Contract (`isAuthEnabled`, `parseCookieHeader`, `constantTimeEqual`, `cookieIsAuthed`, `buildSetCookie`, `isHttpsRequest`, `AUTH_COOKIE_NAME`). Imports `createHash`, `timingSafeEqual` from `node:crypto`. No Express/DOM types — keep it unit-testable like `origin.ts`. | +| `src/server.ts` | (1) import the auth helpers (near `:36`). (2) Add `AUTH_RATE_MAX = 10` to the rate-limit constants (`:80–86`). (3) Instantiate `const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (near `:227`). (4) Register `GET /login` + `POST /auth` **then** `app.use(authGate)` between `:313` and `:317`. (5) `authGate` closure implements the allow-list (reuses `isLoopback` `:160`, `requireAllowedOrigin` untouched). (6) WS upgrade: insert the cookie check after Origin (`:1146`, before `:1149`). Serve `login.html` from a startup-cached read of `path.join(publicDir,'login.html')`. | +| `public/login.html` | **NEW, fully self-contained.** A `
` with a `type="password"` token field + submit. **Inline `