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:
@@ -26,10 +26,16 @@ const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
|
||||
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
|
||||
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
|
||||
const DEFAULT_WS_PATH = '/term'
|
||||
const DEFAULT_MAX_SESSIONS = 50
|
||||
const DEFAULT_MAX_MSGS_PER_SEC = 2000
|
||||
const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min
|
||||
const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
|
||||
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function parsePositiveInt(
|
||||
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
|
||||
function parseNonNegativeInt(
|
||||
raw: string | undefined,
|
||||
label: string,
|
||||
fallback: number,
|
||||
@@ -77,6 +83,16 @@ function parseIdleTtl(raw: string | undefined): number {
|
||||
* - Deduplicate.
|
||||
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
|
||||
*/
|
||||
/** True iff `value` parses as a URL with an http: or https: scheme (M1). */
|
||||
function isHttpOrigin(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
|
||||
const set = new Set<string>()
|
||||
|
||||
@@ -104,11 +120,14 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings)
|
||||
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings).
|
||||
// Reject anything that is not a well-formed http(s) origin so an exotic
|
||||
// scheme (file:, data:, javascript:) can't slip into the whitelist (M1).
|
||||
if (extraEnv) {
|
||||
for (const raw of extraEnv.split(',')) {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed) set.add(trimmed)
|
||||
if (trimmed === '') continue
|
||||
if (isHttpOrigin(trimmed)) set.add(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +155,13 @@ export function loadConfig(env: EnvLike): Config {
|
||||
|
||||
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
|
||||
|
||||
const scrollbackBytes = parsePositiveInt(
|
||||
const scrollbackBytes = parseNonNegativeInt(
|
||||
env['SCROLLBACK_BYTES'],
|
||||
'SCROLLBACK_BYTES',
|
||||
DEFAULT_SCROLLBACK_BYTES,
|
||||
)
|
||||
|
||||
const maxPayloadBytes = parsePositiveInt(
|
||||
const maxPayloadBytes = parseNonNegativeInt(
|
||||
env['MAX_PAYLOAD_BYTES'],
|
||||
'MAX_PAYLOAD_BYTES',
|
||||
DEFAULT_MAX_PAYLOAD_BYTES,
|
||||
@@ -150,6 +169,28 @@ export function loadConfig(env: EnvLike): Config {
|
||||
|
||||
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
|
||||
|
||||
const maxSessions = parseNonNegativeInt(env['MAX_SESSIONS'], 'MAX_SESSIONS', DEFAULT_MAX_SESSIONS)
|
||||
|
||||
const maxMsgsPerSec = parseNonNegativeInt(
|
||||
env['MAX_MSGS_PER_SEC'],
|
||||
'MAX_MSGS_PER_SEC',
|
||||
DEFAULT_MAX_MSGS_PER_SEC,
|
||||
)
|
||||
|
||||
const permTimeoutMs = parseNonNegativeInt(
|
||||
env['PERM_TIMEOUT_MS'],
|
||||
'PERM_TIMEOUT_MS',
|
||||
DEFAULT_PERM_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
const reapIntervalMs = parseNonNegativeInt(
|
||||
env['REAP_INTERVAL_MS'],
|
||||
'REAP_INTERVAL_MS',
|
||||
DEFAULT_REAP_INTERVAL_MS,
|
||||
)
|
||||
|
||||
const previewBytes = parseNonNegativeInt(env['PREVIEW_BYTES'], 'PREVIEW_BYTES', DEFAULT_PREVIEW_BYTES)
|
||||
|
||||
const useTmux = resolveUseTmux(env['USE_TMUX'])
|
||||
|
||||
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
|
||||
@@ -163,6 +204,11 @@ export function loadConfig(env: EnvLike): Config {
|
||||
scrollbackBytes,
|
||||
maxPayloadBytes,
|
||||
wsPath,
|
||||
maxSessions,
|
||||
maxMsgsPerSec,
|
||||
permTimeoutMs,
|
||||
reapIntervalMs,
|
||||
previewBytes,
|
||||
useTmux,
|
||||
allowedOrigins,
|
||||
} satisfies Config)
|
||||
|
||||
Reference in New Issue
Block a user