feat(auth): optional WEBTERM_TOKEN access-token gate (W5)

An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.

- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
  bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
  parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
  HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
  central authGate over all remote HTTP require the cookie. Open: /login, /auth.
  Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
  browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).

Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
This commit is contained in:
Yaojia Wang
2026-07-13 05:25:07 +02:00
parent 9683a16f4f
commit 469037cb94
10 changed files with 995 additions and 8 deletions

View File

@@ -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<string> = new Set([
'/hook',
'/hook/permission',
'/hook/status',
])
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
@@ -230,6 +248,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
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<void> } {
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 '<!doctype html><meta charset="utf-8"><title>Sign in</title><form method="POST" action="/auth"><input type="password" name="token" autofocus><button>Unlock</button></form>'
}
})()
/** 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<string, unknown>
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<void> } {
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)