From 6c871fa838b997b999971a733d2c3f3dd4134e82 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 16 Sep 2026 13:12:54 +0530 Subject: [PATCH 1/6] feat: block private outbound MCP and model-provider URLs --- .changeset/ssrf-outbound-url-guard.md | 6 ++ charts/trueforge/README.md | 4 + charts/trueforge/templates/_helpers.tpl | 7 ++ charts/trueforge/values.yaml | 5 ++ packages/trueforge-core/src/core/index.ts | 1 + .../src/core/llm/VercelAILLM.ts | 7 ++ .../src/core/mcp/remoteMcpClient.ts | 14 +++- .../trueforge-core/src/core/util/ssrfGuard.ts | 79 +++++++++++++++++++ .../tests/core/mcp/connectRemoteMcp.test.ts | 20 ++++- .../tests/core/util/ssrfGuard.test.ts | 36 +++++++++ packages/trueforge/.env.example | 4 + packages/trueforge/src/apis/mcpServers.ts | 22 +++++- packages/trueforge/src/apis/modelProviders.ts | 13 +++ packages/trueforge/src/config.ts | 6 ++ packages/trueforge/src/main.ts | 6 +- packages/trueforge/src/mcp/auth/mcpDcr.ts | 5 +- .../trueforge/src/routes/mcpServerRoutes.ts | 6 +- .../src/routes/modelProviderRoutes.ts | 4 +- .../tests/unit/apis/mcpOAuth.test.ts | 9 +++ .../tests/unit/apis/mcpServers.test.ts | 39 +++++++++ .../tests/unit/apis/modelProviders.test.ts | 57 ++++++++++++- .../tests/unit/mcp/auth/mcpOAuth.test.ts | 9 +++ .../unit/runtime/getMcpConnection.test.ts | 9 +++ 23 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 .changeset/ssrf-outbound-url-guard.md create mode 100644 packages/trueforge-core/src/core/util/ssrfGuard.ts create mode 100644 packages/trueforge-core/tests/core/util/ssrfGuard.test.ts diff --git a/.changeset/ssrf-outbound-url-guard.md b/.changeset/ssrf-outbound-url-guard.md new file mode 100644 index 000000000..05ba9c55b --- /dev/null +++ b/.changeset/ssrf-outbound-url-guard.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-core": patch +"@truefoundry/trueforge": patch +--- + +Block RFC1918, link-local, and loopback destinations on outbound MCP and model-provider HTTP, with optional host allow/block lists. diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index c9721851e..1ecaab1b5 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -268,6 +268,10 @@ configs: # scopes: "openid,profile,email,groups" # Optional email allowlist (exact + * globs). Empty = unrestricted. # allowedEmails: "alice@acme.com,*@partner.com" + outboundUrl: + # Optional. Empty = deny private/loopback/link-local MCP and model-provider URLs. + # allowPrivateHosts: "llm-gateway.internal,localhost" + # blockedHosts: "" ``` ## Using Secrets diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 5e79e2332..b854f02c3 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -448,6 +448,13 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- end -}} {{- end -}} +{{- if .Values.configs.outboundUrl.allowPrivateHosts -}} +{{- $env = append $env (dict "name" "OUTBOUND_URL_ALLOW_PRIVATE_HOSTS" "value" .Values.configs.outboundUrl.allowPrivateHosts) -}} +{{- end -}} +{{- if .Values.configs.outboundUrl.blockedHosts -}} +{{- $env = append $env (dict "name" "OUTBOUND_URL_BLOCKED_HOSTS" "value" .Values.configs.outboundUrl.blockedHosts) -}} +{{- end -}} + {{- /* Controller -> server auth. The app rejects an empty value when peered. */ -}} {{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "TRUEFORGE_API_KEY" "field" "apiKey" "value" .Values.apiKey) | fromJson) -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index e0ac6f8d8..aaa754e08 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -132,6 +132,11 @@ configs: # Optional comma-separated exact emails and/or * globs (e.g. "*@company.com"). # Empty = unrestricted. Matched against the ID token `email` claim. allowedEmails: "" + outboundUrl: + # Comma-separated hosts allowed even if private. Empty = none. Env: OUTBOUND_URL_ALLOW_PRIVATE_HOSTS. + allowPrivateHosts: "" + # Comma-separated hosts always blocked. Empty = none. Env: OUTBOUND_URL_BLOCKED_HOSTS. + blockedHosts: "" # Bitnami relocated its versioned container images to docker.io/bitnamilegacy and # left only rolling `latest` tags under docker.io/bitnami, so the subchart image # defaults no longer pull. We source pinned legacy images from the TrueFoundry diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 76cafcf45..48c19e262 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -136,6 +136,7 @@ export { AgentHarnessError, McpConnectionError, McpDcrConfigurationError } from export { REDIS_KEY_NAMESPACE, redisKey } from './redisKeys'; export { describeUnknownError, extractErrorLogFields } from './util/errorLogFields'; export { PromiseTimeoutError, withTimeout } from './util/promiseUtils'; +export { assertSafeOutboundUrl, configureOutboundUrlGuard, ssrfFetch } from './util/ssrfGuard'; // Sandbox (concrete implementation; provider details exported for composition) export { CodeModeDispatcher } from './sandbox/codeMode/CodeModeDispatcher'; diff --git a/packages/trueforge-core/src/core/llm/VercelAILLM.ts b/packages/trueforge-core/src/core/llm/VercelAILLM.ts index da7e1a3b6..b7eff25a2 100644 --- a/packages/trueforge-core/src/core/llm/VercelAILLM.ts +++ b/packages/trueforge-core/src/core/llm/VercelAILLM.ts @@ -35,6 +35,7 @@ import type { } from 'openai/resources/chat'; import type { Logger } from 'winston'; import { describeUnknownError, extractErrorLogFields } from '../util/errorLogFields'; +import { ssrfFetch } from '../util/ssrfGuard'; import type { ILLM, LLMCreateParams, LLMCreateParamsStreaming } from './ILLM'; import { type CompletionUsage, @@ -135,6 +136,7 @@ function compatibleModel(config: VercelAIProviderConfig): LanguageModel { name: provider.type, baseURL: baseUrl, apiKey, + fetch: ssrfFetch, // Without this the adapter silently downgrades json_schema to a schema-less json_object. supportsStructuredOutputs: true, // These endpoints omit token counts from streamed responses unless asked. @@ -152,6 +154,7 @@ export function buildLanguageModel(config: VercelAIProviderConfig): LanguageMode case 'openai': { const client = createOpenAI({ apiKey, + fetch: ssrfFetch, ...(baseUrl !== undefined ? { baseURL: baseUrl } : {}), ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}), }); @@ -160,6 +163,7 @@ export function buildLanguageModel(config: VercelAIProviderConfig): LanguageMode case 'anthropic': { const client = createAnthropic({ apiKey, + fetch: ssrfFetch, ...(baseUrl !== undefined ? { baseURL: baseUrl } : {}), ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}), }); @@ -168,6 +172,7 @@ export function buildLanguageModel(config: VercelAIProviderConfig): LanguageMode case 'google-gemini': { const client = createGoogle({ apiKey, + fetch: ssrfFetch, ...(baseUrl !== undefined ? { baseURL: baseUrl } : {}), ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}), }); @@ -176,6 +181,7 @@ export function buildLanguageModel(config: VercelAIProviderConfig): LanguageMode case 'moonshot': { const client = createMoonshotAI({ apiKey, + fetch: ssrfFetch, ...(baseUrl !== undefined ? { baseURL: baseUrl } : {}), ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}), }); @@ -189,6 +195,7 @@ export function buildLanguageModel(config: VercelAIProviderConfig): LanguageMode const client = createAlibaba({ apiKey, baseURL: baseUrl, + fetch: ssrfFetch, ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}), }); return client(model.id); diff --git a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts index b76f75ac6..f4f8c6895 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -7,6 +7,7 @@ import type { CallToolRequest, CallToolResult } from '@modelcontextprotocol/sdk/ import { context, propagation } from '@opentelemetry/api'; import { McpConnectionError } from '../errors'; import { withTimeout } from '../util/promiseUtils'; +import { assertSafeOutboundUrl, ssrfFetch } from '../util/ssrfGuard'; import type { ToolSchema } from './IMCPServer'; /** Networking for remote (url-based) MCP servers, kept separate so it can be mocked in tests. */ @@ -50,10 +51,14 @@ function createTransport( ): McpTransport { const requestInit = { headers }; if (type === 'streamable-http') { - return new StreamableHTTPClientTransport(url, { requestInit, ...(sessionId !== undefined ? { sessionId } : {}) }); + return new StreamableHTTPClientTransport(url, { + requestInit, + fetch: ssrfFetch, + ...(sessionId !== undefined ? { sessionId } : {}), + }); } // eslint-disable-next-line @typescript-eslint/no-deprecated -- dual-transport probe; see TRANSPORT_PROBE_ORDER - return new SSEClientTransport(url, { requestInit }); + return new SSEClientTransport(url, { requestInit, fetch: ssrfFetch }); } export function isSessionExpiredError(error: unknown): boolean { @@ -147,6 +152,11 @@ export async function connectRemoteMcp(params: { onClose?: (() => void) | undefined; onError?: ((error: Error) => void) | undefined; }): Promise { + try { + await assertSafeOutboundUrl(params.url); + } catch (error) { + throw new McpConnectionError(error instanceof Error ? error.message : String(error), 400, { cause: error }); + } const url = new URL(params.url); const requestOptions = { signal: params.signal }; const candidates = params.knownTransportType diff --git a/packages/trueforge-core/src/core/util/ssrfGuard.ts b/packages/trueforge-core/src/core/util/ssrfGuard.ts new file mode 100644 index 000000000..04f83c3fd --- /dev/null +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -0,0 +1,79 @@ +import { lookup } from 'node:dns/promises'; +import { BlockList, isIP } from 'node:net'; + +let allowedHosts: string[] = []; +let blockedHosts: string[] = []; + +const privateNets = new BlockList(); +privateNets.addSubnet('0.0.0.0', 8, 'ipv4'); +privateNets.addSubnet('10.0.0.0', 8, 'ipv4'); +privateNets.addSubnet('127.0.0.0', 8, 'ipv4'); +privateNets.addSubnet('169.254.0.0', 16, 'ipv4'); +privateNets.addSubnet('172.16.0.0', 12, 'ipv4'); +privateNets.addSubnet('192.168.0.0', 16, 'ipv4'); +privateNets.addSubnet('::', 128, 'ipv6'); +privateNets.addSubnet('::1', 128, 'ipv6'); +privateNets.addSubnet('fc00::', 7, 'ipv6'); +privateNets.addSubnet('fe80::', 10, 'ipv6'); + +export function configureOutboundUrlGuard(config: { + allowedHosts: readonly string[]; + blockedHosts: readonly string[]; +}): void { + allowedHosts = config.allowedHosts.map(normalizeHost); + blockedHosts = config.blockedHosts.map(normalizeHost); +} + +function normalizeHost(hostname: string): string { + return hostname.replace(/\.$/, '').toLowerCase(); +} + +function isPrivateIp(address: string): boolean { + const ip = address.replace(/^::ffff:/i, ''); + if (isIP(ip) === 4) { + return privateNets.check(ip, 'ipv4'); + } + if (isIP(ip) === 6) { + return privateNets.check(ip, 'ipv6'); + } + return true; +} + +function deny(host: string, cause?: unknown): never { + throw new Error(`Outbound URL blocked for host "${host}"`, { cause }); +} + +export async function assertSafeOutboundUrl(input: string | URL | Request): Promise { + let url: URL; + try { + url = new URL(input instanceof Request ? input.url : input); + } catch (error) { + throw new Error('Outbound URL blocked', { cause: error }); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Outbound URL blocked: only http and https are allowed'); + } + + const host = normalizeHost(url.hostname); + if (host === '' || blockedHosts.includes(host)) { + deny(host); + } + if (allowedHosts.includes(host)) { + return; + } + + let addresses: string[]; + try { + addresses = isIP(host) !== 0 ? [host] : (await lookup(host, { all: true })).map(record => record.address); + } catch (error) { + deny(host, error); + } + if (addresses.some(isPrivateIp)) { + deny(host); + } +} + +export async function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise { + await assertSafeOutboundUrl(input); + return fetch(input, init); +} diff --git a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts index 4a74c4d7e..278d4cf28 100644 --- a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts +++ b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts @@ -59,7 +59,7 @@ jest.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({ })); const baseParams = () => ({ - url: 'https://mcp.example.com/mcp', + url: 'https://93.184.216.34/mcp', headers: {}, requestTimeoutMs: 60_000, connectTimeoutMs: 5_000, @@ -138,3 +138,21 @@ describe('connectRemoteMcp transport selection', () => { expect(mockConnectAttempts).toEqual(['streamable-http']); }); }); + +describe('connectRemoteMcp SSRF guard', () => { + beforeEach(() => { + mockConnectAttempts.length = 0; + }); + + it('rejects loopback and link-local URLs before opening a transport', async () => { + await expect(connectRemoteMcp({ ...baseParams(), url: 'http://127.0.0.1:6379' })).rejects.toMatchObject({ + constructor: McpConnectionError, + statusCode: 400, + }); + await expect(connectRemoteMcp({ ...baseParams(), url: 'http://169.254.169.254/' })).rejects.toMatchObject({ + constructor: McpConnectionError, + statusCode: 400, + }); + expect(mockConnectAttempts).toEqual([]); + }); +}); diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts new file mode 100644 index 000000000..77f54294a --- /dev/null +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -0,0 +1,36 @@ +import { assertSafeOutboundUrl, configureOutboundUrlGuard, ssrfFetch } from '../../../src/core/util/ssrfGuard'; + +afterEach(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); +}); + +describe('assertSafeOutboundUrl', () => { + it('rejects private, loopback, and link-local literals', async () => { + await expect(assertSafeOutboundUrl('http://10.0.0.1/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://192.168.1.1/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://127.0.0.1:6379/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://[::1]/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://[::ffff:127.0.0.1]/')).rejects.toThrow(/blocked/); + }); + + it('rejects non-http(s) and allows a public IPv4 literal', async () => { + await expect(assertSafeOutboundUrl('file:///etc/passwd')).rejects.toThrow(/http and https/); + await expect(assertSafeOutboundUrl('https://93.184.216.34/')).resolves.toBeUndefined(); + }); + + it('honors allow and block lists', async () => { + configureOutboundUrlGuard({ allowedHosts: ['localhost'], blockedHosts: ['93.184.216.34'] }); + await expect(assertSafeOutboundUrl('http://localhost:11434/v1')).resolves.toBeUndefined(); + await expect(assertSafeOutboundUrl('https://93.184.216.34/')).rejects.toThrow(/blocked/); + }); +}); + +describe('ssrfFetch', () => { + it('does not call fetch for a blocked URL', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch'); + await expect(ssrfFetch('http://169.254.169.254/')).rejects.toThrow(/blocked/); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 2950d0de0..40075f28b 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -150,6 +150,10 @@ TRUEFORGE_API_KEY=placeholder-value-please-generate-your-own ## Max ms for an MCP transport connection. Default 30000. # MCP_CONNECT_TIMEOUT_MS=30000 +## Hosts allowed even if private (local Ollama / MCP). Empty = deny private/loopback/link-local. +# OUTBOUND_URL_ALLOW_PRIVATE_HOSTS=localhost,127.0.0.1 +# OUTBOUND_URL_BLOCKED_HOSTS= + ## --------------------------------------------------------------------------- ## Sandbox process knobs. ## diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index d2a612746..ae3c7da66 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -1,5 +1,11 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; -import { extractErrorLogFields, isAuthRequired, McpConnectionError, RemoteMCP } from '@truefoundry/trueforge-core/core'; +import { + assertSafeOutboundUrl, + extractErrorLogFields, + isAuthRequired, + McpConnectionError, + RemoteMCP, +} from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Logger } from 'winston'; @@ -191,6 +197,13 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const requestContext = deps.resolveRequestContext(c); const incomingManifest = body.manifest; + try { + await assertSafeOutboundUrl(incomingManifest.url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Outbound URL blocked'; + return c.json({ error: { message } }, 400); + } + // DCR finishes before the txn (remote I/O stays out of withTransaction on create). let dcrClientToSave: OAuthClientRecord | undefined; if (incomingManifest.auth?.type === 'dcr') { @@ -264,6 +277,13 @@ export function createSettingsMcpServersRouter(deps: McpServersRou const body: UpdateMcpServerRequest = c.req.valid('json'); const incomingManifest = body.manifest; + try { + await assertSafeOutboundUrl(incomingManifest.url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Outbound URL blocked'; + return c.json({ error: { message } }, 400); + } + try { // Lock → resolve secrets → DCR (if needed) → upsert + saveClient in one txn. // Exception: MCP put may run `createMcpOAuthClient` inside the write txn that persists diff --git a/packages/trueforge/src/apis/modelProviders.ts b/packages/trueforge/src/apis/modelProviders.ts index d73e6bb83..619921645 100644 --- a/packages/trueforge/src/apis/modelProviders.ts +++ b/packages/trueforge/src/apis/modelProviders.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; +import { assertSafeOutboundUrl } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; import type { ResolveRequestContext } from '../auth/identity'; import { @@ -78,6 +79,12 @@ export function createModelProvidersRouter(deps: ModelProvidersRou const requestContext = deps.resolveRequestContext(c); const provider = body.manifest; const name = modelProviderName(provider); + try { + await assertSafeOutboundUrl(provider.base_url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Outbound URL blocked'; + return c.json({ error: { message } }, 400); + } try { // Create has no prior row; redacted keep resolves to MissingStoredSecretError → 400. const manifest = resolveModelProviderManifestForWrite({ incoming: provider, existing: undefined }); @@ -104,6 +111,12 @@ export function createModelProvidersRouter(deps: ModelProvidersRou const requestContext = deps.resolveRequestContext(c); const provider = body.manifest; const name = modelProviderName(provider); + try { + await assertSafeOutboundUrl(provider.base_url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Outbound URL blocked'; + return c.json({ error: { message } }, 400); + } try { // Lock → resolve secret from that snapshot → upsert, all in one txn so concurrent keep // cannot re-write a secret over a rotate that committed in between. diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 51c109608..80758e2b8 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -571,6 +571,10 @@ export interface SharedServerConfiguration { * `TRUEFORGE_MTLS_ENABLED` is true. Env: `TRUEFORGE_MTLS_CERTS_DIR`. Default `/etc/tls`. */ TRUEFORGE_MTLS_CERTS_DIR: string; + /** Hosts that may be private. Env: `OUTBOUND_URL_ALLOW_PRIVATE_HOSTS`. Empty = none. */ + OUTBOUND_URL_ALLOW_PRIVATE_HOSTS: string[]; + /** Hosts always blocked. Env: `OUTBOUND_URL_BLOCKED_HOSTS`. Empty = none. */ + OUTBOUND_URL_BLOCKED_HOSTS: string[]; } export type StandaloneServerConfiguration = SharedServerConfiguration & { @@ -801,6 +805,8 @@ const shared: SharedServerConfiguration = { defaultValue: false, }), TRUEFORGE_MTLS_CERTS_DIR: getEnv('TRUEFORGE_MTLS_CERTS_DIR', { defaultValue: '/etc/tls' }) ?? '/etc/tls', + OUTBOUND_URL_ALLOW_PRIVATE_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_ALLOW_PRIVATE_HOSTS')), + OUTBOUND_URL_BLOCKED_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_BLOCKED_HOSTS')), }; const configuration: ServerConfiguration = standalone diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 62b786166..985852f53 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -9,7 +9,7 @@ * (migrate, Redis, listen) are caught below and exit non-zero. SQLite vs * Postgres store modules stay dynamic so only the active engine is loaded. */ -import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; +import { configureOutboundUrlGuard, extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; @@ -36,6 +36,10 @@ try { getPublicUiBasePath, TrueForgeAuthMode, } = await import('./config')); + configureOutboundUrlGuard({ + allowedHosts: configuration.OUTBOUND_URL_ALLOW_PRIVATE_HOSTS, + blockedHosts: configuration.OUTBOUND_URL_BLOCKED_HOSTS, + }); } catch (error) { console.error( 'Failed to start server: Failed to load configuration:', diff --git a/packages/trueforge/src/mcp/auth/mcpDcr.ts b/packages/trueforge/src/mcp/auth/mcpDcr.ts index e754ae4d7..f11d24c36 100644 --- a/packages/trueforge/src/mcp/auth/mcpDcr.ts +++ b/packages/trueforge/src/mcp/auth/mcpDcr.ts @@ -13,7 +13,7 @@ import type { OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { McpConnectionError, McpDcrConfigurationError } from '@truefoundry/trueforge-core/core'; +import { assertSafeOutboundUrl, McpConnectionError, McpDcrConfigurationError } from '@truefoundry/trueforge-core/core'; import { randomBytes } from 'node:crypto'; import { isOAuthAccessTokenUsable, @@ -40,7 +40,8 @@ export const MCP_OAUTH_HTTP_TIMEOUT_MS = 15_000; * Used by discoverOAuthServerInfo / registerClient / refreshAuthorization / exchangeAuthorization * (startAuthorization is local PKCE + URL construction and never calls this). */ -const mcpOAuthFetch: FetchLike = (url, init) => { +const mcpOAuthFetch: FetchLike = async (url, init) => { + await assertSafeOutboundUrl(url); const timeoutSignal = AbortSignal.timeout(MCP_OAUTH_HTTP_TIMEOUT_MS); const signal = init?.signal != null ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal; return fetch(url, { ...init, signal }); diff --git a/packages/trueforge/src/routes/mcpServerRoutes.ts b/packages/trueforge/src/routes/mcpServerRoutes.ts index 534e4b5a3..bd3b47d3d 100644 --- a/packages/trueforge/src/routes/mcpServerRoutes.ts +++ b/packages/trueforge/src/routes/mcpServerRoutes.ts @@ -135,7 +135,8 @@ export const createMcpServerRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Invalid request body, or redacted header secret with no stored value to keep.', + description: + 'Invalid request body, blocked outbound URL, or redacted header secret with no stored value to keep.', }, 409: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -174,7 +175,8 @@ export const putMcpServerRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Invalid request body, or redacted header secret with no stored value to keep.', + description: + 'Invalid request body, blocked outbound URL, or redacted header secret with no stored value to keep.', }, 422: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/src/routes/modelProviderRoutes.ts b/packages/trueforge/src/routes/modelProviderRoutes.ts index c0b8058a4..e0a5a2c0f 100644 --- a/packages/trueforge/src/routes/modelProviderRoutes.ts +++ b/packages/trueforge/src/routes/modelProviderRoutes.ts @@ -60,7 +60,7 @@ export const createModelProviderRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Invalid request body, or redacted API key with no stored secret to keep.', + description: 'Invalid request body, blocked outbound URL, or redacted API key with no stored secret to keep.', }, 409: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -96,7 +96,7 @@ export const putModelProviderRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Invalid request body, or redacted API key with no stored secret to keep.', + description: 'Invalid request body, blocked outbound URL, or redacted API key with no stored secret to keep.', }, 424: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts index 58b09090a..fcc639101 100644 --- a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts @@ -2,6 +2,7 @@ * Authorize + OAuth callback against real sqlite stores with fetch stubbed * (same pattern as the server MCP OAuth helper tests). */ +import { configureOutboundUrlGuard } from '@truefoundry/trueforge-core/core'; import winston from 'winston'; import { createMcpOAuthRouter } from '../../../src/apis/mcpOAuth'; import { createMcpServersRouter, createSettingsMcpServersRouter } from '../../../src/apis/mcpServers'; @@ -84,6 +85,10 @@ describe('MCP OAuth authorize + callback', () => { let logger: ReturnType; beforeAll(async () => { + configureOutboundUrlGuard({ + allowedHosts: ['mcp.example.com', 'auth.example.com'], + blockedHosts: [], + }); const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); tokenStore = new SqliteOAuthTokenStore(db); @@ -123,6 +128,10 @@ describe('MCP OAuth authorize + callback', () => { globalThis.fetch = realFetch; }); + afterAll(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); + }); + /** Registers a dcr server and authorizes it, returning the pending authorization's `state`. */ async function pendingState(name: string, returnTo?: string): Promise { const put = await settingsRouter.request('/', { diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index 06e61a03e..7976c9df5 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -1,3 +1,4 @@ +import { configureOutboundUrlGuard } from '@truefoundry/trueforge-core/core'; import winston from 'winston'; import { createCatalogRouter } from '../../../src/apis/catalog'; import { createMcpServersRouter, createSettingsMcpServersRouter } from '../../../src/apis/mcpServers'; @@ -92,6 +93,18 @@ describe('mcp-servers routers', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { + configureOutboundUrlGuard({ + allowedHosts: [ + 'mcp.deepwiki.com', + 'mcp.linear.app', + 'mcp.example.com', + 'auth.example.com', + 'auth-failure.example.com', + 'mcp-failure.example.com', + 'evil.example.com', + ], + blockedHosts: [], + }); // Eager DCR dials the authorization server. Fail that outbound call fast so hermetic tests // without an OAuth mock hit the "DCR before write" path and must not create rows. globalThis.fetch = (async () => { @@ -154,6 +167,7 @@ describe('mcp-servers routers', () => { } afterAll(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); globalThis.fetch = originalFetch; }); @@ -202,6 +216,31 @@ describe('mcp-servers routers', () => { }); }); + it('PUT and POST reject private outbound URLs', async () => { + const blocked = { + type: 'remote' as const, + name: 'ssrf-mcp', + url: 'http://169.254.169.254/mcp', + description: 'Blocked.', + }; + const put = await settingsRouter.request('/', putInit(wrapManifest(blocked))); + expect(put.status).toBe(400); + expect(await put.json()).toEqual({ + error: { message: 'Outbound URL blocked for host "169.254.169.254"' }, + }); + + const post = await settingsRouter.request( + '/', + postInit(wrapManifest({ ...blocked, name: 'ssrf-mcp-post', url: 'http://127.0.0.1/mcp' })), + ); + expect(post.status).toBe(400); + expect(await post.json()).toEqual({ + error: { message: 'Outbound URL blocked for host "127.0.0.1"' }, + }); + expect((await settingsRouter.request('/ssrf-mcp')).status).toBe(404); + expect((await settingsRouter.request('/ssrf-mcp-post')).status).toBe(404); + }); + it('GET /{name} returns the configured server and 404s unknowns', async () => { const response = await settingsRouter.request(`/${putBody.name}`); expect(response.status).toBe(200); diff --git a/packages/trueforge/tests/unit/apis/modelProviders.test.ts b/packages/trueforge/tests/unit/apis/modelProviders.test.ts index 3948ef86f..ed725b0cd 100644 --- a/packages/trueforge/tests/unit/apis/modelProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/modelProviders.test.ts @@ -1,3 +1,4 @@ +import { configureOutboundUrlGuard } from '@truefoundry/trueforge-core/core'; import winston from 'winston'; import { createCatalogRouter } from '../../../src/apis/catalog'; import { createModelsRouter } from '../../../src/apis/models'; @@ -77,6 +78,29 @@ function postInit(manifest: unknown): RequestInit { }; } +beforeAll(() => { + configureOutboundUrlGuard({ + allowedHosts: [ + 'api.openai.com', + 'api.anthropic.com', + 'generativelanguage.googleapis.com', + 'api.fireworks.ai', + 'api.z.ai', + 'api.moonshot.ai', + 'api.together.xyz', + 'dashscope-intl.aliyuncs.com', + 'llm.internal.example.com', + 'gateway.internal.example.com', + 'llm.eu.example.com', + ], + blockedHosts: [], + }); +}); + +afterAll(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); +}); + function withRedactedApiKey(provider: T): T { return { ...provider, @@ -227,7 +251,7 @@ describe('custom providers may omit auth', () => { const base = { type: 'custom' as const, name, - base_url: 'http://localhost:11434/v1', + base_url: 'http://93.184.216.34:11434/v1', models: [model], }; const body = auth === undefined ? base : { ...base, auth }; @@ -249,7 +273,7 @@ describe('custom providers may omit auth', () => { putInit({ type: 'custom', name: 'llama-empty-key', - base_url: 'http://localhost:11434/v1', + base_url: 'http://93.184.216.34:11434/v1', auth: { api_key: '' }, models: [model], }), @@ -264,13 +288,40 @@ describe('custom providers may omit auth', () => { putInit({ type: 'custom', name: 'llama-empty-auth', - base_url: 'http://localhost:11434/v1', + base_url: 'http://93.184.216.34:11434/v1', auth: {}, models: [model], }), ); expect(put.status).toBe(400); }); + + it('PUT and POST reject private outbound URLs', async () => { + const { settingsRouter } = await createRouters(); + const blocked = { + type: 'custom' as const, + name: 'ssrf-provider', + base_url: 'http://169.254.169.254/v1', + models: [model], + }; + const put = await settingsRouter.request('/model-providers', putInit(blocked)); + expect(put.status).toBe(400); + expect(await put.json()).toEqual({ + error: { message: 'Outbound URL blocked for host "169.254.169.254"' }, + }); + + const post = await settingsRouter.request( + '/model-providers', + postInit({ ...blocked, name: 'ssrf-provider-post', base_url: 'http://127.0.0.1:11434/v1' }), + ); + expect(post.status).toBe(400); + expect(await post.json()).toEqual({ + error: { message: 'Outbound URL blocked for host "127.0.0.1"' }, + }); + const list = await settingsRouter.request('/model-providers'); + expect(list.status).toBe(200); + expect(await list.json()).toEqual({ data: [] }); + }); }); describe('well-known types are limited to one provider', () => { diff --git a/packages/trueforge/tests/unit/mcp/auth/mcpOAuth.test.ts b/packages/trueforge/tests/unit/mcp/auth/mcpOAuth.test.ts index 8d9fbe7fe..f1f8701be 100644 --- a/packages/trueforge/tests/unit/mcp/auth/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/mcp/auth/mcpOAuth.test.ts @@ -3,6 +3,7 @@ * Global fetch is stubbed; production code uses real fetch only. */ import { resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk/shared/auth-utils.js'; +import { configureOutboundUrlGuard } from '@truefoundry/trueforge-core/core'; import { InMemoryOAuthClientStore, InMemoryOAuthTokenStore } from '../../../../src/mcp/auth/inMemoryStores'; import { buildMcpAuthorizationUrl, @@ -34,7 +35,15 @@ const USER_REF = 'user-a'; const realFetch = globalThis.fetch; +beforeEach(() => { + configureOutboundUrlGuard({ + allowedHosts: ['mcp.example.com', 'auth.example.com'], + blockedHosts: [], + }); +}); + afterEach(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); globalThis.fetch = realFetch; }); diff --git a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts b/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts index 12307fa0f..e8c1eec5f 100644 --- a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts +++ b/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts @@ -1,3 +1,4 @@ +import { configureOutboundUrlGuard } from '@truefoundry/trueforge-core/core'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import type { IMcpServerWithAuthStore } from '../../../src/db/mcpServerStore'; @@ -14,6 +15,10 @@ describe('getMcpConnection', () => { let tokenStore: SqliteOAuthTokenStore; beforeAll(async () => { + configureOutboundUrlGuard({ + allowedHosts: ['auth.example.com', 'mcp.oauth.example'], + blockedHosts: [], + }); db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); tokenStore = new SqliteOAuthTokenStore(db); @@ -259,4 +264,8 @@ describe('getMcpConnection', () => { }), ).resolves.toBeUndefined(); }); + + afterAll(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); + }); }); From 2342c30b5a00afba4a5ed67c50affadb0d8d7830 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 16 Sep 2026 13:46:07 +0530 Subject: [PATCH 2/6] comment addressed --- .changeset/ssrf-outbound-url-guard.md | 2 +- packages/trueforge-core/package.json | 1 + .../src/core/mcp/remoteMcpClient.ts | 7 +- .../trueforge-core/src/core/util/ssrfGuard.ts | 235 +++++++++++++-- .../tests/core/mcp/connectRemoteMcp.test.ts | 18 -- .../tests/core/util/ssrfGuard.test.ts | 74 ++++- packages/trueforge/src/mcp/auth/mcpDcr.ts | 5 +- .../trueforge/src/routes/mcpServerRoutes.ts | 6 +- .../src/routes/modelProviderRoutes.ts | 4 +- .../truefoundry/listAgentSkillsPaging.test.ts | 10 +- pnpm-lock.yaml | 280 +----------------- 11 files changed, 308 insertions(+), 334 deletions(-) diff --git a/.changeset/ssrf-outbound-url-guard.md b/.changeset/ssrf-outbound-url-guard.md index 05ba9c55b..f1706baf2 100644 --- a/.changeset/ssrf-outbound-url-guard.md +++ b/.changeset/ssrf-outbound-url-guard.md @@ -3,4 +3,4 @@ "@truefoundry/trueforge": patch --- -Block RFC1918, link-local, and loopback destinations on outbound MCP and model-provider HTTP, with optional host allow/block lists. +Block RFC1918, CGNAT, reserved, link-local, and loopback destinations on outbound MCP and model-provider HTTP, plus in-cluster hostnames, with optional host allow/block lists. diff --git a/packages/trueforge-core/package.json b/packages/trueforge-core/package.json index d3218950e..60e4d0559 100644 --- a/packages/trueforge-core/package.json +++ b/packages/trueforge-core/package.json @@ -117,6 +117,7 @@ "dedent": "^1.7.2", "openai": "^7.5.0", "ulid": "^3.0.2", + "undici": "7.29.0", "winston": "^3.19.0", "ws": "^8.21.3", "zod": "^4.4.3" diff --git a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts index f4f8c6895..e75897956 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -7,7 +7,7 @@ import type { CallToolRequest, CallToolResult } from '@modelcontextprotocol/sdk/ import { context, propagation } from '@opentelemetry/api'; import { McpConnectionError } from '../errors'; import { withTimeout } from '../util/promiseUtils'; -import { assertSafeOutboundUrl, ssrfFetch } from '../util/ssrfGuard'; +import { ssrfFetch } from '../util/ssrfGuard'; import type { ToolSchema } from './IMCPServer'; /** Networking for remote (url-based) MCP servers, kept separate so it can be mocked in tests. */ @@ -152,11 +152,6 @@ export async function connectRemoteMcp(params: { onClose?: (() => void) | undefined; onError?: ((error: Error) => void) | undefined; }): Promise { - try { - await assertSafeOutboundUrl(params.url); - } catch (error) { - throw new McpConnectionError(error instanceof Error ? error.message : String(error), 400, { cause: error }); - } const url = new URL(params.url); const requestOptions = { signal: params.signal }; const candidates = params.knownTransportType diff --git a/packages/trueforge-core/src/core/util/ssrfGuard.ts b/packages/trueforge-core/src/core/util/ssrfGuard.ts index 04f83c3fd..9540af48c 100644 --- a/packages/trueforge-core/src/core/util/ssrfGuard.ts +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -1,20 +1,77 @@ -import { lookup } from 'node:dns/promises'; -import { BlockList, isIP } from 'node:net'; +import type { LookupAllOptions, LookupOptions } from 'node:dns'; +import { lookup as dnsLookup } from 'node:dns'; +import { lookup as dnsLookupAsync } from 'node:dns/promises'; +import { BlockList, isIP, type LookupFunction } from 'node:net'; +import { Agent } from 'undici'; let allowedHosts: string[] = []; let blockedHosts: string[] = []; +const URL_VERIFY = { + allowedProtocols: ['http:', 'https:'], + denyCidrsV4: [ + '0.0.0.0/8', // this host + '10.0.0.0/8', // private + '100.64.0.0/10', // CGNAT (EKS secondary pod CIDRs) + '127.0.0.0/8', // loopback + '169.254.0.0/16', // link-local + metadata + '172.16.0.0/12', // private (docker, k8s service CIDRs) + '192.0.0.0/24', // IETF protocol assignments + '192.0.2.0/24', // TEST-NET-1 + '192.88.99.0/24', // 6to4 relay anycast + '192.168.0.0/16', // private + '198.18.0.0/15', // benchmarking + '198.51.100.0/24', // TEST-NET-2 + '203.0.113.0/24', // TEST-NET-3 + '224.0.0.0/4', // multicast + '240.0.0.0/4', // reserved + broadcast + ], + denyCidrsV6: [ + '::/96', // unspecified, ::1, IPv4-compatible + '64:ff9b::/96', // NAT64 well-known + '64:ff9b:1::/48', // NAT64 local-use + '100::/64', // discard-only + '2001::/32', // Teredo + '2001:10::/28', // ORCHID + '2001:20::/28', // ORCHIDv2 + '2001:db8::/32', // documentation + '2002::/16', // 6to4 + 'fc00::/7', // unique-local (IPv6 k8s service CIDRs) + 'fe80::/10', // link-local + 'ff00::/8', // multicast + ], + denyHostsExact: ['localhost', 'metadata', 'instance-data', 'metadata.google.internal'], + denyHostSuffixes: [ + '.local', + '.localhost', + '.localdomain', + '.internal', + '.svc', + '.cluster', + '.arpa', + '.lan', + '.intranet', + '.corp', + '.home', + '.test', + '.invalid', + '.example', + ], +}; + const privateNets = new BlockList(); -privateNets.addSubnet('0.0.0.0', 8, 'ipv4'); -privateNets.addSubnet('10.0.0.0', 8, 'ipv4'); -privateNets.addSubnet('127.0.0.0', 8, 'ipv4'); -privateNets.addSubnet('169.254.0.0', 16, 'ipv4'); -privateNets.addSubnet('172.16.0.0', 12, 'ipv4'); -privateNets.addSubnet('192.168.0.0', 16, 'ipv4'); -privateNets.addSubnet('::', 128, 'ipv6'); -privateNets.addSubnet('::1', 128, 'ipv6'); -privateNets.addSubnet('fc00::', 7, 'ipv6'); -privateNets.addSubnet('fe80::', 10, 'ipv6'); +function addDenyCidrs(cidrs: readonly string[], family: 'ipv4' | 'ipv6'): void { + for (const cidr of cidrs) { + const slash = cidr.lastIndexOf('/'); + privateNets.addSubnet(cidr.slice(0, slash), Number(cidr.slice(slash + 1)), family); + } +} +addDenyCidrs(URL_VERIFY.denyCidrsV4, 'ipv4'); +addDenyCidrs(URL_VERIFY.denyCidrsV6, 'ipv6'); + +const MAX_REDIRECTS = 20; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const CROSS_ORIGIN_STRIPPED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'host']; export function configureOutboundUrlGuard(config: { allowedHosts: readonly string[]; @@ -39,32 +96,96 @@ function isPrivateIp(address: string): boolean { return true; } +function blockedError(host: string, cause?: unknown): Error { + return new Error(`Outbound URL blocked for host "${host}"`, { cause }); +} + function deny(host: string, cause?: unknown): never { - throw new Error(`Outbound URL blocked for host "${host}"`, { cause }); + throw blockedError(host, cause); } -export async function assertSafeOutboundUrl(input: string | URL | Request): Promise { +/** Block/allow lists, k8s hostname shapes, IP literals. Hostname DNS is classified in `guardedLookup`. */ +function assertHost(host: string): void { + if (host === '' || blockedHosts.includes(host)) { + deny(host); + } + if (allowedHosts.includes(host)) { + return; + } + if (isIP(host) === 0) { + if ( + !host.includes('.') || + URL_VERIFY.denyHostsExact.includes(host) || + URL_VERIFY.denyHostSuffixes.some(suffix => host.endsWith(suffix)) + ) { + deny(host); + } + return; + } + if (isPrivateIp(host)) { + deny(host); + } +} + +function parseOutboundUrl(input: string | URL | Request): URL { let url: URL; try { url = new URL(input instanceof Request ? input.url : input); } catch (error) { throw new Error('Outbound URL blocked', { cause: error }); } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { + if (!URL_VERIFY.allowedProtocols.includes(url.protocol)) { throw new Error('Outbound URL blocked: only http and https are allowed'); } + return url; +} - const host = normalizeHost(url.hostname); - if (host === '' || blockedHosts.includes(host)) { - deny(host); +/** + * undici runs this as the socket lookup, so the addresses we allow are the ones connected to. + */ +const guardedLookup: LookupFunction = (hostname, options: LookupOptions, callback) => { + const host = normalizeHost(hostname); + try { + assertHost(host); + } catch (error) { + callback(error instanceof Error ? error : blockedError(host, error), ''); + return; } - if (allowedHosts.includes(host)) { + if (allowedHosts.includes(host) || isIP(host) !== 0) { + dnsLookup(hostname, options, callback); return; } + const allOptions: LookupAllOptions = { ...options, all: true }; + dnsLookup(hostname, allOptions, (err, addresses) => { + if (err) { + callback(err, ''); + return; + } + const first = addresses[0]; + if (first === undefined || addresses.some(record => isPrivateIp(record.address))) { + callback(blockedError(host), ''); + return; + } + if (options.all) { + callback(null, addresses); + return; + } + callback(null, first.address, first.family); + }); +}; + +const outboundAgent = new Agent({ connect: { lookup: guardedLookup } }); +export async function assertSafeOutboundUrl(input: string | URL | Request): Promise { + const url = parseOutboundUrl(input); + const host = normalizeHost(url.hostname); + assertHost(host); + if (allowedHosts.includes(host) || isIP(host) !== 0) { + return; + } let addresses: string[]; try { - addresses = isIP(host) !== 0 ? [host] : (await lookup(host, { all: true })).map(record => record.address); + addresses = (await dnsLookupAsync(host, { all: true })).map(record => record.address); } catch (error) { deny(host, error); } @@ -73,7 +194,77 @@ export async function assertSafeOutboundUrl(input: string | URL | Request): Prom } } +function nextHop( + response: Response, + location: string, + current: URL, + input: string | URL | Request, + init: RequestInit, +): { url: URL; init: RequestInit } { + let nextUrl: URL; + try { + nextUrl = new URL(location, current); + } catch (error) { + throw new Error('Outbound URL blocked', { cause: error }); + } + if (!URL_VERIFY.allowedProtocols.includes(nextUrl.protocol)) { + throw new Error('Outbound URL blocked: only http and https are allowed'); + } + + const request = input instanceof Request ? input : undefined; + const headers = new Headers(init.headers ?? request?.headers); + let method = (init.method ?? request?.method ?? 'GET').toUpperCase(); + let body = init.body ?? null; + const downgradesToGet = + ((response.status === 301 || response.status === 302) && method === 'POST') || + (response.status === 303 && method !== 'GET' && method !== 'HEAD'); + if (downgradesToGet) { + method = 'GET'; + body = null; + headers.delete('content-encoding'); + headers.delete('content-language'); + headers.delete('content-location'); + headers.delete('content-type'); + headers.delete('content-length'); + } + if (nextUrl.origin !== current.origin) { + for (const header of CROSS_ORIGIN_STRIPPED_HEADERS) { + headers.delete(header); + } + } + return { url: nextUrl, init: { ...init, method, headers, body } }; +} + +async function guardedFetch(input: string | URL | Request, init: RequestInit, hopsLeft: number): Promise { + const url = parseOutboundUrl(input); + assertHost(normalizeHost(url.hostname)); + const redirect = init.redirect ?? 'follow'; + const followsRedirects = redirect === 'follow'; + const requestInit: RequestInit = { + ...init, + redirect: followsRedirects ? 'manual' : redirect, + }; + // Agent vs undici-types Dispatcher: attach at runtime so fetch still uses this lookup. + Object.assign(requestInit, { dispatcher: outboundAgent }); + const response = await fetch(url.href, requestInit); + if (!followsRedirects) { + return response; + } + if (!REDIRECT_STATUSES.has(response.status)) { + return response; + } + const location = response.headers.get('location'); + if (location === null) { + return response; + } + void response.body?.cancel().catch(() => undefined); + if (hopsLeft === 0) { + throw new Error('Outbound URL blocked: too many redirects'); + } + const hop = nextHop(response, location, url, input, init); + return guardedFetch(hop.url, hop.init, hopsLeft - 1); +} + export async function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise { - await assertSafeOutboundUrl(input); - return fetch(input, init); + return guardedFetch(input, init ?? {}, MAX_REDIRECTS); } diff --git a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts index 278d4cf28..033d811c0 100644 --- a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts +++ b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts @@ -138,21 +138,3 @@ describe('connectRemoteMcp transport selection', () => { expect(mockConnectAttempts).toEqual(['streamable-http']); }); }); - -describe('connectRemoteMcp SSRF guard', () => { - beforeEach(() => { - mockConnectAttempts.length = 0; - }); - - it('rejects loopback and link-local URLs before opening a transport', async () => { - await expect(connectRemoteMcp({ ...baseParams(), url: 'http://127.0.0.1:6379' })).rejects.toMatchObject({ - constructor: McpConnectionError, - statusCode: 400, - }); - await expect(connectRemoteMcp({ ...baseParams(), url: 'http://169.254.169.254/' })).rejects.toMatchObject({ - constructor: McpConnectionError, - statusCode: 400, - }); - expect(mockConnectAttempts).toEqual([]); - }); -}); diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts index 77f54294a..ffe1a4229 100644 --- a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -1,27 +1,64 @@ +import http from 'node:http'; import { assertSafeOutboundUrl, configureOutboundUrlGuard, ssrfFetch } from '../../../src/core/util/ssrfGuard'; afterEach(() => { configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); }); +async function listen(handler: http.RequestListener): Promise<{ server: http.Server; origin: string }> { + const server = http.createServer(handler); + await new Promise(resolve => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a TCP listen address'); + } + return { server, origin: `http://127.0.0.1:${String(address.port)}` }; +} + +async function closeServer(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); +} + describe('assertSafeOutboundUrl', () => { it('rejects private, loopback, and link-local literals', async () => { await expect(assertSafeOutboundUrl('http://10.0.0.1/')).rejects.toThrow(/blocked/); await expect(assertSafeOutboundUrl('http://192.168.1.1/')).rejects.toThrow(/blocked/); await expect(assertSafeOutboundUrl('http://127.0.0.1:6379/')).rejects.toThrow(/blocked/); await expect(assertSafeOutboundUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/blocked/); - await expect(assertSafeOutboundUrl('http://[::1]/')).rejects.toThrow(/blocked/); await expect(assertSafeOutboundUrl('http://[::ffff:127.0.0.1]/')).rejects.toThrow(/blocked/); }); + it('rejects CGNAT, TEST-NET, multicast, and IPv6 literals', async () => { + await expect(assertSafeOutboundUrl('http://100.64.0.1/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://192.0.2.1/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://224.0.0.1/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://[::1]/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://[2001:db8::1]/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('https://[2606:4700:4700::1111]/')).rejects.toThrow(/blocked/); + }); + + it('rejects single-label and in-cluster hostnames before DNS', async () => { + await expect(assertSafeOutboundUrl('http://redis/')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('http://foo.svc.cluster.local/mcp')).rejects.toThrow(/blocked/); + await expect(assertSafeOutboundUrl('https://metadata.google.internal/')).rejects.toThrow(/blocked/); + }); + it('rejects non-http(s) and allows a public IPv4 literal', async () => { await expect(assertSafeOutboundUrl('file:///etc/passwd')).rejects.toThrow(/http and https/); await expect(assertSafeOutboundUrl('https://93.184.216.34/')).resolves.toBeUndefined(); }); it('honors allow and block lists', async () => { - configureOutboundUrlGuard({ allowedHosts: ['localhost'], blockedHosts: ['93.184.216.34'] }); + configureOutboundUrlGuard({ + allowedHosts: ['localhost', 'foo.svc.cluster.local'], + blockedHosts: ['93.184.216.34'], + }); await expect(assertSafeOutboundUrl('http://localhost:11434/v1')).resolves.toBeUndefined(); + await expect(assertSafeOutboundUrl('http://foo.svc.cluster.local/mcp')).resolves.toBeUndefined(); await expect(assertSafeOutboundUrl('https://93.184.216.34/')).rejects.toThrow(/blocked/); }); }); @@ -33,4 +70,37 @@ describe('ssrfFetch', () => { expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); + + it('does not follow a redirect onto a private address', async () => { + configureOutboundUrlGuard({ allowedHosts: ['127.0.0.1'], blockedHosts: [] }); + const { server, origin } = await listen((_req, res) => { + res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' }); + res.end(); + }); + try { + await expect(ssrfFetch(`${origin}/`)).rejects.toThrow(/blocked/); + } finally { + await closeServer(server); + } + }); + + it('follows a same-origin redirect when the host is allowed', async () => { + configureOutboundUrlGuard({ allowedHosts: ['127.0.0.1'], blockedHosts: [] }); + const { server, origin } = await listen((req, res) => { + if (req.url === '/from') { + res.writeHead(302, { location: '/to' }); + res.end(); + return; + } + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + }); + try { + const response = await ssrfFetch(`${origin}/from`); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe('ok'); + } finally { + await closeServer(server); + } + }); }); diff --git a/packages/trueforge/src/mcp/auth/mcpDcr.ts b/packages/trueforge/src/mcp/auth/mcpDcr.ts index f11d24c36..cb4331d58 100644 --- a/packages/trueforge/src/mcp/auth/mcpDcr.ts +++ b/packages/trueforge/src/mcp/auth/mcpDcr.ts @@ -13,7 +13,7 @@ import type { OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { assertSafeOutboundUrl, McpConnectionError, McpDcrConfigurationError } from '@truefoundry/trueforge-core/core'; +import { McpConnectionError, McpDcrConfigurationError, ssrfFetch } from '@truefoundry/trueforge-core/core'; import { randomBytes } from 'node:crypto'; import { isOAuthAccessTokenUsable, @@ -41,10 +41,9 @@ export const MCP_OAUTH_HTTP_TIMEOUT_MS = 15_000; * (startAuthorization is local PKCE + URL construction and never calls this). */ const mcpOAuthFetch: FetchLike = async (url, init) => { - await assertSafeOutboundUrl(url); const timeoutSignal = AbortSignal.timeout(MCP_OAUTH_HTTP_TIMEOUT_MS); const signal = init?.signal != null ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal; - return fetch(url, { ...init, signal }); + return ssrfFetch(url, { ...init, signal }); }; function isTimeoutError(error: unknown): boolean { diff --git a/packages/trueforge/src/routes/mcpServerRoutes.ts b/packages/trueforge/src/routes/mcpServerRoutes.ts index bd3b47d3d..534e4b5a3 100644 --- a/packages/trueforge/src/routes/mcpServerRoutes.ts +++ b/packages/trueforge/src/routes/mcpServerRoutes.ts @@ -135,8 +135,7 @@ export const createMcpServerRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: - 'Invalid request body, blocked outbound URL, or redacted header secret with no stored value to keep.', + description: 'Invalid request body, or redacted header secret with no stored value to keep.', }, 409: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -175,8 +174,7 @@ export const putMcpServerRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: - 'Invalid request body, blocked outbound URL, or redacted header secret with no stored value to keep.', + description: 'Invalid request body, or redacted header secret with no stored value to keep.', }, 422: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/src/routes/modelProviderRoutes.ts b/packages/trueforge/src/routes/modelProviderRoutes.ts index e0a5a2c0f..c0b8058a4 100644 --- a/packages/trueforge/src/routes/modelProviderRoutes.ts +++ b/packages/trueforge/src/routes/modelProviderRoutes.ts @@ -60,7 +60,7 @@ export const createModelProviderRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Invalid request body, blocked outbound URL, or redacted API key with no stored secret to keep.', + description: 'Invalid request body, or redacted API key with no stored secret to keep.', }, 409: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, @@ -96,7 +96,7 @@ export const putModelProviderRoute = createRoute({ }, 400: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Invalid request body, blocked outbound URL, or redacted API key with no stored secret to keep.', + description: 'Invalid request body, or redacted API key with no stored secret to keep.', }, 424: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts b/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts index f48394138..0aaeb6929 100644 --- a/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts @@ -3,9 +3,13 @@ import { TrueFoundryServiceFoundryServerClient } from '../../../src/truefoundry/ const fetchMock = jest.fn(); -jest.mock('undici', () => ({ - fetch: (...args: unknown[]) => fetchMock(...args), -})); +jest.mock('undici', () => { + const actual = jest.requireActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); function jsonResponse(body: unknown): { ok: true; status: 200; text: () => Promise } { return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85bbacd41..7df36e1e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,9 @@ importers: ulid: specifier: ^3.0.2 version: 3.0.2 + undici: + specifier: 7.29.0 + version: 7.29.0 winston: specifier: ^3.19.0 version: 3.19.0 @@ -1077,12 +1080,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1101,12 +1098,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} @@ -1125,12 +1116,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} @@ -1149,12 +1134,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} @@ -1173,12 +1152,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} @@ -1197,12 +1170,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} @@ -1221,12 +1188,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} @@ -1245,12 +1206,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} @@ -1269,12 +1224,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} @@ -1293,12 +1242,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} @@ -1317,12 +1260,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} @@ -1341,12 +1278,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} @@ -1365,12 +1296,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} @@ -1389,12 +1314,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} @@ -1413,12 +1332,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} @@ -1437,12 +1350,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} @@ -1461,12 +1368,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} @@ -1485,12 +1386,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} @@ -1509,12 +1404,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} @@ -1533,12 +1422,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} @@ -1557,12 +1440,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} @@ -1581,12 +1458,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} @@ -1605,12 +1476,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} @@ -1629,12 +1494,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} @@ -1653,12 +1512,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} @@ -1677,12 +1530,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -4828,11 +4675,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -8497,9 +8339,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true @@ -8509,9 +8348,6 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm64@0.28.2': optional: true @@ -8521,9 +8357,6 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-arm@0.28.2': optional: true @@ -8533,9 +8366,6 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/android-x64@0.28.2': optional: true @@ -8545,9 +8375,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.28.2': optional: true @@ -8557,9 +8384,6 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.28.2': optional: true @@ -8569,9 +8393,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true @@ -8581,9 +8402,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true @@ -8593,9 +8411,6 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true @@ -8605,9 +8420,6 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true @@ -8617,9 +8429,6 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true @@ -8629,9 +8438,6 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true @@ -8641,9 +8447,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true @@ -8653,9 +8456,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true @@ -8665,9 +8465,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true @@ -8677,9 +8474,6 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true @@ -8689,9 +8483,6 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true @@ -8701,9 +8492,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true @@ -8713,9 +8501,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true @@ -8725,9 +8510,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true @@ -8737,9 +8519,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true @@ -8749,9 +8528,6 @@ snapshots: '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true @@ -8761,9 +8537,6 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true @@ -8773,9 +8546,6 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true @@ -8785,9 +8555,6 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true @@ -8797,9 +8564,6 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true @@ -11098,10 +10862,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) @@ -12169,35 +11933,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -12226,7 +11961,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 - optional: true escalade@3.2.0: {} @@ -15304,7 +15038,7 @@ snapshots: tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -15339,7 +15073,7 @@ snapshots: typescript-eslint@8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.68.0(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) From 0ff35fdc8d78f5ec6051f949c647a198964b27ab Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 16 Sep 2026 14:56:23 +0530 Subject: [PATCH 3/6] comment addressed --- .../trueforge-core/src/core/util/ssrfGuard.ts | 16 +++++++++++----- .../tests/core/util/ssrfGuard.test.ts | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/trueforge-core/src/core/util/ssrfGuard.ts b/packages/trueforge-core/src/core/util/ssrfGuard.ts index 9540af48c..fc94cf995 100644 --- a/packages/trueforge-core/src/core/util/ssrfGuard.ts +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -73,6 +73,7 @@ const MAX_REDIRECTS = 20; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const CROSS_ORIGIN_STRIPPED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'host']; +/** Allow `localhost` / `foo.svc.cluster.local`; block `93.184.216.34`. Hosts run through `normalizeHost`. */ export function configureOutboundUrlGuard(config: { allowedHosts: readonly string[]; blockedHosts: readonly string[]; @@ -81,10 +82,13 @@ export function configureOutboundUrlGuard(config: { blockedHosts = config.blockedHosts.map(normalizeHost); } +/** `Example.COM.` → `example.com`; `[2606:4700:4700::1111]` → `2606:4700:4700::1111` (`isIP` rejects brackets). */ function normalizeHost(hostname: string): string { - return hostname.replace(/\.$/, '').toLowerCase(); + const host = hostname.replace(/\.$/, '').toLowerCase(); + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; } +/** `10.0.0.1` / `::1` / `::ffff:127.0.0.1` true; `93.184.216.34` / `2606:4700:4700::1111` false. */ function isPrivateIp(address: string): boolean { const ip = address.replace(/^::ffff:/i, ''); if (isIP(ip) === 4) { @@ -104,7 +108,7 @@ function deny(host: string, cause?: unknown): never { throw blockedError(host, cause); } -/** Block/allow lists, k8s hostname shapes, IP literals. Hostname DNS is classified in `guardedLookup`. */ +/** Deny `redis`, `foo.svc`, `127.0.0.1`, `::1`; allow `example.com`, `93.184.216.34`, `2606:4700:4700::1111`. DNS later. */ function assertHost(host: string): void { if (host === '' || blockedHosts.includes(host)) { deny(host); @@ -127,6 +131,7 @@ function assertHost(host: string): void { } } +/** `https://example.com` / `http://[2606:4700:4700::1111]/` ok; `file:///etc/passwd` / `ftp://…` denied. */ function parseOutboundUrl(input: string | URL | Request): URL { let url: URL; try { @@ -140,9 +145,7 @@ function parseOutboundUrl(input: string | URL | Request): URL { return url; } -/** - * undici runs this as the socket lookup, so the addresses we allow are the ones connected to. - */ +/** Connect-time lookup: `example.com` → public A/AAAA ok; resolve-to-`10.0.0.1` denied. */ const guardedLookup: LookupFunction = (hostname, options: LookupOptions, callback) => { const host = normalizeHost(hostname); try { @@ -176,6 +179,7 @@ const guardedLookup: LookupFunction = (hostname, options: LookupOptions, callbac const outboundAgent = new Agent({ connect: { lookup: guardedLookup } }); +/** Save/preflight: `https://[2606:4700:4700::1111]/` allow; `http://169.254.169.254/` deny. */ export async function assertSafeOutboundUrl(input: string | URL | Request): Promise { const url = parseOutboundUrl(input); const host = normalizeHost(url.hostname); @@ -194,6 +198,7 @@ export async function assertSafeOutboundUrl(input: string | URL | Request): Prom } } +/** 302 `/to` same-origin keep; 302 `http://169.254.169.254/` is re-checked on the next hop. */ function nextHop( response: Response, location: string, @@ -265,6 +270,7 @@ async function guardedFetch(input: string | URL | Request, init: RequestInit, ho return guardedFetch(hop.url, hop.init, hopsLeft - 1); } +/** Fetch via the guard: `https://example.com` proceeds; `http://169.254.169.254/` throws before fetch. */ export async function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise { return guardedFetch(input, init ?? {}, MAX_REDIRECTS); } diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts index ffe1a4229..ccb9ad135 100644 --- a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -38,7 +38,6 @@ describe('assertSafeOutboundUrl', () => { await expect(assertSafeOutboundUrl('http://224.0.0.1/')).rejects.toThrow(/blocked/); await expect(assertSafeOutboundUrl('http://[::1]/')).rejects.toThrow(/blocked/); await expect(assertSafeOutboundUrl('http://[2001:db8::1]/')).rejects.toThrow(/blocked/); - await expect(assertSafeOutboundUrl('https://[2606:4700:4700::1111]/')).rejects.toThrow(/blocked/); }); it('rejects single-label and in-cluster hostnames before DNS', async () => { @@ -47,9 +46,10 @@ describe('assertSafeOutboundUrl', () => { await expect(assertSafeOutboundUrl('https://metadata.google.internal/')).rejects.toThrow(/blocked/); }); - it('rejects non-http(s) and allows a public IPv4 literal', async () => { + it('rejects non-http(s) and allows public IPv4 and IPv6 literals', async () => { await expect(assertSafeOutboundUrl('file:///etc/passwd')).rejects.toThrow(/http and https/); await expect(assertSafeOutboundUrl('https://93.184.216.34/')).resolves.toBeUndefined(); + await expect(assertSafeOutboundUrl('https://[2606:4700:4700::1111]/')).resolves.toBeUndefined(); }); it('honors allow and block lists', async () => { From a52f83f2b99bc86c2c9a1283fcf8aa4e77fc50ad Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 16 Sep 2026 16:00:00 +0530 Subject: [PATCH 4/6] enhance outbound URL guard with ENABLE_SSRF option --- .changeset/ssrf-outbound-url-guard.md | 2 +- charts/trueforge/README.md | 1 + charts/trueforge/templates/_helpers.tpl | 1 + charts/trueforge/values.yaml | 2 ++ .../trueforge-core/src/core/util/ssrfGuard.ts | 33 +++++++++---------- .../tests/core/util/ssrfGuard.test.ts | 6 ++++ packages/trueforge/.env.example | 2 ++ packages/trueforge/src/config.ts | 7 ++++ packages/trueforge/src/main.ts | 1 + 9 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.changeset/ssrf-outbound-url-guard.md b/.changeset/ssrf-outbound-url-guard.md index f1706baf2..0a8ad5d1a 100644 --- a/.changeset/ssrf-outbound-url-guard.md +++ b/.changeset/ssrf-outbound-url-guard.md @@ -3,4 +3,4 @@ "@truefoundry/trueforge": patch --- -Block RFC1918, CGNAT, reserved, link-local, and loopback destinations on outbound MCP and model-provider HTTP, plus in-cluster hostnames, with optional host allow/block lists. +Block RFC1918, CGNAT, reserved, link-local, and loopback destinations on outbound MCP and model-provider HTTP, plus in-cluster hostnames, with optional host allow/block lists and ENABLE_SSRF (default on). diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index 1ecaab1b5..cb1e155e9 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -269,6 +269,7 @@ configs: # Optional email allowlist (exact + * globs). Empty = unrestricted. # allowedEmails: "alice@acme.com,*@partner.com" outboundUrl: + # enabled: true # Optional. Empty = deny private/loopback/link-local MCP and model-provider URLs. # allowPrivateHosts: "llm-gateway.internal,localhost" # blockedHosts: "" diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index b854f02c3..99c816621 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -448,6 +448,7 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- end -}} {{- end -}} +{{- $env = append $env (dict "name" "ENABLE_SSRF" "value" (.Values.configs.outboundUrl.enabled | toString)) -}} {{- if .Values.configs.outboundUrl.allowPrivateHosts -}} {{- $env = append $env (dict "name" "OUTBOUND_URL_ALLOW_PRIVATE_HOSTS" "value" .Values.configs.outboundUrl.allowPrivateHosts) -}} {{- end -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index aaa754e08..8f05289aa 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -133,6 +133,8 @@ configs: # Empty = unrestricted. Matched against the ID token `email` claim. allowedEmails: "" outboundUrl: + # Env: ENABLE_SSRF. Default true. Set false to skip the outbound URL guard. + enabled: true # Comma-separated hosts allowed even if private. Empty = none. Env: OUTBOUND_URL_ALLOW_PRIVATE_HOSTS. allowPrivateHosts: "" # Comma-separated hosts always blocked. Empty = none. Env: OUTBOUND_URL_BLOCKED_HOSTS. diff --git a/packages/trueforge-core/src/core/util/ssrfGuard.ts b/packages/trueforge-core/src/core/util/ssrfGuard.ts index b5dfb8c80..6fe908a4b 100644 --- a/packages/trueforge-core/src/core/util/ssrfGuard.ts +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -6,6 +6,7 @@ import { Agent, fetch as undiciFetch } from 'undici'; let allowedHosts: string[] = []; let blockedHosts: string[] = []; +let guardEnabled = true; const URL_VERIFY = { allowedProtocols: ['http:', 'https:'], @@ -40,7 +41,6 @@ const URL_VERIFY = { 'fe80::/10', // link-local 'ff00::/8', // multicast ], - denyHostsExact: ['localhost', 'metadata', 'instance-data', 'metadata.google.internal'], denyHostSuffixes: [ '.local', '.localhost', @@ -73,22 +73,21 @@ const MAX_REDIRECTS = 20; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const CROSS_ORIGIN_STRIPPED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'host']; -/** Allow `localhost` / `foo.svc.cluster.local`; block `93.184.216.34`. Hosts run through `normalizeHost`. */ export function configureOutboundUrlGuard(config: { + enabled?: boolean; allowedHosts: readonly string[]; blockedHosts: readonly string[]; }): void { + guardEnabled = config.enabled ?? true; allowedHosts = config.allowedHosts.map(normalizeHost); blockedHosts = config.blockedHosts.map(normalizeHost); } -/** `Example.COM.` → `example.com`; `[2606:4700:4700::1111]` → `2606:4700:4700::1111` (`isIP` rejects brackets). */ function normalizeHost(hostname: string): string { const host = hostname.replace(/\.$/, '').toLowerCase(); return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; } -/** `10.0.0.1` / `::1` / `::ffff:127.0.0.1` true; `93.184.216.34` / `2606:4700:4700::1111` false. */ function isPrivateIp(address: string): boolean { const ip = address.replace(/^::ffff:/i, ''); if (isIP(ip) === 4) { @@ -108,7 +107,6 @@ function deny(host: string, cause?: unknown): never { throw blockedError(host, cause); } -/** Deny `redis`, `foo.svc`, `127.0.0.1`, `::1`; allow `example.com`, `93.184.216.34`, `2606:4700:4700::1111`. DNS later. */ function assertHost(host: string): void { if (host === '' || blockedHosts.includes(host)) { deny(host); @@ -117,11 +115,7 @@ function assertHost(host: string): void { return; } if (isIP(host) === 0) { - if ( - !host.includes('.') || - URL_VERIFY.denyHostsExact.includes(host) || - URL_VERIFY.denyHostSuffixes.some(suffix => host.endsWith(suffix)) - ) { + if (!host.includes('.') || URL_VERIFY.denyHostSuffixes.some(suffix => host.endsWith(suffix))) { deny(host); } return; @@ -131,7 +125,6 @@ function assertHost(host: string): void { } } -/** `https://example.com` / `http://[2606:4700:4700::1111]/` ok; `file:///etc/passwd` / `ftp://…` denied. */ function parseOutboundUrl(input: string | URL | Request): URL { let url: URL; try { @@ -145,8 +138,12 @@ function parseOutboundUrl(input: string | URL | Request): URL { return url; } -/** Connect-time lookup: `example.com` → public A/AAAA ok; resolve-to-`10.0.0.1` denied. */ +/** undici runs this as the socket lookup, so the addresses we allow are the ones connected to. */ const guardedLookup: LookupFunction = (hostname, options: LookupOptions, callback) => { + if (!guardEnabled) { + dnsLookup(hostname, options, callback); + return; + } const host = normalizeHost(hostname); try { assertHost(host); @@ -185,9 +182,11 @@ const outboundAgent = new Agent({ connect: { lookup: guardedLookup }, }); -/** Save/preflight: `https://[2606:4700:4700::1111]/` allow; `http://169.254.169.254/` deny. */ export async function assertSafeOutboundUrl(input: string | URL | Request): Promise { const url = parseOutboundUrl(input); + if (!guardEnabled) { + return; + } const host = normalizeHost(url.hostname); assertHost(host); if (allowedHosts.includes(host) || isIP(host) !== 0) { @@ -204,7 +203,6 @@ export async function assertSafeOutboundUrl(input: string | URL | Request): Prom } } -/** 302 `/to` same-origin keep; 302 `http://169.254.169.254/` is re-checked on the next hop. */ function nextHop( response: Response, location: string, @@ -260,10 +258,12 @@ function mergeRequestInit(input: string | URL | Request, init: RequestInit): Req async function guardedFetch(input: string | URL | Request, init: RequestInit, hopsLeft: number): Promise { const url = parseOutboundUrl(input); - assertHost(normalizeHost(url.hostname)); + if (guardEnabled) { + assertHost(normalizeHost(url.hostname)); + } const merged = mergeRequestInit(input, init); const redirect = merged.redirect ?? 'follow'; - const followsRedirects = redirect === 'follow'; + const followsRedirects = guardEnabled && redirect === 'follow'; const requestInit = { redirect: followsRedirects ? 'manual' : redirect, dispatcher: outboundAgent, @@ -295,7 +295,6 @@ async function guardedFetch(input: string | URL | Request, init: RequestInit, ho return guardedFetch(hop.url, hop.init, hopsLeft - 1); } -/** Fetch via the guard: `https://example.com` proceeds; `http://169.254.169.254/` throws before fetch. */ export async function ssrfFetch(input: string | URL | Request, init?: RequestInit): Promise { return guardedFetch(input, init ?? {}, MAX_REDIRECTS); } diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts index ae3c4b405..e5d72d5ee 100644 --- a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -61,6 +61,12 @@ describe('assertSafeOutboundUrl', () => { await expect(assertSafeOutboundUrl('http://foo.svc.cluster.local/mcp')).resolves.toBeUndefined(); await expect(assertSafeOutboundUrl('https://93.184.216.34/')).rejects.toThrow(/blocked/); }); + + it('skips the guard when disabled', async () => { + configureOutboundUrlGuard({ enabled: false, allowedHosts: [], blockedHosts: [] }); + await expect(assertSafeOutboundUrl('http://127.0.0.1:6379/')).resolves.toBeUndefined(); + await expect(assertSafeOutboundUrl('http://redis/')).resolves.toBeUndefined(); + }); }); describe('ssrfFetch', () => { diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 40075f28b..93583fd72 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -150,6 +150,8 @@ TRUEFORGE_API_KEY=placeholder-value-please-generate-your-own ## Max ms for an MCP transport connection. Default 30000. # MCP_CONNECT_TIMEOUT_MS=30000 +## Outbound URL guard (MCP url / model-provider base_url). Default on; false skips it. +# ENABLE_SSRF=true ## Hosts allowed even if private (local Ollama / MCP). Empty = deny private/loopback/link-local. # OUTBOUND_URL_ALLOW_PRIVATE_HOSTS=localhost,127.0.0.1 # OUTBOUND_URL_BLOCKED_HOSTS= diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 90cea46d8..a5055b7fd 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -571,6 +571,8 @@ export interface SharedServerConfiguration { * `TRUEFORGE_MTLS_ENABLED` is true. Env: `TRUEFORGE_MTLS_CERTS_DIR`. Default `/etc/tls`. */ TRUEFORGE_MTLS_CERTS_DIR: string; + /** Env: `ENABLE_SSRF`. Default true. `false` skips the outbound URL guard. */ + ENABLE_SSRF: boolean; /** Hosts that may be private. Env: `OUTBOUND_URL_ALLOW_PRIVATE_HOSTS`. Empty = none. */ OUTBOUND_URL_ALLOW_PRIVATE_HOSTS: string[]; /** Hosts always blocked. Env: `OUTBOUND_URL_BLOCKED_HOSTS`. Empty = none. */ @@ -805,6 +807,11 @@ const shared: SharedServerConfiguration = { defaultValue: false, }), TRUEFORGE_MTLS_CERTS_DIR: getEnv('TRUEFORGE_MTLS_CERTS_DIR', { defaultValue: '/etc/tls' }) ?? '/etc/tls', + ENABLE_SSRF: parseBoolean({ + envKey: 'ENABLE_SSRF', + raw: getEnv('ENABLE_SSRF'), + defaultValue: true, + }), OUTBOUND_URL_ALLOW_PRIVATE_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_ALLOW_PRIVATE_HOSTS')), OUTBOUND_URL_BLOCKED_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_BLOCKED_HOSTS')), }; diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 2294c047f..ed6c5eb02 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -37,6 +37,7 @@ try { TrueForgeAuthMode, } = await import('./config')); configureOutboundUrlGuard({ + enabled: configuration.ENABLE_SSRF, allowedHosts: configuration.OUTBOUND_URL_ALLOW_PRIVATE_HOSTS, blockedHosts: configuration.OUTBOUND_URL_BLOCKED_HOSTS, }); From eecd7587dbb500a1e37655372997b5264c0f6377 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Wed, 16 Sep 2026 23:26:32 +0530 Subject: [PATCH 5/6] comment addressed --- charts/trueforge/README.md | 2 +- charts/trueforge/templates/_helpers.tpl | 4 +-- charts/trueforge/values.yaml | 42 ++----------------------- packages/trueforge/.env.example | 2 +- packages/trueforge/src/config.ts | 6 ++-- packages/trueforge/src/main.ts | 2 +- 6 files changed, 11 insertions(+), 47 deletions(-) diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index 13eb41a3c..caa464ba4 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -272,7 +272,7 @@ configs: outboundUrl: # enabled: true # Optional. Empty = deny private/loopback/link-local MCP and model-provider URLs. - # allowPrivateHosts: "llm-gateway.internal,localhost" + # allowHosts: "llm-gateway.internal,localhost" # blockedHosts: "" ``` diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 2989b58d1..e036fbd24 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -474,8 +474,8 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- end -}} {{- $env = append $env (dict "name" "ENABLE_SSRF" "value" (.Values.configs.outboundUrl.enabled | toString)) -}} -{{- if .Values.configs.outboundUrl.allowPrivateHosts -}} -{{- $env = append $env (dict "name" "OUTBOUND_URL_ALLOW_PRIVATE_HOSTS" "value" .Values.configs.outboundUrl.allowPrivateHosts) -}} +{{- if .Values.configs.outboundUrl.allowHosts -}} +{{- $env = append $env (dict "name" "OUTBOUND_URL_ALLOW_HOSTS" "value" .Values.configs.outboundUrl.allowHosts) -}} {{- end -}} {{- if .Values.configs.outboundUrl.blockedHosts -}} {{- $env = append $env (dict "name" "OUTBOUND_URL_BLOCKED_HOSTS" "value" .Values.configs.outboundUrl.blockedHosts) -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index c4960abf5..76fc25bf8 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -41,7 +41,7 @@ image: repository: tfy.jfrog.io/tfy-images/trueforge # Tag defaults to chart appVersion when empty. Prod chart-release PRs set this # to {appVersion}-{shortSha} for the npm-install image already in the registry. - tag: "0.2.0-rc.11-7fc322f" + tag: "0.2.0-rc.12-1525f94" pullPolicy: IfNotPresent imagePullSecrets: [] # Default tfy.jfrog.io repos are anonymously pullable. Set secrets only when @@ -174,46 +174,10 @@ configs: outboundUrl: # Env: ENABLE_SSRF. Default true. Set false to skip the outbound URL guard. enabled: true - # Comma-separated hosts allowed even if private. Empty = none. Env: OUTBOUND_URL_ALLOW_PRIVATE_HOSTS. - allowPrivateHosts: "" + # Comma-separated hosts always allowed. Empty = none. Env: OUTBOUND_URL_ALLOW_HOSTS. + allowHosts: "" # Comma-separated hosts always blocked. Empty = none. Env: OUTBOUND_URL_BLOCKED_HOSTS. blockedHosts: "" -# Bitnami relocated its versioned container images to docker.io/bitnamilegacy and -# left only rolling `latest` tags under docker.io/bitnami, so the subchart image -# defaults no longer pull. We source pinned legacy images from the TrueFoundry -# JFrog mirror instead. Overriding the registry trips Bitnami's image guardrail, -# so allowInsecureImages must be set for the subcharts to render. -global: - security: - allowInsecureImages: true - # Custom CA trust, set here or inherited from a parent chart. Give an inline - # `certificate` or an `existingConfigMap.name` (key `ca-certificates.crt`); - # mounted over /etc/ssl/certs (overrideCAList) or merged with the system - # bundle by an initContainer. Sets NODE_EXTRA_CA_CERTS either way. - customCA: - enabled: false - certificate: "" - existingConfigMap: - name: "" - overrideCAList: false - image: - registry: tfy.jfrog.io - repository: tfy-mirror/alpine - tag: "3.21" - securityContext: - runAsNonRoot: true - runAsUser: 1001 - runAsGroup: 1001 - readOnlyRootFilesystem: true - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - # Extra env for the initContainer only, e.g. SSL_CERT_FILE, REQUESTS_CA_BUNDLE. - env: {} - emptyDir: - sslCerts: - sizeLimit: 10Mi # Resource tier: small | medium | large presets for server and controller. # Empty = use `resources` / `controller.resources`. Wins over a parent chart's # global.resourceTier. diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index a7a041987..ef505fef1 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -114,7 +114,7 @@ TRUEFORGE_API_KEY=placeholder-value-please-generate-your-own ## Outbound URL guard (MCP url / model-provider base_url). Default on; false skips it. # ENABLE_SSRF=true ## Hosts allowed even if private (local Ollama / MCP). Empty = deny private/loopback/link-local. -# OUTBOUND_URL_ALLOW_PRIVATE_HOSTS=localhost,127.0.0.1 +# OUTBOUND_URL_ALLOW_HOSTS=localhost,127.0.0.1 # OUTBOUND_URL_BLOCKED_HOSTS= ## --------------------------------------------------------------------------- diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index faf5f0681..87d662936 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -612,8 +612,8 @@ export interface SharedServerConfiguration { TRUEFORGE_MTLS_CERTS_DIR: string; /** Env: `ENABLE_SSRF`. Default true. `false` skips the outbound URL guard. */ ENABLE_SSRF: boolean; - /** Hosts that may be private. Env: `OUTBOUND_URL_ALLOW_PRIVATE_HOSTS`. Empty = none. */ - OUTBOUND_URL_ALLOW_PRIVATE_HOSTS: string[]; + /** Hosts always allowed. Env: `OUTBOUND_URL_ALLOW_HOSTS`. Empty = none. */ + OUTBOUND_URL_ALLOW_HOSTS: string[]; /** Hosts always blocked. Env: `OUTBOUND_URL_BLOCKED_HOSTS`. Empty = none. */ OUTBOUND_URL_BLOCKED_HOSTS: string[]; } @@ -865,7 +865,7 @@ const shared: SharedServerConfiguration = { raw: getEnv('ENABLE_SSRF'), defaultValue: true, }), - OUTBOUND_URL_ALLOW_PRIVATE_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_ALLOW_PRIVATE_HOSTS')), + OUTBOUND_URL_ALLOW_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_ALLOW_HOSTS')), OUTBOUND_URL_BLOCKED_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_BLOCKED_HOSTS')), }; diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index a8bedc607..eeb8fc68f 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -38,7 +38,7 @@ try { } = await import('./config')); configureOutboundUrlGuard({ enabled: configuration.ENABLE_SSRF, - allowedHosts: configuration.OUTBOUND_URL_ALLOW_PRIVATE_HOSTS, + allowedHosts: configuration.OUTBOUND_URL_ALLOW_HOSTS, blockedHosts: configuration.OUTBOUND_URL_BLOCKED_HOSTS, }); } catch (error) { From 7b206914dba08e0d224a8449baae631ec28134d0 Mon Sep 17 00:00:00 2001 From: Bhavesh Patel Date: Thu, 17 Sep 2026 20:47:03 +0530 Subject: [PATCH 6/6] Merge branch 'main' into AGE-2246 --- .../trueforge-core/src/core/mcp/remoteMcpClient.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts index a354cb603..aacfe988e 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { CallToolRequest, CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { context, propagation } from '@opentelemetry/api'; import { McpConnectionError } from '../errors'; @@ -32,15 +33,6 @@ const TRANSPORT_PROBE_ORDER: RemoteMcpTransportType[] = ['streamable-http', 'sse export const DEFAULT_MAX_MCP_RESPONSE_BYTES = 50 * 1024 * 1024; -// MCP SSE/streamable-HTTP keeps a long-lived response open that is often idle between tool calls. -// Node fetch (undici) defaults bodyTimeout to 300s of silence, then kills the stream with -// `Body Timeout Error` — we reconnect and the ~5m cycle repeats in logs. 30m matches the -// Gateway idle-body window; MCP request deadlines still come from requestTimeoutMs. -const MCP_BODY_TIMEOUT_MS = 30 * 60 * 1000; -const mcpHttpAgent = new Agent({ bodyTimeout: MCP_BODY_TIMEOUT_MS }); -const mcpFetch: FetchLike = (url, init) => - undiciFetch(typeof url === 'string' ? url : url.href, { ...(init as object), dispatcher: mcpHttpAgent }); - /** GET SSE is long-lived and uncapped; every other body aborts at `maxBytes`. */ export function withMaxResponseBytes(fetchFn: FetchLike, maxBytes: number): FetchLike { return async (url, init) => { @@ -196,7 +188,7 @@ export async function connectRemoteMcp(params: { }): Promise { const url = new URL(params.url); const requestOptions = { signal: params.signal }; - const fetchFn = withMaxResponseBytes(mcpFetch, params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES); + const fetchFn = withMaxResponseBytes(ssrfFetch, params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES); const candidates = params.knownTransportType ? [params.knownTransportType, ...TRANSPORT_PROBE_ORDER.filter(t => t !== params.knownTransportType)] : TRANSPORT_PROBE_ORDER;