diff --git a/src/__tests__/local-api-hardening-static.test.ts b/src/__tests__/local-api-hardening-static.test.ts new file mode 100644 index 0000000..3e14770 --- /dev/null +++ b/src/__tests__/local-api-hardening-static.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8'); + +function routeBlock(startMarker: string, endMarker: string): string { + const start = serverSource.indexOf(startMarker); + expect(start, `missing route marker ${startMarker}`).toBeGreaterThanOrEqual(0); + const end = serverSource.indexOf(endMarker, start); + expect(end, `missing end marker ${endMarker}`).toBeGreaterThan(start); + return serverSource.slice(start, end); +} + +describe('local API authorization hardening invariants', () => { + it('/api/events does not grant wildcard CORS and rejects foreign browser origins before opening SSE', () => { + const route = routeBlock("app.get('/api/events'", '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS'); + + expect(route).not.toMatch(/Access-Control-Allow-Origin['"]\s*:\s*['"]\*['"]/); + expect(route).toMatch(/const\s+origin\s*=\s*_?req\.get\(['"]origin['"]\)/); + expect(route).toMatch(/origin\s*&&\s*!isLoopbackOrigin\(origin\)/); + expect(route).toMatch(/Access-Control-Allow-Origin['"]\]\s*=\s*origin/); + }); + + it('/api/tools/execute binds approval to the parsed command target, not a caller-supplied target override', () => { + const route = routeBlock("app.post('/api/tools/execute'", "app.post('/api/tools/recon'"); + + expect(route).not.toMatch(/body\.target\s*\|\|\s*inferCommandTarget\(parsed\)/); + expect(route).toMatch(/resolveCommandExecutionTarget\(body, parsed\)/); + expect(route).toMatch(/guardAction\(body,\s*['"]command_execution['"],\s*targetResolution\.target/); + }); + + it('Admiral live launch re-checks every General-produced execution target before mission bring-up', () => { + const route = routeBlock("app.post('/api/admiral/launch'", '// =============================================================================\n// BOUNTY PLATFORM INTEGRATIONS'); + + expect(route).toMatch(/ensureExecTargetsWithinApprovedTarget\(execConfig\.targets, brief\.target\)/); + expect(route).toMatch(/outOfScopeTargets\.length/); + expect(route.indexOf('ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target)')) + .toBeLessThan(route.indexOf('bringUpMissionFromPlan(execConfig, generalConfig)')); + }); +}); diff --git a/src/server.ts b/src/server.ts index 4fd8f52..1bcfece 100755 --- a/src/server.ts +++ b/src/server.ts @@ -364,6 +364,37 @@ function inferCommandTarget(parsed: ParsedCommand): string { return positional[positional.length - 1] || 'unknown-network-target'; } +function resolveCommandExecutionTarget( + body: Record, + parsed: ParsedCommand, +): { target: string } | { error: string } { + const inferredTarget = normalizeTargetValue(inferCommandTarget(parsed)); + + // Local-only commands can still carry a UI target for bookkeeping, but networked + // commands must be authorized against the host they will actually contact. A + // caller-supplied body.target is NOT authoritative; at most it may mirror the + // parsed command target. This prevents approving one host and executing a + // whitelisted network command against another. + if (!NETWORK_COMMANDS.has(parsed.bin)) { + return { target: normalizeTargetValue(body.target || inferredTarget) }; + } + + if (!inferredTarget || inferredTarget === 'unknown-network-target') { + return { error: `Could not infer network target for ${parsed.bin}; include the target as a direct command argument.` }; + } + + if (body.target !== undefined) { + const suppliedTarget = normalizeTargetValue(body.target); + if (hostFromTarget(suppliedTarget) !== hostFromTarget(inferredTarget)) { + return { + error: `Command target mismatch: requested approval target "${suppliedTarget}" does not match parsed command target "${inferredTarget}".`, + }; + } + } + + return { target: inferredTarget }; +} + async function executeCommand(command: string, timeout = 30000): Promise { const startTime = Date.now(); const parsed = parseCommand(command); @@ -1066,6 +1097,13 @@ function approvalMatches(approval: ApprovalRequest, action: GuardAction, target: return hostFromTarget(approval.target) === hostFromTarget(target); } +function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget: string): string[] { + const approvedHost = hostFromTarget(normalizeTargetValue(approvedTarget)); + return targets + .map(target => normalizeTargetValue(target)) + .filter(target => hostFromTarget(target) !== approvedHost); +} + function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean { if (!approvalMatches(approval, action, target)) return false; if (approval.operationId && operationId && approval.operationId !== operationId) return false; @@ -4600,17 +4638,28 @@ export function broadcastEvent(event: string, data: Record): vo // single operator uses a handful of dashboard tabs; this only rejects a pathological flood. const MAX_SSE_CLIENTS = 64; app.get('/api/events', (_req: Request, res: Response) => { + const origin = _req.get('origin'); + if (origin && !isLoopbackOrigin(origin)) { + res.status(403).json({ + error: 'Cross-origin event stream rejected', + detail: 'The SSE feed may contain live mission/task/finding metadata and is only available to the localhost UI.', + }); + return; + } if (sseClients.size >= MAX_SSE_CLIENTS) { res.status(503).json({ error: 'Too many event-stream clients', detail: `SSE client cap (${MAX_SSE_CLIENTS}) reached — close an existing dashboard tab and retry.` }); return; } - // SSE headers - res.writeHead(200, { + // SSE headers. Do not use a wildcard ACAO here: browsers can open EventSource + // cross-origin, and the event feed carries live operational metadata. Reflect + // only a trusted loopback Origin; same-origin/no-Origin clients need no CORS header. + const headers: Record = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - }); + }; + if (origin) headers['Access-Control-Allow-Origin'] = origin; + res.writeHead(200, headers); // Send initial heartbeat res.write('event: connected\ndata: {"status":"connected"}\n\n'); @@ -5837,8 +5886,9 @@ app.post('/api/tools/execute', async (req: Request, res: Response): Promise