feat: allow origin-scoped target headers from env (#65)

Enable authenticated scans across the HTTP arsenal while binding configured credentials to an exact target origin.

Includes case-insensitive per-request overrides, cross-origin redirect containment, protected curl configuration, result redaction, documentation, and regression coverage.

Co-authored-by: Fred Blaise <fred.blaise@protonmail.com>
This commit is contained in:
Fred BlaiseandFred Blaise authored and GitHub committed 2026-07-15 23:07:08 -04:00
1 parent 3e19454bd4
commit 26fe8495b9
4 files changed
+404 -37

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Arsenal, BUILTIN_TOOLS, createToolContext } from '../arsenal/index.js';
const httpTool = BUILTIN_TOOLS.find(tool => tool.name === 'http_request');
const technologyTool = BUILTIN_TOOLS.find(tool => tool.name === 'technology_detect');
if (!httpTool || !technologyTool) throw new Error('Required HTTP tools are not registered');
const originalOrigin = process.env.TEMPEST_TARGET_ORIGIN;
const originalHeaders = process.env.TEMPEST_TARGET_HEADERS;
function response(status = 200, headers: Record<string, string> = {}): Response {
const normalized = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
return {
status,
statusText: status === 200 ? 'OK' : 'Found',
headers: {
entries: () => normalized.entries(),
get: (name: string) => normalized.get(name.toLowerCase()) ?? null,
},
text: async () => '<html></html>',
} as unknown as Response;
}
function configure(origin = 'https://target.example') {
process.env.TEMPEST_TARGET_ORIGIN = origin;
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({
Authorization: 'Bearer configured-secret',
'X-Tenant': 'acme',
});
}
beforeEach(() => configure());
afterEach(() => {
if (originalOrigin === undefined) delete process.env.TEMPEST_TARGET_ORIGIN;
else process.env.TEMPEST_TARGET_ORIGIN = originalOrigin;
if (originalHeaders === undefined) delete process.env.TEMPEST_TARGET_HEADERS;
else process.env.TEMPEST_TARGET_HEADERS = originalHeaders;
vi.unstubAllGlobals();
});
describe('target header binding', () => {
it('injects configured headers only into the exact origin', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
await httpTool.handler(createToolContext(undefined, { url: 'https://other.example/api' }));
await httpTool.handler(createToolContext(undefined, { url: 'http://target.example/api' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
expect(new Headers(mockFetch.mock.calls[1][1]?.headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[2][1]?.headers).has('authorization')).toBe(false);
});
it('requires both the origin binding and a valid non-empty string header map', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
delete process.env.TEMPEST_TARGET_ORIGIN;
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
process.env.TEMPEST_TARGET_ORIGIN = 'https://target.example';
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Authorization: 123 });
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
});
it('rejects origins with paths and transport-level headers', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
configure('https://target.example/api');
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
configure();
process.env.TEMPEST_TARGET_HEADERS = JSON.stringify({ Host: 'other.example' });
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/api' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('host')).toBe(false);
});
it('lets explicit tool headers override configured headers case-insensitively', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, {
url: 'https://target.example',
headers: { authorization: 'Bearer request-secret' },
}));
const sent = new Headers(mockFetch.mock.calls[0][1].headers);
expect(sent.get('authorization')).toBe('Bearer request-secret');
expect([...sent.keys()].filter(name => name === 'authorization')).toHaveLength(1);
expect(sent.get('x-tenant')).toBe('acme');
});
it('applies configured headers to built-in probes beyond http_request', async () => {
const mockFetch = vi.fn().mockResolvedValue(response());
vi.stubGlobal('fetch', mockFetch);
await technologyTool.handler(createToolContext(undefined, { url: 'https://target.example' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('authorization')).toBe('Bearer configured-secret');
});
});
describe('target header redirect safety', () => {
it('removes every configured header before a cross-origin redirect', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(response(302, { location: 'https://external.example/landing' }))
.mockResolvedValueOnce(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
expect(new Headers(mockFetch.mock.calls[0][1].headers).get('x-tenant')).toBe('acme');
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('authorization')).toBe(false);
expect(new Headers(mockFetch.mock.calls[1][1].headers).has('x-tenant')).toBe(false);
expect(String(mockFetch.mock.calls[1][0])).toBe('https://external.example/landing');
});
it('keeps configured headers on same-origin redirects', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(response(302, { location: '/next' }))
.mockResolvedValueOnce(response());
vi.stubGlobal('fetch', mockFetch);
await httpTool.handler(createToolContext(undefined, { url: 'https://target.example/start' }));
expect(new Headers(mockFetch.mock.calls[1][1].headers).get('authorization')).toBe('Bearer configured-secret');
});
});
describe('target header secret handling', () => {
it('redacts configured values from persisted tool output and findings', async () => {
const mockFetch = vi.fn().mockResolvedValue(response(200, {
'x-reflected-value': 'Bearer configured-secret',
}));
vi.stubGlobal('fetch', mockFetch);
const arsenal = new Arsenal();
arsenal.register(httpTool);
const result = await arsenal.execute('http_request', createToolContext(undefined, {
url: 'https://target.example',
}));
expect(result.output).toContain('[REDACTED]');
expect(result.output).not.toContain('configured-secret');
expect(arsenal.getExecutions()[0].result?.output).not.toContain('configured-secret');
});
it('keeps configured curl headers out of argv and disables redirect-following flags', () => {
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
const curl = source.slice(source.indexOf("name: 'curl_request'"));
expect(curl).toContain("writeFile(configPath");
expect(curl).toContain("mode: 0o600");
expect(curl).toContain('withoutCurlRedirectFlags');
expect(curl).not.toContain("args.push('-H', `${name}: ${value}`)");
});
it('routes every built-in fetch call through the target-aware helper', () => {
const source = readFileSync(join(process.cwd(), 'src/arsenal/index.ts'), 'utf8');
const builtins = source.slice(source.indexOf('export const BUILTIN_TOOLS'), source.indexOf('export const EXTERNAL_TOOLS'));
expect(builtins).not.toMatch(/\bfetch\s*\(/);
expect(builtins).toContain('targetFetch(');
});
});
+186 -37
View File
@@ -7,6 +7,9 @@
import { EventEmitter } from 'eventemitter3';
import { randomUUID } from 'crypto';
import { execFile } from 'child_process';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import * as net from 'net';
import * as dns from 'dns';
@@ -61,6 +64,115 @@ async function checkPort(host: string, port: number, timeout: number = 2000): Pr
});
}
// =============================================================================
// TARGET HEADER INJECTION
// =============================================================================
interface TargetHeaderConfig {
origin: string;
headers: Headers;
}
const FORBIDDEN_TARGET_HEADERS = new Set([
'connection', 'content-length', 'host', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
/** Parse an exact-origin binding and its default headers. Invalid configuration fails closed. */
function parseTargetHeaderConfig(): TargetHeaderConfig | null {
const rawOrigin = process.env.TEMPEST_TARGET_ORIGIN?.trim();
const rawHeaders = process.env.TEMPEST_TARGET_HEADERS?.trim();
if (!rawOrigin || !rawHeaders) return null;
try {
const target = new URL(rawOrigin);
if (!['http:', 'https:'].includes(target.protocol)
|| target.username || target.password
|| target.pathname !== '/' || target.search || target.hash) {
return null;
}
const parsed: unknown = JSON.parse(rawHeaders);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const headers = new Headers();
for (const [name, value] of Object.entries(parsed)) {
if (typeof value !== 'string' || FORBIDDEN_TARGET_HEADERS.has(name.toLowerCase())) return null;
headers.set(name, value);
}
return headers.keys().next().done ? null : { origin: target.origin, headers };
} catch {
return null;
}
}
function targetHeadersForUrl(url: string | URL, explicit?: RequestInit['headers']): Headers | undefined {
const merged = new Headers();
const config = parseTargetHeaderConfig();
try {
if (config && new URL(url).origin === config.origin) {
config.headers.forEach((value, name) => merged.set(name, value));
}
} catch {
// Let fetch or curl report malformed URLs; they simply receive no configured secrets.
}
new Headers(explicit).forEach((value, name) => merged.set(name, value));
return merged.keys().next().done ? undefined : merged;
}
function redactConfiguredSecrets(result: ToolResult): ToolResult {
const config = parseTargetHeaderConfig();
const secrets = config ? [...config.headers.values()].filter(Boolean) : [];
if (secrets.length === 0) return result;
const redact = (value: unknown): unknown => {
if (typeof value === 'string') {
return secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), value);
}
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, redact(nested)]));
}
return value;
};
return redact(result) as ToolResult;
}
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const CROSS_ORIGIN_CREDENTIAL_HEADERS = ['authorization', 'cookie', 'cookie2', 'proxy-authorization'];
/** Apply exact-origin headers to every built-in fetch without leaking them across redirects. */
async function targetFetch(url: string | URL, init: RequestInit = {}): Promise<Response> {
const config = parseTargetHeaderConfig();
let currentUrl = new URL(url);
if (!config || currentUrl.origin !== config.origin) return globalThis.fetch(url, init);
const explicitHeaders = new Headers(init.headers);
let method = (init.method || 'GET').toUpperCase();
let body = init.body;
for (let redirects = 0; redirects <= 20; redirects++) {
const headers = targetHeadersForUrl(currentUrl, explicitHeaders);
const response = await globalThis.fetch(currentUrl, { ...init, method, body, headers, redirect: 'manual' });
const location = response.headers.get('location');
if (!REDIRECT_STATUS.has(response.status) || !location) return response;
if (redirects === 20) throw new Error('Target request exceeded 20 redirects');
const nextUrl = new URL(location, currentUrl);
if (nextUrl.origin !== currentUrl.origin) {
config.headers.forEach((_value, name) => explicitHeaders.delete(name));
CROSS_ORIGIN_CREDENTIAL_HEADERS.forEach(name => explicitHeaders.delete(name));
}
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
method = 'GET';
body = undefined;
['content-encoding', 'content-language', 'content-location', 'content-type'].forEach(name => explicitHeaders.delete(name));
}
currentUrl = nextUrl;
}
throw new Error('Target request redirect handling failed');
}
// =============================================================================
// EVENTS
// =============================================================================
@@ -301,7 +413,7 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
const startTime = Date.now();
try {
const result = await tool.handler(context);
const result = redactConfiguredSecrets(await tool.handler(context));
const durationMs = Date.now() - startTime;
execution.completedAt = Date.now();
@@ -312,7 +424,10 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
return result;
} catch (error) {
execution.completedAt = Date.now();
execution.error = error instanceof Error ? error.message : String(error);
execution.error = redactConfiguredSecrets({
success: false,
error: error instanceof Error ? error.message : String(error),
}).error;
this.emit('tool:error', { tool, error: error as Error });
@@ -748,11 +863,11 @@ export const BUILTIN_TOOLS: CustomTool[] = [
handler: async (context) => {
const url = context.parameters.url as string;
const method = (context.parameters.method as string) || 'GET';
const headers = (context.parameters.headers as Record<string, string>) || {};
const headers = context.parameters.headers as Record<string, string> | undefined;
const body = context.parameters.body as string | undefined;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method,
headers,
body: body && method !== 'GET' ? body : undefined,
@@ -787,7 +902,10 @@ export const BUILTIN_TOOLS: CustomTool[] = [
'Permissions-Policy', 'Cross-Origin-Opener-Policy', 'Cross-Origin-Resource-Policy',
];
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
const response = await targetFetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(10000),
});
const analysis = securityHeaders.map(h => {
const value = response.headers.get(h);
return `${h}: ${value ? `✓ ${value}` : '✗ Missing'}`;
@@ -834,7 +952,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
batch.map(async (path) => {
const fullUrl = `${baseUrl}/${path}`;
try {
const response = await fetch(fullUrl, {
const response = await targetFetch(fullUrl, {
method: 'GET',
signal: AbortSignal.timeout(timeout),
redirect: 'manual',
@@ -895,7 +1013,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
});
@@ -1022,7 +1140,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -1099,7 +1217,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
try {
const baselineUrl = new URL(baseUrl);
baselineUrl.searchParams.set(param, 'normalvalue');
const baselineResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baselineResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baselineBody = await baselineResp.text();
baselineLength = baselineBody.length;
} catch {
@@ -1111,7 +1229,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
const response = await targetFetch(testUrl.toString(), { signal: AbortSignal.timeout(5000) });
const body = await response.text();
const bodyLower = body.toLowerCase();
@@ -1321,7 +1439,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
baselineBody.set(usernameField, username);
baselineBody.set(passwordField, 'definitely_invalid_password_xyz123!@#');
const baselineResp = await fetch(url, {
const baselineResp = await targetFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: baselineBody.toString(),
@@ -1345,7 +1463,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
body.set(usernameField, username);
body.set(passwordField, password);
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
@@ -1605,7 +1723,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
const robotsUrl = `${baseUrl}/robots.txt`;
const response = await fetch(robotsUrl, {
const response = await targetFetch(robotsUrl, {
signal: AbortSignal.timeout(10000),
});
@@ -1766,7 +1884,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
// Fetch main page
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
});
const headers = Object.fromEntries(response.headers.entries());
@@ -1840,7 +1958,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const probePromises = versionPaths.map(async (vp) => {
try {
const probeResp = await fetch(`${baseUrl}${vp.path}`, {
const probeResp = await targetFetch(`${baseUrl}${vp.path}`, {
signal: AbortSignal.timeout(5000),
redirect: 'manual',
});
@@ -1994,7 +2112,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000),
});
@@ -2151,7 +2269,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
batch.map(async (path) => {
try {
const fullUrl = `${baseUrl}${path}`;
const resp = await fetch(fullUrl, {
const resp = await targetFetch(fullUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
redirect: 'manual',
@@ -2231,7 +2349,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
// First try OPTIONS to see if Allow header is returned
let optionsAllow: string | null = null;
try {
const optResp = await fetch(url, {
const optResp = await targetFetch(url, {
method: 'OPTIONS',
signal: AbortSignal.timeout(5000),
});
@@ -2253,7 +2371,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
}
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method,
signal: AbortSignal.timeout(5000),
redirect: 'manual',
@@ -2332,7 +2450,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
for (const test of testOrigins) {
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
headers: { 'Origin': test.origin },
signal: AbortSignal.timeout(5000),
@@ -2403,7 +2521,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
signal: AbortSignal.timeout(10000),
redirect: 'manual', // Don't follow redirects to capture Set-Cookie
});
@@ -2521,7 +2639,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
redirect: 'manual', // Don't follow redirects
signal: AbortSignal.timeout(5000),
});
@@ -2613,7 +2731,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
try {
const baselineUrl = new URL(baseUrl);
baselineUrl.searchParams.set(param, 'nonexistent_file_xyz');
const baseResp = await fetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baseResp = await targetFetch(baselineUrl.toString(), { signal: AbortSignal.timeout(5000) });
const baseBody = await baseResp.text();
baselineLength = baseBody.length;
} catch {
@@ -2625,7 +2743,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -2727,7 +2845,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const testUrl = new URL(baseUrl);
testUrl.searchParams.set(param, payload);
const response = await fetch(testUrl.toString(), {
const response = await targetFetch(testUrl.toString(), {
signal: AbortSignal.timeout(5000),
});
const body = await response.text();
@@ -2803,7 +2921,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
const url = context.parameters.url as string;
try {
const response = await fetch(url, {
const response = await targetFetch(url, {
method: 'GET',
signal: AbortSignal.timeout(10000),
});
@@ -3102,6 +3220,10 @@ function sanitizeExternalFlags(tool: 'curl' | 'nmap', raw?: string): string[] {
return out;
}
function withoutCurlRedirectFlags(flags: string[]): string[] {
return flags.filter(flag => !/^--location(?:-trusted)?(?:=|$)/i.test(flag) && !/^-[^-]*L/.test(flag));
}
export const EXTERNAL_TOOLS: CustomTool[] = [
{
name: 'nmap_scan',
@@ -3249,6 +3371,8 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
const flags = context.parameters.flags as string | undefined;
const args = ['-s', '-i', '-X', method];
let configuredHeaders = targetHeadersForUrl(url);
let configDir: string | undefined;
if (data) {
// A body starting with @ (read local file) or < (read stdin) turns curl into a local-file
// disclosure primitive. --data-raw sends it verbatim and disables @/< interpretation.
@@ -3256,19 +3380,44 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
args.push('--data-raw', data);
}
if (headers) {
for (const h of headers.split(',')) {
args.push('-H', h.trim());
const headerLines = headers.split(',').map(header => header.trim()).filter(Boolean);
if (configuredHeaders) {
const explicitHeaders = new Headers();
for (const header of headerLines) {
const separator = header.indexOf(':');
if (separator <= 0) return { success: false, error: `curl_request: invalid header '${header}'` };
explicitHeaders.set(header.slice(0, separator).trim(), header.slice(separator + 1).trim());
}
configuredHeaders = targetHeadersForUrl(url, explicitHeaders);
} else {
for (const header of headerLines) args.push('-H', header);
}
}
if (flags) args.push(...sanitizeExternalFlags('curl', flags)); // drop -o/-K/-T/-x file-read/write injection
args.push(url);
const result = await runSubprocess('curl', args, { timeout: 30000 });
return {
success: result.exitCode === 0,
output: result.stdout,
error: result.exitCode !== 0 ? result.stderr : undefined,
};
if (flags) {
const sanitized = sanitizeExternalFlags('curl', flags); // drop -o/-K/-T/-x file-read/write injection
args.push(...(configuredHeaders ? withoutCurlRedirectFlags(sanitized) : sanitized));
}
try {
if (configuredHeaders) {
configDir = await mkdtemp(join(tmpdir(), 't3mp3st-curl-'));
const configPath = join(configDir, 'headers.conf');
const escapeConfig = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const config = [...configuredHeaders.entries()]
.map(([name, value]) => `header = "${escapeConfig(`${name}: ${value}`)}"`)
.join('\n');
await writeFile(configPath, `${config}\n`, { mode: 0o600 });
args.push('--config', configPath);
}
args.push(url);
const result = await runSubprocess('curl', args, { timeout: 30000 });
return redactConfiguredSecrets({
success: result.exitCode === 0,
output: result.stdout,
error: result.exitCode !== 0 ? result.stderr : undefined,
});
} finally {
if (configDir) await rm(configDir, { recursive: true, force: true });
}
},
},
];