fix: address review report across security, architecture, quality, tests

Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review).
typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage
thresholds (80%) enforced in vitest.config.ts.

Critical:
- multi-device approval race: release held approval only when the last
  client detaches (closing one mirror no longer cancels another's prompt)
- unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS),
  enforced in manager via the existing M4 exit(-1) path
- signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close()
- terminal-session initialInput timer tracked + cleared on dispose
- tabs.addEntry null-as-cast type hole removed (build session before entry)

Should-fix:
- security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id]
- history.ts converted to fs/promises (async /sessions handler)
- removed dead clientDims map + blur protocol message end-to-end
- per-connection WS message rate limit (Config.maxMsgsPerSec)
- /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7)

Tests:
- new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites
- extended history/config/manager/integration coverage incl. regressions

Hygiene:
- parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation
- log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped
- operational constants moved into Config
- extracted public/preview-grid.ts (DRY launcher/manage)
- doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
Yaojia Wang
2026-06-20 18:27:45 +02:00
parent 97d57326fd
commit d22dcd24f7
30 changed files with 2900 additions and 400 deletions

View File

@@ -227,9 +227,9 @@ function waitForMessage(
// ── Test suite ────────────────────────────────────────────────────────────────
describe('startServer — integration', () => {
// Suppress stray MaxListeners warnings: each startServer call adds SIGINT/SIGTERM/
// uncaughtException handlers. Tests use separate server instances per test.
process.setMaxListeners(50)
// NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix
// (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
// it registered, so repeated startServer()/close() cycles don't accumulate.
let port: number
let cfg: Config
@@ -730,4 +730,151 @@ describe('startServer — integration', () => {
},
20_000,
)
// ── Signal-handler leak fix (CQ H1) ────────────────────────────────────────
it('does not leak SIGINT listeners across startServer()/close() cycles', async () => {
const before = process.listenerCount('SIGINT')
for (let i = 0; i < 3; i++) {
const p = await getFreePort()
const h = startServer(makeTestConfig(p, process.env['SHELL'] ?? '/bin/zsh'))
await new Promise<void>((r) => setTimeout(r, 30))
await h.close()
}
expect(process.listenerCount('SIGINT')).toBe(before)
expect(process.listenerCount('SIGTERM')).toBe(before)
})
// ── GET /live-sessions (route, no PTY needed when empty) ────────────────────
it('GET /live-sessions returns an array (empty when none running)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.status).toBe(200)
const body = (await res.json()) as unknown
expect(Array.isArray(body)).toBe(true)
})
// ── GET /live-sessions/:id/preview — unknown id → 404 ───────────────────────
it('GET /live-sessions/:id/preview returns 404 for an unknown session id', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000/preview`)
expect(res.status).toBe(404)
})
// ── Security headers present on responses ───────────────────────────────────
it('sets conservative security headers (CSP / X-Frame-Options / nosniff)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.headers.get('x-frame-options')).toBe('DENY')
expect(res.headers.get('x-content-type-options')).toBe('nosniff')
expect(res.headers.get('content-security-policy')).toContain("default-src 'self'")
})
// ── DELETE /live-sessions Origin guard (Sec H2 / Arch 5b) ───────────────────
it('DELETE /live-sessions → 403 with a foreign Origin', 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)
})
it('DELETE /live-sessions → 403 with NO Origin header (default-deny)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE' })
expect(res.status).toBe(403)
})
it('DELETE /live-sessions → 200 with an allowed (same-host) Origin', 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)
const body = (await res.json()) as { killed: number }
expect(typeof body.killed).toBe('number')
})
it('DELETE /live-sessions/:id → 403 with a foreign Origin (before 404 lookup)', async () => {
const res = await fetch(
`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
{ method: 'DELETE', headers: { Origin: 'http://evil.example' } },
)
expect(res.status).toBe(403)
})
it('DELETE /live-sessions/:id → 404 (allowed Origin, unknown id)', async () => {
const res = await fetch(
`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
{ method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` } },
)
expect(res.status).toBe(404)
})
// ── Hook side-channel endpoints (route-level, no PTY needed) ────────────────
it('POST /hook from loopback with an unknown session → 204 (no-op broadcast)', 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)
})
it('POST /hook with an unparseable event body → 400', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // no session header / no event name
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it('POST /hook/permission with no matching session falls back to {} (Claude prompts itself)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
body: JSON.stringify({ tool_name: 'Bash' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
// ── Per-connection WS rate limit (Sec M3) ───────────────────────────────────
// Fires BEFORE attach, so we can flood pre-attach frames without a real PTY.
// A tiny MAX_MSGS_PER_SEC makes the cap observable: the connection survives a
// flood (frames are dropped, not closed).
it('drops frames over MAX_MSGS_PER_SEC but keeps the connection open (no close)', async () => {
const rlPort = await getFreePort()
const rlCfg = loadConfig({
PORT: String(rlPort),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${rlPort}`,
IDLE_TTL: '86400',
USE_TMUX: '0',
MAX_MSGS_PER_SEC: '5',
})
const rlServer = startServer(rlCfg)
await new Promise<void>((r) => setTimeout(r, 80))
try {
const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${rlPort}` },
})
await waitForOpen(ws, 3_000)
// Flood 50 invalid frames in one tick — far over the 5/s cap.
for (let i = 0; i < 50; i++) ws.send('not-json')
// The connection must NOT be closed by the rate limiter.
let closed = false
ws.once('close', () => {
closed = true
})
await new Promise<void>((r) => setTimeout(r, 200))
expect(closed).toBe(false)
expect(ws.readyState).toBe(WebSocket.OPEN)
ws.close()
await waitForClose(ws, 3_000).catch(() => undefined)
} finally {
await rlServer.close()
await new Promise<void>((r) => setTimeout(r, 50))
}
})
})