fix: tighten local API execution guards

This commit is contained in:
Eddy committed 2026-07-05 12:07:02 +02:00
1 parent bd2463e0c3
commit ec4616c42e
2 files changed
+106 -6

No files matched your search

@@ -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)'));
});
});
+65 -6
View File
@@ -364,6 +364,37 @@ function inferCommandTarget(parsed: ParsedCommand): string {
return positional[positional.length - 1] || 'unknown-network-target'; return positional[positional.length - 1] || 'unknown-network-target';
} }
function resolveCommandExecutionTarget(
body: Record<string, unknown>,
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<ToolResult> { async function executeCommand(command: string, timeout = 30000): Promise<ToolResult> {
const startTime = Date.now(); const startTime = Date.now();
const parsed = parseCommand(command); const parsed = parseCommand(command);
@@ -1066,6 +1097,13 @@ function approvalMatches(approval: ApprovalRequest, action: GuardAction, target:
return hostFromTarget(approval.target) === hostFromTarget(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 { function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean {
if (!approvalMatches(approval, action, target)) return false; if (!approvalMatches(approval, action, target)) return false;
if (approval.operationId && operationId && approval.operationId !== operationId) return false; if (approval.operationId && operationId && approval.operationId !== operationId) return false;
@@ -4600,17 +4638,28 @@ export function broadcastEvent(event: string, data: Record<string, unknown>): vo
// single operator uses a handful of dashboard tabs; this only rejects a pathological flood. // single operator uses a handful of dashboard tabs; this only rejects a pathological flood.
const MAX_SSE_CLIENTS = 64; const MAX_SSE_CLIENTS = 64;
app.get('/api/events', (_req: Request, res: Response) => { 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) { 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.` }); 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; return;
} }
// SSE headers // SSE headers. Do not use a wildcard ACAO here: browsers can open EventSource
res.writeHead(200, { // 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<string, string> = {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*', };
}); if (origin) headers['Access-Control-Allow-Origin'] = origin;
res.writeHead(200, headers);
// Send initial heartbeat // Send initial heartbeat
res.write('event: connected\ndata: {"status":"connected"}\n\n'); res.write('event: connected\ndata: {"status":"connected"}\n\n');
@@ -5837,8 +5886,9 @@ app.post('/api/tools/execute', async (req: Request, res: Response): Promise<void
if (!command) { res.status(400).json({ error: 'Command required' }); return; } if (!command) { res.status(400).json({ error: 'Command required' }); return; }
const parsed = parseCommand(command); const parsed = parseCommand(command);
if ('error' in parsed) { res.status(400).json({ error: parsed.error }); return; } if ('error' in parsed) { res.status(400).json({ error: parsed.error }); return; }
const target = normalizeTargetValue(body.target || inferCommandTarget(parsed)); const targetResolution = resolveCommandExecutionTarget(body, parsed);
const guard = guardAction(body, 'command_execution', target, `Run ${parsed.bin} against ${target}`); if ('error' in targetResolution) { res.status(400).json({ error: targetResolution.error }); return; }
const guard = guardAction(body, 'command_execution', targetResolution.target, `Run ${parsed.bin} against ${targetResolution.target}`);
if (!guard.allowed) { blockForApproval(res, guard); return; } if (!guard.allowed) { blockForApproval(res, guard); return; }
const result = await executeCommand(command, timeout); const result = await executeCommand(command, timeout);
res.json(result); res.json(result);
@@ -7191,6 +7241,15 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<voi
res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review }); res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review });
return; return;
} }
const outOfScopeTargets = ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target);
if (outOfScopeTargets.length) {
res.status(403).json({
error: 'Admiral LIVE plan contains targets outside the approved brief target',
approvedTarget: brief.target,
outOfScopeTargets,
});
return;
}
const broughtUp = bringUpMissionFromPlan(execConfig, generalConfig); const broughtUp = bringUpMissionFromPlan(execConfig, generalConfig);
res.json({ res.json({
mode: 'live', plan, review: execConfig.review, mode: 'live', plan, review: execConfig.review,