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:
153
src/server.ts
153
src/server.ts
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import net from 'node:net'
|
||||
import { URL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -39,7 +40,7 @@ import { isOriginAllowed } from './http/origin.js'
|
||||
import { parseHookEvent } from './http/hook.js'
|
||||
import { listSessions } from './http/history.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js'
|
||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
import type { Config } from './types.js'
|
||||
import { WS_OPEN } from './types.js'
|
||||
|
||||
@@ -48,22 +49,38 @@ import { WS_OPEN } from './types.js'
|
||||
const DEFAULT_COLS = 80
|
||||
const DEFAULT_ROWS = 24
|
||||
|
||||
// How many bytes of recent scrollback the manage-page preview renders (enough to
|
||||
// contain a full-screen TUI repaint, so the thumbnail shows the current screen).
|
||||
const PREVIEW_BYTES = 24 * 1024
|
||||
// Width of the leaky-bucket window for per-connection WS rate limiting (10).
|
||||
const RATE_WINDOW_MS = 1000
|
||||
|
||||
// H3: how long the server holds a PermissionRequest before falling back to
|
||||
// Claude's own interactive prompt (so it never hangs if nobody responds).
|
||||
const PERM_TIMEOUT_MS = 5 * 60_000
|
||||
// Max chars of a user-controlled string written to a log line (M2 log injection).
|
||||
const LOG_FIELD_MAX = 200
|
||||
|
||||
/** The decision JSON a PermissionRequest command hook writes to stdout. */
|
||||
function permDecision(behavior: 'allow' | 'deny'): unknown {
|
||||
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
|
||||
}
|
||||
|
||||
/** True for loopback peers (hooks always run on the host). */
|
||||
/**
|
||||
* True for loopback peers (hooks always run on the host). Accepts the whole
|
||||
* 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer
|
||||
* address behind a local proxy / alternate loopback alias still passes.
|
||||
*/
|
||||
function isLoopback(ip: string): boolean {
|
||||
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
|
||||
if (ip === '::1') return true
|
||||
// Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part.
|
||||
const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip
|
||||
if (net.isIPv4(v4)) {
|
||||
return v4.startsWith('127.')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Strip control chars and truncate a user-controlled value before logging (M2). */
|
||||
function sanitizeForLog(value: unknown): string {
|
||||
return String(value)
|
||||
.slice(0, LOG_FIELD_MAX)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f]/g, '?')
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -105,14 +122,34 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// ── Express static hosting ────────────────────────────────────────────────
|
||||
const app = express()
|
||||
|
||||
// ── Security headers (Sec H2) ─────────────────────────────────────────────
|
||||
// Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the
|
||||
// 'unsafe-inline' on style-src is required because xterm.js injects inline
|
||||
// styles for the terminal viewport (verified: dropping it breaks rendering).
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('X-Frame-Options', 'DENY')
|
||||
res.setHeader('Referrer-Policy', 'no-referrer')
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:",
|
||||
)
|
||||
next()
|
||||
})
|
||||
|
||||
// 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 ──
|
||||
app.get('/sessions', (_req, res) => {
|
||||
res.json(listSessions(50))
|
||||
// SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the
|
||||
// first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to
|
||||
// any LAN device. That is consistent with the app's threat model — this whole
|
||||
// app hands a full shell to anyone who can reach the port (no auth, LAN-only,
|
||||
// never public-internet). Deploy behind Tailscale. See TECH_DOC §7.
|
||||
app.get('/sessions', async (_req, res) => {
|
||||
res.json(await listSessions(50))
|
||||
})
|
||||
|
||||
// ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
|
||||
@@ -133,12 +170,26 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
id: session.meta.id,
|
||||
cols: session.pty.cols,
|
||||
rows: session.pty.rows,
|
||||
data: session.buffer.tail(PREVIEW_BYTES),
|
||||
data: session.buffer.tail(cfg.previewBytes),
|
||||
})
|
||||
})
|
||||
|
||||
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
|
||||
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
|
||||
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
|
||||
// Origin whitelist; reject missing/foreign Origin with 403.
|
||||
function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean {
|
||||
const origin = req.headers['origin'] as string | undefined
|
||||
if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
|
||||
res.status(403).end()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
|
||||
app.delete('/live-sessions', (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
const onlyDetached = req.query['detached'] === '1'
|
||||
let killed = 0
|
||||
for (const s of manager.list()) {
|
||||
@@ -150,6 +201,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
|
||||
// Kill one session by id — manage page per-row action.
|
||||
app.delete('/live-sessions/:id', (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
const ok = manager.killById(req.params.id)
|
||||
res.status(ok ? 204 : 404).end()
|
||||
})
|
||||
@@ -194,7 +246,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
pendingApprovals.delete(sessionId)
|
||||
res.json({}) // timeout → fall back to Claude's interactive prompt
|
||||
manager.handleHookEvent(sessionId, 'idle')
|
||||
}, PERM_TIMEOUT_MS)
|
||||
}, cfg.permTimeoutMs)
|
||||
pendingApprovals.set(sessionId, { res, timer })
|
||||
|
||||
// Show the approve/reject affordance on the client.
|
||||
@@ -213,10 +265,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
})
|
||||
|
||||
// ── Idle reaper ───────────────────────────────────────────────────────────
|
||||
const REAP_INTERVAL_MS = 60_000 // check every minute
|
||||
const reapTimer = setInterval(() => {
|
||||
manager.reapIdle(Date.now())
|
||||
}, REAP_INTERVAL_MS)
|
||||
}, cfg.reapIntervalMs)
|
||||
// Don't let this timer prevent the process from exiting.
|
||||
reapTimer.unref()
|
||||
|
||||
@@ -250,14 +301,43 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// State: session id bound after the first 'attach' frame.
|
||||
let boundSessionId: string | null = null
|
||||
|
||||
// Per-connection leaky-bucket rate limit (Sec M3). maxPayload caps frame
|
||||
// SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate
|
||||
// CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a
|
||||
// legitimate burst); the warn log is throttled to once per window.
|
||||
let windowStart = Date.now()
|
||||
let msgsThisWindow = 0
|
||||
let droppedThisWindow = 0
|
||||
let warnedThisWindow = false
|
||||
|
||||
// Per-connection message handler.
|
||||
const onMessage = (raw: Buffer | string): void => {
|
||||
// ── Rate limit (leaky bucket over a 1s window) ────────────────────────
|
||||
const now = Date.now()
|
||||
if (now - windowStart >= RATE_WINDOW_MS) {
|
||||
windowStart = now
|
||||
msgsThisWindow = 0
|
||||
droppedThisWindow = 0
|
||||
warnedThisWindow = false
|
||||
}
|
||||
msgsThisWindow += 1
|
||||
if (msgsThisWindow > cfg.maxMsgsPerSec) {
|
||||
droppedThisWindow += 1
|
||||
if (!warnedThisWindow) {
|
||||
warnedThisWindow = true
|
||||
console.error(
|
||||
`[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const text = typeof raw === 'string' ? raw : raw.toString('utf8')
|
||||
const result = parseClientMessage(text)
|
||||
|
||||
if (!result.ok) {
|
||||
// Discard invalid frames; log without noisy stack traces (avoid noise per spec).
|
||||
console.error('[server] invalid client message discarded:', result.error)
|
||||
console.error('[server] invalid client message discarded:', sanitizeForLog(result.error))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,11 +385,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
if (msg.type === 'input') {
|
||||
writeInput(session, msg.data)
|
||||
} else if (msg.type === 'resize') {
|
||||
// Per-client dims; the PTY tracks the min across actively-viewing devices.
|
||||
// Latest-writer-wins: this device's dims drive the shared PTY size.
|
||||
setClientDims(session, ws, msg.cols, msg.rows)
|
||||
} else if (msg.type === 'blur') {
|
||||
// Tab hidden on this device — withdraw its size vote (still mirrors output).
|
||||
clearClientDims(session, ws)
|
||||
} else if (msg.type === 'approve') {
|
||||
// H3: resolve the held PermissionRequest with allow.
|
||||
resolvePending(boundSessionId, permDecision('allow'))
|
||||
@@ -329,15 +406,23 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
ws.on('close', () => {
|
||||
if (boundSessionId === null) return
|
||||
|
||||
// H3: release any held approval so the hook isn't stuck for the full timeout.
|
||||
resolvePending(boundSessionId, {})
|
||||
|
||||
const session = manager.get(boundSessionId)
|
||||
if (session === undefined) return
|
||||
if (session === undefined) {
|
||||
// Session already gone — make sure no held approval dangles.
|
||||
resolvePending(boundSessionId, {})
|
||||
return
|
||||
}
|
||||
|
||||
// Invariant #2: never kill the PTY on WS close — detach THIS client only
|
||||
// (the PTY stays alive for other devices and for reconnect).
|
||||
detachWs(session, ws, Date.now())
|
||||
|
||||
// H3: only release a held approval when the LAST viewer leaves. With
|
||||
// multi-device sharing (v0.4), another device may still be looking at the
|
||||
// same pending approval — closing one mirror must not cancel it for them.
|
||||
if (session.clients.size === 0) {
|
||||
resolvePending(boundSessionId, {})
|
||||
}
|
||||
})
|
||||
|
||||
// ── WS error ─────────────────────────────────────────────────────────────
|
||||
@@ -361,16 +446,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
|
||||
}
|
||||
|
||||
process.on('SIGINT', onSignal)
|
||||
process.on('SIGTERM', onSignal)
|
||||
|
||||
// ── uncaughtException: log and exit for truly unexpected errors ───────────
|
||||
// (spawn failures / send failures are handled above and must NOT reach here)
|
||||
process.on('uncaughtException', (err: Error) => {
|
||||
const onUncaught = (err: Error): void => {
|
||||
console.error('[server] uncaughtException — exiting:', err)
|
||||
doShutdown()
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGINT', onSignal)
|
||||
process.on('SIGTERM', onSignal)
|
||||
process.on('uncaughtException', onUncaught)
|
||||
|
||||
// Remove the process-level listeners this server registered, so repeated
|
||||
// startServer()/close() cycles (tests, library use) don't leak handlers.
|
||||
function removeProcessListeners(): void {
|
||||
process.off('SIGINT', onSignal)
|
||||
process.off('SIGTERM', onSignal)
|
||||
process.off('uncaughtException', onUncaught)
|
||||
}
|
||||
|
||||
// ── Start listening ───────────────────────────────────────────────────────
|
||||
httpServer.listen(cfg.port, cfg.bindHost, () => {
|
||||
@@ -384,6 +478,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
return {
|
||||
close(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
removeProcessListeners()
|
||||
doShutdown()
|
||||
httpServer.close(() => resolve())
|
||||
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the
|
||||
|
||||
Reference in New Issue
Block a user