From 25f86b0c66a9bb3d94f0636ee5f24efd6df4a9b6 Mon Sep 17 00:00:00 2001 From: Eddy Date: Thu, 16 Jul 2026 00:12:35 +0200 Subject: [PATCH] fix: reject curl destination override flags * fix: reject curl destination override flags * fix: block additional curl target bypass forms --- .../local-api-hardening-static.test.ts | 33 +++++++- src/server.ts | 82 +++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/__tests__/local-api-hardening-static.test.ts b/src/__tests__/local-api-hardening-static.test.ts index 3e14770..6eb68a2 100644 --- a/src/__tests__/local-api-hardening-static.test.ts +++ b/src/__tests__/local-api-hardening-static.test.ts @@ -4,14 +4,18 @@ import { join } from 'path'; const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8'); -function routeBlock(startMarker: string, endMarker: string): string { +function sourceBlock(startMarker: string, endMarker: string): string { const start = serverSource.indexOf(startMarker); - expect(start, `missing route marker ${startMarker}`).toBeGreaterThanOrEqual(0); + expect(start, `missing source marker ${startMarker}`).toBeGreaterThanOrEqual(0); const end = serverSource.indexOf(endMarker, start); expect(end, `missing end marker ${endMarker}`).toBeGreaterThan(start); return serverSource.slice(start, end); } +function routeBlock(startMarker: string, endMarker: string): string { + return sourceBlock(startMarker, endMarker); +} + 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'); @@ -30,6 +34,31 @@ describe('local API authorization hardening invariants', () => { expect(route).toMatch(/guardAction\(body,\s*['"]command_execution['"],\s*targetResolution\.target/); }); + it('/api/tools/execute rejects curl flags that change the effective destination', () => { + const parser = sourceBlock('const CURL_TRANSPORT_OVERRIDE_FLAGS', 'function inferCommandTarget'); + + for (const flag of [ + '--resolve', + '--connect-to', + '--proxy', + '--preproxy', + '--socks5', + '--socks5-hostname', + '--unix-socket', + '--interface', + '--url', + '--config', + '--next', + '-K', + ]) { + expect(parser).toContain(`'${flag}'`); + } + expect(parser).toMatch(/findCurlTransportOverrideFlag\(args\)/); + expect(parser).toMatch(/countCurlUrlOperands\(args\)\s*>\s*1/); + expect(parser).toMatch(/multiple URL operands/); + expect(parser).toMatch(/changes the effective network destination/); + }); + 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'); diff --git a/src/server.ts b/src/server.ts index 01b5fb2..a29b32e 100755 --- a/src/server.ts +++ b/src/server.ts @@ -344,6 +344,79 @@ interface ParsedCommand { const SHELL_META = /[|&;$<>`\\]/; const COMMAND_CONTROL = /[\x00-\x1F\x7F-\x9F\u2028\u2029]/; +const CURL_TRANSPORT_OVERRIDE_FLAGS = new Set([ + '--resolve', + '--connect-to', + '--proxy', + '--preproxy', + '--socks4', + '--socks4a', + '--socks5', + '--socks5-hostname', + '--unix-socket', + '--abstract-unix-socket', + '--interface', + '--url', + '--config', + '--next', + '-x', + '-K', +]); +const CURL_VALUE_FLAGS = new Set([ + '-A', '--user-agent', + '-b', '--cookie', + '-c', '--cookie-jar', + '-d', '--data', '--data-ascii', '--data-binary', '--data-raw', '--data-urlencode', + '-F', '--form', + '-H', '--header', + '-m', '--max-time', + '-o', '--output', + '-T', '--upload-file', + '-u', '--user', + '-X', '--request', + '--cacert', '--cert', '--connect-timeout', '--key', '--request-target', '--retry', +]); + +function findCurlTransportOverrideFlag(args: string[]): string | undefined { + for (const arg of args) { + if (!arg) continue; + const flag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; + if (CURL_TRANSPORT_OVERRIDE_FLAGS.has(flag)) return flag; + if (arg.startsWith('-x') && arg !== '-X' && arg.length > 2) return '-x'; + if (arg.startsWith('-K') && arg.length > 2) return '-K'; + } + return undefined; +} + +function looksLikeCurlUrlOperand(arg: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\/\S+/i.test(arg) + || /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i.test(arg) + || /^(?:localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?(?:[/?#].*)?$/i.test(arg) + || /^\[[0-9a-f:]+\](?::\d+)?(?:[/?#].*)?$/i.test(arg); +} + +function countCurlUrlOperands(args: string[]): number { + let count = 0; + let skipNext = false; + let endOfOptions = false; + for (const arg of args) { + if (!arg) continue; + if (skipNext) { skipNext = false; continue; } + if (!endOfOptions && arg === '--') { endOfOptions = true; continue; } + if (!endOfOptions && arg.startsWith('--')) { + const hasInlineValue = arg.includes('='); + const flag = hasInlineValue ? arg.slice(0, arg.indexOf('=')) : arg; + if (!hasInlineValue && CURL_VALUE_FLAGS.has(flag)) skipNext = true; + continue; + } + if (!endOfOptions && arg.startsWith('-')) { + if (arg.length === 2 && CURL_VALUE_FLAGS.has(arg)) skipNext = true; + continue; + } + if (looksLikeCurlUrlOperand(arg)) count += 1; + } + return count; +} function parseCommand(command: string): ParsedCommand | { error: string } { if (SHELL_META.test(command) || COMMAND_CONTROL.test(command)) return { error: 'Shell control characters are not allowed; use direct argv-style commands only.' }; @@ -355,6 +428,15 @@ function parseCommand(command: string): ParsedCommand | { error: string } { if (adapter?.execution === 'catalog_only' || adapter?.execution === 'import_only') { return { error: `Tool is catalog-only and cannot be executed directly: ${bin}` }; } + if (bin === 'curl') { + const overrideFlag = findCurlTransportOverrideFlag(args); + if (overrideFlag) { + return { error: `curl flag ${overrideFlag} changes the effective network destination and is not allowed through /api/tools/execute.` }; + } + if (countCurlUrlOperands(args) > 1) { + return { error: 'curl commands with multiple URL operands are not allowed through /api/tools/execute; submit one transfer per approved target.' }; + } + } return { bin, args }; }