diff --git a/CLAUDE.md b/CLAUDE.md index 1b32de2..b42695f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,8 @@ npm test # unit tests (vitest, all modules) Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. +`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16–512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`. + ## Architecture (the parts that span files) The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c16cd6..081078c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -121,12 +121,15 @@ build sequence (dependencies noted). the split-grid watch board + statusLine gauges + per-quadrant inline approve; server side is a thin "sessions grouped by repo/worktree" endpoint. Cost is UI. **Effort: L.** Depends on Wave 4. -- [ ] **App-level access token** (leave-the-LAN bar-raiser) - Smallest step to safely use the new relay/tunnel off-LAN: a constant-time-compared - `WEBTERM_TOKEN` checked on the WS handshake (alongside Origin) + on state-changing routes, - set as an httpOnly cookie after one-time `?token=`, disabled when unset (keeps LAN zero-config). - **Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute; needs constant-time - compare + cookie flags + rate-limiting or it's security theater. **Effort: M.** +- [x] **App-level access token** (leave-the-LAN bar-raiser) — shipped on `develop` + A constant-time-compared (`crypto.timingSafeEqual` over SHA-256, fixed-length guard) + `WEBTERM_TOKEN` checked on the WS handshake (alongside, not replacing, Origin) + a central + gate over every remote HTTP route, set as an `HttpOnly; SameSite=Strict; Secure-when-https` + cookie after one-time `GET /?token=` or `POST /auth` (rate-limited 10/min/IP), disabled when + unset (keeps LAN zero-config byte-identical). Charset/length-validated at load; loopback + `/hook*` ingest exempt. `src/http/auth.ts` + wiring in `src/server.ts`. + **Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the + token is cleartext and replayable; only hardens the TLS-terminated relay/tunnel path. **Effort: M.** - [ ] **Android Projects / Diff / Worktree screens** (client parity) Android's whole v0.6/v0.7 projects-git UI is SDK-gated/off in `settings.gradle.kts`. diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..b49af44 --- /dev/null +++ b/public/login.html @@ -0,0 +1,125 @@ + + + + + + + Sign in — web-terminal + + + + +
+

Access token required

+

This web-terminal is protected by a shared access token.

+ +
+ + + +
+

+ Bar-raiser, not a substitute for TLS/Tailscale. On a plain ws:// LAN + the token is sent in cleartext and can be replayed — only use this off-LAN over + an HTTPS/WSS relay or tunnel. Never port-forward the raw port to the internet. +

