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:
1 parent
3e19454bd4
commit
26fe8495b9
4 files changed
+404
-37
No files matched your search
@@ -11,6 +11,16 @@ TOGETHER_API_KEY=
|
|||||||
HUGGINGFACE_TOKEN=
|
HUGGINGFACE_TOKEN=
|
||||||
REPLICATE_API_TOKEN=
|
REPLICATE_API_TOKEN=
|
||||||
|
|
||||||
|
# ── Target authentication (both values are required) ──
|
||||||
|
# Exact origin that may receive the configured headers (scheme + host + optional port).
|
||||||
|
# TEMPEST_TARGET_ORIGIN=https://target.example
|
||||||
|
# JSON object of headers injected into HTTP requests to that exact origin.
|
||||||
|
# Explicit per-request headers override these defaults case-insensitively.
|
||||||
|
# Bearer token: TEMPEST_TARGET_HEADERS={"Authorization":"Bearer eyJhbGc..."}
|
||||||
|
# Cookie: TEMPEST_TARGET_HEADERS={"Cookie":"session=abc123"}
|
||||||
|
# Multiple: TEMPEST_TARGET_HEADERS={"Authorization":"Bearer token","X-Tenant":"acme"}
|
||||||
|
# TEMPEST_TARGET_HEADERS=
|
||||||
|
|
||||||
# ── GitHub (xbow-bench challenge fetch / gh operations) ──
|
# ── GitHub (xbow-bench challenge fetch / gh operations) ──
|
||||||
GITHUB_TOKEN=
|
GITHUB_TOKEN=
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Target Header Injection
|
||||||
|
|
||||||
|
T3MP3ST can apply authentication headers to its HTTP arsenal tools. The headers are bound to one exact origin so credentials are not sent to other authorized targets, subdomains, ports, or redirect destinations.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set both values in `~/.t3mp3st/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEMPEST_TARGET_ORIGIN=https://api.target.example
|
||||||
|
TEMPEST_TARGET_HEADERS={"Authorization":"Bearer token","X-Tenant":"acme"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`TEMPEST_TARGET_ORIGIN` must contain only an `http` or `https` origin: scheme, hostname, and optional port. Paths, query strings, fragments, and embedded credentials are rejected. Origin matching includes the scheme and effective port.
|
||||||
|
|
||||||
|
`TEMPEST_TARGET_HEADERS` must be a non-empty JSON object whose values are strings. Invalid or incomplete configuration fails closed: the request continues without configured headers.
|
||||||
|
|
||||||
|
Transport-level headers such as `Host`, `Content-Length`, `Connection`, `Transfer-Encoding`, and proxy authorization headers are rejected. Authentication headers including `Authorization`, `Cookie`, and application-specific API key headers are supported.
|
||||||
|
|
||||||
|
## Coverage And Overrides
|
||||||
|
|
||||||
|
Configured headers are applied to HTTP requests made by all built-in web probes and to `curl_request` when the request URL matches the configured origin. They are not applied to unrelated origins.
|
||||||
|
|
||||||
|
Headers supplied explicitly by a tool call override configured defaults case-insensitively. For example, `authorization` overrides a configured `Authorization` value without sending duplicates.
|
||||||
|
|
||||||
|
## Redirect Safety
|
||||||
|
|
||||||
|
Built-in requests follow redirects while retaining configured headers only for same-origin hops. Before following a cross-origin redirect, T3MP3ST removes every configured header as well as standard credential headers.
|
||||||
|
|
||||||
|
When configured headers are active, `curl_request` removes `-L`, `--location`, and `--location-trusted` from caller-supplied flags. This prevents custom authentication headers from being forwarded to a redirect destination.
|
||||||
|
|
||||||
|
## Secret Handling
|
||||||
|
|
||||||
|
Configured curl headers are written to a temporary mode `0600` config file rather than command-line arguments, then deleted after execution. This keeps values out of the local process list.
|
||||||
|
|
||||||
|
Before Arsenal records or emits a tool result, configured header values are replaced with `[REDACTED]` in output, errors, findings, and other structured result fields. This also covers targets that reflect a credential in a response.
|
||||||
|
|
||||||
|
Keep `~/.t3mp3st/.env` private and never commit real credentials. Rotate a credential if it appears in logs or evidence created outside the Arsenal execution path.
|
||||||
@@ -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
@@ -7,6 +7,9 @@
|
|||||||
import { EventEmitter } from 'eventemitter3';
|
import { EventEmitter } from 'eventemitter3';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { execFile } from 'child_process';
|
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 { promisify } from 'util';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as dns from 'dns';
|
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
|
// EVENTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -301,7 +413,7 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await tool.handler(context);
|
const result = redactConfiguredSecrets(await tool.handler(context));
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
execution.completedAt = Date.now();
|
execution.completedAt = Date.now();
|
||||||
@@ -312,7 +424,10 @@ export class Arsenal extends EventEmitter<ArsenalEvents> {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
execution.completedAt = Date.now();
|
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 });
|
this.emit('tool:error', { tool, error: error as Error });
|
||||||
|
|
||||||
@@ -748,11 +863,11 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
handler: async (context) => {
|
handler: async (context) => {
|
||||||
const url = context.parameters.url as string;
|
const url = context.parameters.url as string;
|
||||||
const method = (context.parameters.method as string) || 'GET';
|
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;
|
const body = context.parameters.body as string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body && method !== 'GET' ? body : undefined,
|
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',
|
'Permissions-Policy', 'Cross-Origin-Opener-Policy', 'Cross-Origin-Resource-Policy',
|
||||||
];
|
];
|
||||||
try {
|
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 analysis = securityHeaders.map(h => {
|
||||||
const value = response.headers.get(h);
|
const value = response.headers.get(h);
|
||||||
return `${h}: ${value ? `✓ ${value}` : '✗ Missing'}`;
|
return `${h}: ${value ? `✓ ${value}` : '✗ Missing'}`;
|
||||||
@@ -834,7 +952,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
batch.map(async (path) => {
|
batch.map(async (path) => {
|
||||||
const fullUrl = `${baseUrl}/${path}`;
|
const fullUrl = `${baseUrl}/${path}`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(fullUrl, {
|
const response = await targetFetch(fullUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: AbortSignal.timeout(timeout),
|
signal: AbortSignal.timeout(timeout),
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
@@ -895,7 +1013,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
const url = context.parameters.url as string;
|
const url = context.parameters.url as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1022,7 +1140,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
const testUrl = new URL(baseUrl);
|
const testUrl = new URL(baseUrl);
|
||||||
testUrl.searchParams.set(param, payload);
|
testUrl.searchParams.set(param, payload);
|
||||||
|
|
||||||
const response = await fetch(testUrl.toString(), {
|
const response = await targetFetch(testUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
@@ -1099,7 +1217,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
try {
|
try {
|
||||||
const baselineUrl = new URL(baseUrl);
|
const baselineUrl = new URL(baseUrl);
|
||||||
baselineUrl.searchParams.set(param, 'normalvalue');
|
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();
|
const baselineBody = await baselineResp.text();
|
||||||
baselineLength = baselineBody.length;
|
baselineLength = baselineBody.length;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1111,7 +1229,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
const testUrl = new URL(baseUrl);
|
const testUrl = new URL(baseUrl);
|
||||||
testUrl.searchParams.set(param, payload);
|
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 body = await response.text();
|
||||||
const bodyLower = body.toLowerCase();
|
const bodyLower = body.toLowerCase();
|
||||||
|
|
||||||
@@ -1321,7 +1439,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
baselineBody.set(usernameField, username);
|
baselineBody.set(usernameField, username);
|
||||||
baselineBody.set(passwordField, 'definitely_invalid_password_xyz123!@#');
|
baselineBody.set(passwordField, 'definitely_invalid_password_xyz123!@#');
|
||||||
|
|
||||||
const baselineResp = await fetch(url, {
|
const baselineResp = await targetFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: baselineBody.toString(),
|
body: baselineBody.toString(),
|
||||||
@@ -1345,7 +1463,7 @@ export const BUILTIN_TOOLS: CustomTool[] = [
|
|||||||
body.set(usernameField, username);
|
body.set(usernameField, username);
|
||||||
body.set(passwordField, password);
|
body.set(passwordField, password);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
@@ -1605,7 +1723,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const robotsUrl = `${baseUrl}/robots.txt`;
|
const robotsUrl = `${baseUrl}/robots.txt`;
|
||||||
const response = await fetch(robotsUrl, {
|
const response = await targetFetch(robotsUrl, {
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1766,7 +1884,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch main page
|
// Fetch main page
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
const headers = Object.fromEntries(response.headers.entries());
|
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) => {
|
const probePromises = versionPaths.map(async (vp) => {
|
||||||
try {
|
try {
|
||||||
const probeResp = await fetch(`${baseUrl}${vp.path}`, {
|
const probeResp = await targetFetch(`${baseUrl}${vp.path}`, {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
});
|
});
|
||||||
@@ -1994,7 +2112,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
const url = context.parameters.url as string;
|
const url = context.parameters.url as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
@@ -2151,7 +2269,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
batch.map(async (path) => {
|
batch.map(async (path) => {
|
||||||
try {
|
try {
|
||||||
const fullUrl = `${baseUrl}${path}`;
|
const fullUrl = `${baseUrl}${path}`;
|
||||||
const resp = await fetch(fullUrl, {
|
const resp = await targetFetch(fullUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
redirect: 'manual',
|
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
|
// First try OPTIONS to see if Allow header is returned
|
||||||
let optionsAllow: string | null = null;
|
let optionsAllow: string | null = null;
|
||||||
try {
|
try {
|
||||||
const optResp = await fetch(url, {
|
const optResp = await targetFetch(url, {
|
||||||
method: 'OPTIONS',
|
method: 'OPTIONS',
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
@@ -2253,7 +2371,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
method,
|
method,
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
@@ -2332,7 +2450,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
|
|
||||||
for (const test of testOrigins) {
|
for (const test of testOrigins) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Origin': test.origin },
|
headers: { 'Origin': test.origin },
|
||||||
signal: AbortSignal.timeout(5000),
|
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;
|
const url = context.parameters.url as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
redirect: 'manual', // Don't follow redirects to capture Set-Cookie
|
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);
|
const testUrl = new URL(baseUrl);
|
||||||
testUrl.searchParams.set(param, payload);
|
testUrl.searchParams.set(param, payload);
|
||||||
|
|
||||||
const response = await fetch(testUrl.toString(), {
|
const response = await targetFetch(testUrl.toString(), {
|
||||||
redirect: 'manual', // Don't follow redirects
|
redirect: 'manual', // Don't follow redirects
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
@@ -2613,7 +2731,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
try {
|
try {
|
||||||
const baselineUrl = new URL(baseUrl);
|
const baselineUrl = new URL(baseUrl);
|
||||||
baselineUrl.searchParams.set(param, 'nonexistent_file_xyz');
|
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();
|
const baseBody = await baseResp.text();
|
||||||
baselineLength = baseBody.length;
|
baselineLength = baseBody.length;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2625,7 +2743,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
const testUrl = new URL(baseUrl);
|
const testUrl = new URL(baseUrl);
|
||||||
testUrl.searchParams.set(param, payload);
|
testUrl.searchParams.set(param, payload);
|
||||||
|
|
||||||
const response = await fetch(testUrl.toString(), {
|
const response = await targetFetch(testUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
@@ -2727,7 +2845,7 @@ ${issues.length ? `Issues:\n${issues.join('\n')}` : '✓ No obvious issues'}`,
|
|||||||
const testUrl = new URL(baseUrl);
|
const testUrl = new URL(baseUrl);
|
||||||
testUrl.searchParams.set(param, payload);
|
testUrl.searchParams.set(param, payload);
|
||||||
|
|
||||||
const response = await fetch(testUrl.toString(), {
|
const response = await targetFetch(testUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
const body = await response.text();
|
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;
|
const url = context.parameters.url as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await targetFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
@@ -3102,6 +3220,10 @@ function sanitizeExternalFlags(tool: 'curl' | 'nmap', raw?: string): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withoutCurlRedirectFlags(flags: string[]): string[] {
|
||||||
|
return flags.filter(flag => !/^--location(?:-trusted)?(?:=|$)/i.test(flag) && !/^-[^-]*L/.test(flag));
|
||||||
|
}
|
||||||
|
|
||||||
export const EXTERNAL_TOOLS: CustomTool[] = [
|
export const EXTERNAL_TOOLS: CustomTool[] = [
|
||||||
{
|
{
|
||||||
name: 'nmap_scan',
|
name: 'nmap_scan',
|
||||||
@@ -3249,6 +3371,8 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
|
|||||||
const flags = context.parameters.flags as string | undefined;
|
const flags = context.parameters.flags as string | undefined;
|
||||||
|
|
||||||
const args = ['-s', '-i', '-X', method];
|
const args = ['-s', '-i', '-X', method];
|
||||||
|
let configuredHeaders = targetHeadersForUrl(url);
|
||||||
|
let configDir: string | undefined;
|
||||||
if (data) {
|
if (data) {
|
||||||
// A body starting with @ (read local file) or < (read stdin) turns curl into a local-file
|
// 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.
|
// disclosure primitive. --data-raw sends it verbatim and disables @/< interpretation.
|
||||||
@@ -3256,19 +3380,44 @@ export const EXTERNAL_TOOLS: CustomTool[] = [
|
|||||||
args.push('--data-raw', data);
|
args.push('--data-raw', data);
|
||||||
}
|
}
|
||||||
if (headers) {
|
if (headers) {
|
||||||
for (const h of headers.split(',')) {
|
const headerLines = headers.split(',').map(header => header.trim()).filter(Boolean);
|
||||||
args.push('-H', h.trim());
|
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
|
if (flags) {
|
||||||
args.push(url);
|
const sanitized = sanitizeExternalFlags('curl', flags); // drop -o/-K/-T/-x file-read/write injection
|
||||||
|
args.push(...(configuredHeaders ? withoutCurlRedirectFlags(sanitized) : sanitized));
|
||||||
const result = await runSubprocess('curl', args, { timeout: 30000 });
|
}
|
||||||
return {
|
try {
|
||||||
success: result.exitCode === 0,
|
if (configuredHeaders) {
|
||||||
output: result.stdout,
|
configDir = await mkdtemp(join(tmpdir(), 't3mp3st-curl-'));
|
||||||
error: result.exitCode !== 0 ? result.stderr : undefined,
|
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 });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user