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:
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user