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

139
test/auth.test.ts Normal file
View File

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

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

View File

@@ -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<number> {
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<string, string> = {
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<void> {
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<void> }
beforeEach(async () => {
port = await getFreePort()
handle = startServer(makeCfg(port, false))
await new Promise<void>((r) => setTimeout(r, 80))
})
afterEach(async () => {
await handle.close()
await new Promise<void>((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<void> }
beforeEach(async () => {
port = await getFreePort()
handle = startServer(makeCfg(port, true))
await new Promise<void>((r) => setTimeout(r, 80))
})
afterEach(async () => {
await handle.close()
await new Promise<void>((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=<valid> → 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=<invalid> → 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('<form')
})
it('GET /login?e=1 → the error banner is revealed (show-error)', async () => {
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)
})
})