+
+ + diff --git a/src/config.ts b/src/config.ts index 156b73c..cfec464 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,6 +169,31 @@ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly s return Object.freeze(roots.length > 0 ? roots : [homeDir]) } +/** + * WEBTERM_TOKEN charset+length policy (w5-access-token). URL/cookie-safe chars + * only (no `;`, space, `%`, or control chars) so the value cannot inject a + * Set-Cookie/header split or create query-string `%`-encoding ambiguity; the + * ≥16 floor multiplies brute-force cost against the /auth rate limiter. + */ +const WEBTERM_TOKEN_RE = /^[A-Za-z0-9._~+/=-]{16,512}$/ + +/** + * Parse WEBTERM_TOKEN. Unset/empty ⇒ undefined (auth DISABLED, LAN zero-config + * preserved). When present it must match WEBTERM_TOKEN_RE; invalid ⇒ throw + * (fail-fast, like parsePort). The token value is NEVER included in the error + * message (secret hygiene). + */ +function parseWebtermToken(raw: string | undefined): string | undefined { + if (raw === undefined || raw === '') return undefined + if (!WEBTERM_TOKEN_RE.test(raw)) { + throw new Error( + 'Invalid config: WEBTERM_TOKEN must be 16–512 characters from the ' + + 'URL/cookie-safe set [A-Za-z0-9._~+/=-] (min length 16).', + ) + } + return raw +} + function parsePort(raw: string | undefined): number { if (raw === undefined) return DEFAULT_PORT const n = Number(raw) @@ -329,6 +354,10 @@ export function loadConfig(env: EnvLike): Config { const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) + // w5-access-token — optional shared access token (SECRET — never logged here). + // Unset/empty ⇒ undefined ⇒ auth disabled (LAN zero-config unchanged). + const webtermToken = parseWebtermToken(env['WEBTERM_TOKEN']) + // v0.6 Project Manager — discovery config const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir) const projectScanDepth = parseNonNegativeInt( @@ -474,6 +503,7 @@ export function loadConfig(env: EnvLike): Config { previewBytes, useTmux, allowedOrigins, + webtermToken, projectRoots, projectScanDepth, projectScanTtlMs, diff --git a/src/http/auth.ts b/src/http/auth.ts new file mode 100644 index 0000000..a39c94a --- /dev/null +++ b/src/http/auth.ts @@ -0,0 +1,146 @@ +/** + * src/http/auth.ts — optional shared access-token gate (w5-access-token). + * + * Pure, dependency-light helpers (mirrors the shape/discipline of + * src/http/origin.ts). This layer is **ADDITIVE**: it sits *in front of* the + * existing Origin/CSWSH defence (src/http/origin.ts) and CSRF guard + * (`requireAllowedOrigin` in src/server.ts) — it never replaces or weakens them. + * + * When `WEBTERM_TOKEN` is unset/empty the whole gate is DISABLED — every helper + * that consults `cfg.webtermToken` short-circuits and the server wiring calls + * `next()` unconditionally, so behaviour is byte-identical to today (LAN + * zero-config preserved). The gate activates only when a token is configured. + * + * ── HONEST SECURITY BOUNDARY (read before trusting this) ──────────────────── + * This is a **bar-raiser, NOT a TLS/Tailscale substitute.** On a bare-LAN + * `ws://`/`http://` deployment the terminal stream — and therefore this cookie + * and token — travel in CLEARTEXT. Anyone sniffing the LAN sees the token and + * can REPLAY it (there is no per-request nonce, no channel binding). 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 + restarting, + * no lockout beyond rate-limiting. Do NOT read it as "safe on the public + * internet" — the TECH_DOC §7 "never port-forward this raw" guidance stands. + */ + +import { createHash, timingSafeEqual } from 'node:crypto' +import type { Config } from '../types.js' + +/** The auth cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate). */ +export const AUTH_COOKIE_NAME = 'webterm_auth' + +/** Cookie lifetime (seconds). Shipped as a constant (YAGNI — no env dial in v1); + * 30 days balances "don't re-auth every session" against a bounded replay window. */ +const COOKIE_TTL_SEC = 30 * 24 * 60 * 60 // 2592000 (30 days) + +/** Minimal structural view of an incoming request — keeps this module free of + * Express/DOM types (a real `http.IncomingMessage`/Express `Request` is + * structurally assignable). Used only by `isHttpsRequest`. */ +export interface RequestLike { + readonly headers: Readonly> + // `remoteAddress` is here only so a real `net.Socket` is structurally + // assignable (TS weak-type check needs a shared property); `encrypted` (a + // `tls.TLSSocket` field) is the one this module actually reads. + readonly socket?: { readonly encrypted?: boolean; readonly remoteAddress?: string } | undefined +} + +/** True iff auth is enabled: a non-empty token is configured. Unset/empty ⇒ + * DISABLED (the single central switch — no scattered `if` checks elsewhere). */ +export function isAuthEnabled(cfg: Pick): boolean { + return typeof cfg.webtermToken === 'string' && cfg.webtermToken.length > 0 +} + +/** + * Parse a raw `Cookie:` header into a name→value map. + * Malformed pairs (no `=`, empty name) are ignored; last write wins on dup names. + * Values are returned verbatim (NOT URL-decoded) — our token uses a cookie-safe + * charset (validated at config load) so there is nothing to decode. + */ +export function parseCookieHeader(header: string | undefined): Record { + const out: Record = {} + if (header === undefined || header === '') return out + for (const part of header.split(';')) { + const eq = part.indexOf('=') + if (eq <= 0) continue // no '=' or empty name → ignore + const name = part.slice(0, eq).trim() + if (name === '') continue + out[name] = part.slice(eq + 1).trim() + } + return out +} + +/** + * Constant-time string comparison (SECURITY-CRITICAL — never use `===`). + * + * Both inputs are hashed with SHA-256 to a FIXED 32 bytes, then compared with + * `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard": + * it removes the length side-channel (unequal-length secrets no longer leak + * their length via timing) AND sidesteps `timingSafeEqual`'s throw-on-length- + * mismatch. A present-vs-absent (empty/undefined) candidate short-circuits to + * `false` before the comparator — a missing value is not a secret-compare oracle, + * not a timing signal to protect. + */ +export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean { + // Boundary guard (input validation): never trust the candidate. An empty or + // absent value can never be a match and must not reach the comparator. + if (typeof a !== 'string' || typeof b !== 'string') return false + if (a.length === 0 || b.length === 0) return false + const ha = createHash('sha256').update(a, 'utf8').digest() + const hb = createHash('sha256').update(b, 'utf8').digest() + return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws +} + +/** + * True iff the request's cookie carries a valid auth token. + * Disabled config (no token) ⇒ `false` — callers MUST short-circuit on + * `isAuthEnabled` first; a disabled gate never treats anyone as "authed". + */ +export function cookieIsAuthed( + cfg: Pick, + cookieHeader: string | undefined, +): boolean { + const token = cfg.webtermToken + if (typeof token !== 'string' || token.length === 0) return false + const presented = parseCookieHeader(cookieHeader)[AUTH_COOKIE_NAME] + return constantTimeEqual(presented, token) +} + +/** + * Build the `Set-Cookie` header value for a successful auth. + * + * Flags: `HttpOnly` (XSS can't read it), `SameSite=Strict` (cross-site pages + * can't ride the cookie — complements the Origin/CSWSH defence), `Path=/`, + * `Max-Age=`, and `Secure` **only when `opts.secure`**. The 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. + */ +export function buildSetCookie( + cfg: Pick, + opts: { secure: boolean }, +): string { + const parts = [ + `${AUTH_COOKIE_NAME}=${cfg.webtermToken ?? ''}`, + 'Path=/', + `Max-Age=${COOKIE_TTL_SEC}`, + 'HttpOnly', + 'SameSite=Strict', + ] + if (opts.secure) parts.push('Secure') + return parts.join('; ') +} + +/** + * True iff the request arrived over HTTPS/WSS — either directly + * (`socket.encrypted`) or behind a TLS-terminating edge that set + * `x-forwarded-proto: https` (the relay/tunnel path). Drives the dynamic + * `Secure` cookie flag above. + */ +export function isHttpsRequest(req: RequestLike): boolean { + const xfp = req.headers['x-forwarded-proto'] + const proto = Array.isArray(xfp) ? xfp[0] : xfp + if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') { + return true + } + return req.socket?.encrypted === true +} diff --git a/src/server.ts b/src/server.ts index 138a315..a5c7192 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,9 +24,10 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' +import { readFileSync } from 'node:fs' import express from 'express' -import type { Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { WebSocketServer } from 'ws' import type { WebSocket as WsWebSocket } from 'ws' import type { IncomingMessage } from 'node:http' @@ -34,6 +35,13 @@ import type { IncomingMessage } from 'node:http' import { loadConfig } from './config.js' import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js' import { isOriginAllowed } from './http/origin.js' +import { + buildSetCookie, + constantTimeEqual, + cookieIsAuthed, + isAuthEnabled, + isHttpsRequest, +} from './http/auth.js' import { parseHookEvent } from './http/hook.js' import { deriveApprovalPreview } from './http/approval-preview.js' import { listSessions } from './http/history.js' @@ -84,6 +92,16 @@ const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound) +const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard) + +/** Loopback-only Claude Code hook ingest paths the auth gate lets through from a + * loopback peer with no cookie (the token is about REMOTE access; hooks run on + * the host). Each handler independently re-checks isLoopback (defense in depth). */ +const LOOPBACK_INGEST_PATHS: ReadonlySet = new Set([ + '/hook', + '/hook/permission', + '/hook/status', +]) /** The four permission modes the WS approve relay accepts (B4); else undefined. */ const PERMISSION_MODES: ReadonlySet = new Set([ @@ -230,6 +248,7 @@ export function startServer(cfg: Config): { close(): Promise } { const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2 const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push + const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token // W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook // schedules a debounced drain of one queue entry after cfg.queueSettleMs, so @@ -313,9 +332,134 @@ export function startServer(cfg: Config): { close(): Promise } { next() }) + const publicDir = path.join(__dirname, '..', 'public') + + // ── w5-access-token: optional shared-token gate (ADDITIVE — in front of the + // Origin/CSRF model, never replacing it). Unset WEBTERM_TOKEN ⇒ DISABLED ⇒ + // every request falls straight through `next()` (LAN zero-config unchanged). + // + // HONEST BOUNDARY: a bar-raiser, NOT a TLS/Tailscale substitute. On bare ws:// + // the cookie/token travel in cleartext and are replayable by a LAN sniffer; + // this only meaningfully hardens the relay/tunnel (TLS-terminated) path. See + // src/http/auth.ts and TECH_DOC §7 ("never port-forward this raw"). + + // Startup-cached login page (served by GET /login BEFORE the gate, so it is + // always reachable while unauthed). Fallback keeps the server alive if the + // asset is missing rather than crashing at boot. + const loginHtml = ((): string => { + try { + return readFileSync(path.join(publicDir, 'login.html'), 'utf8') + } catch { + return 'Sign in
' + } + })() + + /** True for a top-level browser navigation (wants HTML) vs an XHR/fetch (wants JSON). */ + function acceptsHtml(req: Request): boolean { + const accept = req.headers['accept'] + return typeof accept === 'string' && accept.includes('text/html') + } + + /** Redirect target for the `?token=` bootstrap: same path with `token` stripped + * so no secret is left in browser history / Referer. */ + function pathWithoutToken(req: Request): string { + const u = new URL(req.originalUrl, 'http://localhost') + u.searchParams.delete('token') + const qs = u.searchParams.toString() + return u.pathname + (qs ? `?${qs}` : '') + } + + // GET /login — always reachable. On ?e=1 reveal the error banner (no JS: the + // CSP is `script-src 'self'`, so the toggle is a server-side class swap). + app.get('/login', (req, res) => { + const html = + req.query['e'] === '1' + ? loginHtml.replace('ERRSTATE', 'show-error') + : loginHtml.replace('ERRSTATE', '') + res.type('html').send(html) + }) + + // POST /auth — always reachable, rate-limited. Accepts urlencoded (native form) + // AND json (XHR). Valid ⇒ Set-Cookie + 302→/ (form) or 204 (XHR). Invalid ⇒ + // 302→/login?e=1 (form) or 401 (XHR). Over the limit ⇒ 429. + app.post( + '/auth', + express.urlencoded({ extended: false, limit: '1kb' }), + express.json({ limit: '1kb' }), + (req, res) => { + const ip = req.socket.remoteAddress ?? 'unknown' + if (!authLimiter(ip, Date.now())) { + res.status(429).end() + return + } + // Disabled ⇒ nothing to authenticate; acknowledge without setting a cookie. + if (!isAuthEnabled(cfg)) { + res.status(204).end() + return + } + const body = (req.body ?? {}) as Record + const candidate = typeof body['token'] === 'string' ? body['token'] : '' + const isForm = acceptsHtml(req) + if (constantTimeEqual(candidate, cfg.webtermToken)) { + res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) })) + if (isForm) res.redirect(302, '/') + else res.status(204).end() + return + } + if (isForm) res.redirect(302, '/login?e=1') + else res.status(401).json({ error: 'invalid token' }) + }, + ) + + // The global auth gate. Runs after the security-headers middleware and BEFORE + // express.static + every API route below, so ONE central policy point covers + // the whole app (the origin.ts idiom). Allow-list, in order: + function authGate(req: Request, res: Response, next: NextFunction): void { + // 1. Disabled → everything open (zero-config LAN unchanged). + if (!isAuthEnabled(cfg)) { + next() + return + } + // 2. Loopback-only Claude Code hook ingest (POST /hook, /hook/permission, + // /hook/status) has no cookie and must keep working — the token is about + // REMOTE access, not the host's own hook side-channel. DEVIATION from the + // plan's blanket `isLoopback → next()`: scoped to these three ingest paths + // so the gate still protects every OTHER route even from a loopback peer + // (a token-enabled deploy then also gates the local browser — strictly + // more secure, and testable from a 127.0.0.1 harness). + if (LOOPBACK_INGEST_PATHS.has(req.path) && isLoopback(req.socket.remoteAddress ?? '')) { + next() + return + } + // 3. GET with ?token= bootstrap link → validate (rate-limited), set cookie + + // redirect to the same path with the token stripped, else → /login. + if (req.method === 'GET' && typeof req.query['token'] === 'string' && req.query['token'] !== '') { + const ip = req.socket.remoteAddress ?? 'unknown' + if (!authLimiter(ip, Date.now())) { + res.status(429).end() + return + } + if (constantTimeEqual(req.query['token'], cfg.webtermToken)) { + res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) })) + res.redirect(302, pathWithoutToken(req)) + } else { + res.redirect(302, '/login') + } + return + } + // 4. Valid cookie → allow. + if (cookieIsAuthed(cfg, req.headers['cookie'])) { + next() + return + } + // 5. Unauthed: HTML navigation → login page; XHR/other → 401 JSON. + if (acceptsHtml(req)) res.redirect(302, '/login') + else res.status(401).json({ error: 'authentication required' }) + } + app.use(authGate) + // Serve the entire public/ directory (including public/build/ esbuild output). // Note: `npm run build:web` must be run before `npm start` to populate public/build/. - const publicDir = path.join(__dirname, '..', 'public') app.use(express.static(publicDir)) // ── Claude Code history (O2) — list past sessions for the resume browser ── @@ -1156,6 +1300,16 @@ export function startServer(cfg: Config): { close(): Promise } { return } + // 2b. Access-token gate (w5-access-token) — ADDITIVE, runs AFTER the Origin + // check (never weakens it) and BEFORE the handshake. The browser + // auto-sends the HttpOnly cookie on the same-origin WS upgrade, so no + // frontend change is needed. Disabled (no token) → this is a no-op. + if (isAuthEnabled(cfg) && !cookieIsAuthed(cfg, req.headers['cookie'])) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + // 3. Hand off to wss — it completes the handshake and emits 'connection'. wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) diff --git a/src/types.ts b/src/types.ts index c806b7d..34e1546 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,11 @@ export interface Config { readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) + // w5-access-token — optional shared access token gate (ADDITIVE, in front of the + // Origin/CSRF model; never replaces it). undefined/empty ⇒ auth DISABLED, so LAN + // zero-config is preserved. When set: validated at load (16–512 URL/cookie-safe + // chars). SECRET — never log; never return over /config/ui. See src/http/auth.ts. + readonly webtermToken: string | undefined; // WEBTERM_TOKEN // v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3) readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir] readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4 diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..85901c8 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,139 @@ +/** + * test/auth.test.ts — unit tests for src/http/auth.ts (w5-access-token). + * + * Mirrors the discipline of test/origin.test.ts. Security-critical: the + * constant-time comparison, the cookie flags, and the disabled short-circuit. + */ + +import { describe, it, expect } from 'vitest' +import { + AUTH_COOKIE_NAME, + buildSetCookie, + constantTimeEqual, + cookieIsAuthed, + isAuthEnabled, + isHttpsRequest, + parseCookieHeader, +} from '../src/http/auth.js' + +const TOKEN = 'super-secret-token-1234' // ≥16, safe charset + +// ── constantTimeEqual ───────────────────────────────────────────────────────── +describe('constantTimeEqual', () => { + it('returns true for equal strings', () => { + expect(constantTimeEqual(TOKEN, TOKEN)).toBe(true) + }) + + it('returns false for unequal same-length strings', () => { + expect(constantTimeEqual('aaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaab')).toBe(false) + }) + + it('returns false for different-length strings without throwing (fixed-length guard)', () => { + // The SHA-256-to-fixed-32-bytes guard means unequal lengths never throw. + expect(() => constantTimeEqual('short', 'a-much-longer-candidate-value')).not.toThrow() + expect(constantTimeEqual('short', 'a-much-longer-candidate-value')).toBe(false) + }) + + it('returns false for an empty candidate (present-vs-absent short-circuit)', () => { + expect(constantTimeEqual('', TOKEN)).toBe(false) + expect(constantTimeEqual(TOKEN, '')).toBe(false) + }) + + it('returns false for an undefined candidate', () => { + expect(constantTimeEqual(undefined, TOKEN)).toBe(false) + expect(constantTimeEqual(TOKEN, undefined)).toBe(false) + }) +}) + +// ── parseCookieHeader ───────────────────────────────────────────────────────── +describe('parseCookieHeader', () => { + it('parses a multi-pair cookie header into a map', () => { + const map = parseCookieHeader('a=1; webterm_auth=xyz; b=2') + expect(map).toEqual({ a: '1', webterm_auth: 'xyz', b: '2' }) + }) + + it('returns {} for a missing or empty header', () => { + expect(parseCookieHeader(undefined)).toEqual({}) + expect(parseCookieHeader('')).toEqual({}) + }) + + it('ignores malformed pairs (no = / empty name)', () => { + const map = parseCookieHeader('novalue; =orphan; webterm_auth=ok') + expect(map).toEqual({ webterm_auth: 'ok' }) + }) +}) + +// ── isAuthEnabled ───────────────────────────────────────────────────────────── +describe('isAuthEnabled', () => { + it('is false when the token is undefined', () => { + expect(isAuthEnabled({ webtermToken: undefined })).toBe(false) + }) + + it('is false when the token is an empty string', () => { + expect(isAuthEnabled({ webtermToken: '' })).toBe(false) + }) + + it('is true when a token is set', () => { + expect(isAuthEnabled({ webtermToken: TOKEN })).toBe(true) + }) +}) + +// ── cookieIsAuthed ──────────────────────────────────────────────────────────── +describe('cookieIsAuthed', () => { + it('is true for a correct auth cookie', () => { + expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=${TOKEN}`)).toBe(true) + }) + + it('is false for a wrong cookie value', () => { + expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=nope-nope-nope-nope`)).toBe(false) + }) + + it('is false when the auth cookie is absent', () => { + expect(cookieIsAuthed({ webtermToken: TOKEN }, 'other=1')).toBe(false) + expect(cookieIsAuthed({ webtermToken: TOKEN }, undefined)).toBe(false) + }) + + it('is false when auth is disabled (no token) regardless of cookie', () => { + expect(cookieIsAuthed({ webtermToken: undefined }, `${AUTH_COOKIE_NAME}=anything`)).toBe(false) + }) +}) + +// ── buildSetCookie ──────────────────────────────────────────────────────────── +describe('buildSetCookie', () => { + it('includes the token value and the required flags', () => { + const c = buildSetCookie({ webtermToken: TOKEN }, { secure: false }) + expect(c).toContain(`${AUTH_COOKIE_NAME}=${TOKEN}`) + expect(c).toContain('Path=/') + expect(c).toContain('Max-Age=') + expect(c).toContain('HttpOnly') + expect(c).toContain('SameSite=Strict') + }) + + it('omits Secure when opts.secure is false (LAN over http)', () => { + expect(buildSetCookie({ webtermToken: TOKEN }, { secure: false })).not.toContain('Secure') + }) + + it('includes Secure when opts.secure is true (relay over https)', () => { + expect(buildSetCookie({ webtermToken: TOKEN }, { secure: true })).toContain('; Secure') + }) +}) + +// ── isHttpsRequest ──────────────────────────────────────────────────────────── +describe('isHttpsRequest', () => { + it('is true when x-forwarded-proto is https (TLS-terminating edge)', () => { + expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https' } })).toBe(true) + }) + + it('reads the first value of a comma-joined x-forwarded-proto', () => { + expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https, http' } })).toBe(true) + }) + + it('is true when the socket is encrypted (direct TLS)', () => { + expect(isHttpsRequest({ headers: {}, socket: { encrypted: true } })).toBe(true) + }) + + it('is false for a plain http request', () => { + expect(isHttpsRequest({ headers: {}, socket: { encrypted: false } })).toBe(false) + expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'http' } })).toBe(false) + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts index 4b9de49..8fbb7f3 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -840,3 +840,49 @@ describe('loadConfig — W2 inject queue', () => { expect(() => loadConfig({ QUEUE_SETTLE_MS: 'soon' })).toThrow() }) }) + +describe('loadConfig — w5-access-token (WEBTERM_TOKEN)', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('is undefined when WEBTERM_TOKEN is unset (auth disabled — LAN zero-config)', () => { + expect(loadConfig({}).webtermToken).toBeUndefined() + }) + + it('is undefined when WEBTERM_TOKEN is the empty string (treated as unset)', () => { + expect(loadConfig({ WEBTERM_TOKEN: '' }).webtermToken).toBeUndefined() + }) + + it('stores a valid token verbatim (≥16 chars, safe charset)', () => { + const t = 'super-secret-token-1234' + expect(loadConfig({ WEBTERM_TOKEN: t }).webtermToken).toBe(t) + }) + + it('throws for a too-short token (< 16 chars)', () => { + expect(() => loadConfig({ WEBTERM_TOKEN: 'abc' })).toThrow(/WEBTERM_TOKEN/) + }) + + it('throws for a token with an unsafe char (semicolon → Set-Cookie injection)', () => { + expect(() => loadConfig({ WEBTERM_TOKEN: 'has;semicolon;inside0' })).toThrow(/WEBTERM_TOKEN/) + }) + + it('throws for a token with a space', () => { + expect(() => loadConfig({ WEBTERM_TOKEN: 'has a space in it here' })).toThrow(/WEBTERM_TOKEN/) + }) + + it('throws for a token with a control char', () => { + expect(() => loadConfig({ WEBTERM_TOKEN: 'ctrlchar-injection00' })).toThrow(/WEBTERM_TOKEN/) + }) + + it('never leaks the token value in the validation error message (secret hygiene)', () => { + const badToken = 'secretbutbad;value00' + expect(() => loadConfig({ WEBTERM_TOKEN: badToken })).toThrow() + try { + loadConfig({ WEBTERM_TOKEN: badToken }) + } catch (e) { + expect(String((e as Error).message)).not.toContain(badToken) + } + }) +}) diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts new file mode 100644 index 0000000..c3d226e --- /dev/null +++ b/test/integration/auth.test.ts @@ -0,0 +1,337 @@ +/** + * test/integration/auth.test.ts — real-server integration for the optional + * access-token gate (w5-access-token). Pattern mirrors integration/server.test.ts. + * + * Two axes: + * - DISABLED (no WEBTERM_TOKEN): everything works byte-identically to today — + * WS connects on Origin alone, state-changing routes gated only by Origin. + * - ENABLED: the gate + WS cookie check activate ALONGSIDE (never replacing) + * the Origin/CSRF model. + * + * All traffic here is loopback (127.0.0.1). The gate's loopback bypass is scoped + * to the /hook* ingest paths only, so non-hook routes are genuinely gated even + * from 127.0.0.1 — which is what makes the "unauthed → 401/302" cases testable. + * + * Each test gets its own port + server (beforeEach) so the per-IP auth rate + * limiter never leaks across cases. + */ + +import net from 'node:net' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import WebSocket from 'ws' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { Config } from '../../src/types.js' + +const TOKEN = 'integration-secret-token-01' // ≥16, safe charset +const COOKIE = `webterm_auth=${TOKEN}` + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (!addr || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +/** Build a config, optionally with WEBTERM_TOKEN set (auth enabled). */ +function makeCfg(port: number, withToken: boolean): Config { + const env: Record = { + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + SCROLLBACK_BYTES: String(64 * 1024), + IDLE_TTL: '86400', + USE_TMUX: '0', + } + if (withToken) env['WEBTERM_TOKEN'] = TOKEN + return loadConfig(env) +} + +/** Resolve iff the WS opens; reject if it errors/closes before open. */ +function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs) + ws.once('open', () => { + clearTimeout(timer) + resolve() + }) + ws.once('error', (err) => { + clearTimeout(timer) + reject(err) + }) + ws.once('close', () => { + clearTimeout(timer) + reject(new Error('WS closed before open')) + }) + }) +} + +// ── DISABLED: zero behaviour change (the regression proof) ────────────────────── +describe('access-token gate — DISABLED (WEBTERM_TOKEN unset)', () => { + let port: number + let handle: { close(): Promise } + + beforeEach(async () => { + port = await getFreePort() + handle = startServer(makeCfg(port, false)) + await new Promise((r) => setTimeout(r, 80)) + }) + afterEach(async () => { + await handle.close() + await new Promise((r) => setTimeout(r, 50)) + }) + + it('WS handshake succeeds with a valid Origin and NO cookie (unchanged)', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await expect(waitForOpen(ws)).resolves.toBeUndefined() + ws.terminate() + }) + + it('GET /live-sessions is open (no cookie required)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) + expect(res.status).toBe(200) + expect(Array.isArray(await res.json())).toBe(true) + }) + + it('GET / serves the app (200, no login redirect)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/`, { + headers: { Accept: 'text/html' }, + redirect: 'manual', + }) + expect(res.status).toBe(200) + }) + + it('DELETE /live-sessions works with a valid Origin and no cookie (Origin-only, as today)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + expect(res.status).toBe(200) + }) + + it('DELETE /live-sessions still 403s a foreign Origin (CSRF layer intact)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: 'http://evil.example' }, + }) + expect(res.status).toBe(403) + }) +}) + +// ── ENABLED: the gate + WS cookie check activate ──────────────────────────────── +describe('access-token gate — ENABLED (WEBTERM_TOKEN set)', () => { + let port: number + let handle: { close(): Promise } + + beforeEach(async () => { + port = await getFreePort() + handle = startServer(makeCfg(port, true)) + await new Promise((r) => setTimeout(r, 80)) + }) + afterEach(async () => { + await handle.close() + await new Promise((r) => setTimeout(r, 50)) + }) + + // ── WS handshake ────────────────────────────────────────────────────────── + it('WS: valid Origin + NO cookie → handshake rejected (401)', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await expect(waitForOpen(ws)).rejects.toThrow() + ws.terminate() + }) + + it('WS: valid Origin + WRONG cookie → handshake rejected (401)', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { + headers: { Origin: `http://127.0.0.1:${port}`, Cookie: 'webterm_auth=wrong-value-000000' }, + }) + await expect(waitForOpen(ws)).rejects.toThrow() + ws.terminate() + }) + + it('WS: valid Origin + VALID cookie → handshake accepted (opens)', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { + headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE }, + }) + await expect(waitForOpen(ws)).resolves.toBeUndefined() + ws.terminate() + }) + + it('WS: the Origin check still runs FIRST — bad Origin + valid cookie → 401 (additive, not replacing)', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { + headers: { Origin: 'http://evil.example', Cookie: COOKIE }, + }) + await expect(waitForOpen(ws)).rejects.toThrow() + ws.terminate() + }) + + // ── POST /auth ──────────────────────────────────────────────────────────── + it('POST /auth with the WRONG token → 401 JSON (XHR)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'nope-nope-nope-nope-00' }), + }) + expect(res.status).toBe(401) + }) + + it('POST /auth with the CORRECT token (form) → 302 + Set-Cookie with HttpOnly/SameSite/Path/Max-Age, NO Secure over http', async () => { + const res = await fetch(`http://127.0.0.1:${port}/auth`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'text/html' }, + body: `token=${encodeURIComponent(TOKEN)}`, + redirect: 'manual', + }) + expect(res.status).toBe(302) + const setCookie = res.headers.get('set-cookie') ?? '' + expect(setCookie).toContain(`webterm_auth=${TOKEN}`) + expect(setCookie).toContain('HttpOnly') + expect(setCookie).toContain('SameSite=Strict') + expect(setCookie).toContain('Path=/') + expect(setCookie).toContain('Max-Age=') + expect(setCookie).not.toContain('Secure') + }) + + it('POST /auth with the CORRECT token (XHR) → 204 + Set-Cookie', async () => { + const res = await fetch(`http://127.0.0.1:${port}/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: TOKEN }), + }) + expect(res.status).toBe(204) + expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=') + }) + + it('POST /auth over x-forwarded-proto: https → Set-Cookie INCLUDES Secure', async () => { + const res = await fetch(`http://127.0.0.1:${port}/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' }, + body: JSON.stringify({ token: TOKEN }), + }) + expect(res.status).toBe(204) + expect(res.headers.get('set-cookie') ?? '').toContain('Secure') + }) + + it('POST /auth is rate-limited: >10 bad attempts/min → 429', async () => { + let saw429 = false + for (let i = 0; i < 12; i++) { + const res = await fetch(`http://127.0.0.1:${port}/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'bad-attempt-value-000' }), + }) + if (res.status === 429) saw429 = true + } + expect(saw429).toBe(true) + }) + + // ── GET /?token= bootstrap ────────────────────────────────────────────────── + it('GET /?token= → 302 to a path with NO token + Set-Cookie', async () => { + const res = await fetch(`http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}`, { + redirect: 'manual', + }) + expect(res.status).toBe(302) + const loc = res.headers.get('location') ?? '' + expect(loc).not.toContain('token') + expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=') + }) + + it('GET /?token= → 302 to /login, NO cookie', async () => { + const res = await fetch(`http://127.0.0.1:${port}/?token=wrong-token-value-00`, { + redirect: 'manual', + }) + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/login') + expect(res.headers.get('set-cookie')).toBeNull() + }) + + // ── /login reachable + unauthed routing ───────────────────────────────────── + it('GET /login → 200 HTML, reachable while unauthed', async () => { + const res = await fetch(`http://127.0.0.1:${port}/login`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type') ?? '').toContain('text/html') + expect(await res.text()).toContain(' { + const res = await fetch(`http://127.0.0.1:${port}/login?e=1`) + expect(await res.text()).toContain('show-error') + }) + + it('unauthed HTML navigation (GET /, no cookie) → 302 /login', async () => { + const res = await fetch(`http://127.0.0.1:${port}/`, { + headers: { Accept: 'text/html' }, + redirect: 'manual', + }) + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/login') + }) + + it('unauthed XHR (GET /live-sessions, no cookie) → 401 JSON', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) + expect(res.status).toBe(401) + expect(await res.json()).toEqual({ error: 'authentication required' }) + }) + + it('authed XHR (GET /live-sessions with valid cookie) → 200', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + headers: { Cookie: COOKIE }, + }) + expect(res.status).toBe(200) + expect(Array.isArray(await res.json())).toBe(true) + }) + + // ── Gate + CSRF stacking on a state-changing route ────────────────────────── + it('DELETE /live-sessions, valid Origin, NO cookie → 401 (gate blocks before CSRF)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + expect(res.status).toBe(401) + }) + + it('DELETE /live-sessions, valid Origin + valid cookie → 200 (passes gate, then CSRF)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE }, + }) + expect(res.status).toBe(200) + }) + + it('DELETE /live-sessions, valid cookie but FOREIGN Origin → 403 (CSRF layer still independent)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: 'http://evil.example', Cookie: COOKIE }, + }) + expect(res.status).toBe(403) + }) + + // ── Loopback hook ingest bypass (guard against regressing the side-channel) ── + it('POST /hook from loopback with NO cookie still returns 204 (hooks unaffected)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-webterm-session': '00000000-0000-4000-8000-000000000000', + }, + body: JSON.stringify({ hook_event_name: 'Stop' }), + }) + expect(res.status).toBe(204) + }) +})