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

@@ -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)
}
})
})