diff --git a/.changeset/ssrf-outbound-url-guard.md b/.changeset/ssrf-outbound-url-guard.md new file mode 100644 index 000000000..0a8ad5d1a --- /dev/null +++ b/.changeset/ssrf-outbound-url-guard.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-core": patch +"@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 and ENABLE_SSRF (default on). diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index 6a11f3c5f..0b23e4364 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -269,6 +269,11 @@ configs: # scopes: "openid,profile,email,groups" # 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. + # allowHosts: "llm-gateway.internal,localhost" + # blockedHosts: "" ``` ## Using Secrets diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 7e45f82d7..f5a4b1050 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -491,6 +491,14 @@ 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.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) -}} +{{- 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 4c871fbc3..0764fbb2b 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -170,6 +170,13 @@ configs: # Optional comma-separated exact emails and/or * globs (e.g. "*@company.com"). # 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 always allowed. Empty = none. Env: OUTBOUND_URL_ALLOW_HOSTS. + allowHosts: "" + # Comma-separated hosts always blocked. Empty = none. Env: OUTBOUND_URL_BLOCKED_HOSTS. + blockedHosts: "" # 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-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 76538942f..1cff0d22d 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -145,6 +145,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 9298fb441..aacfe988e 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -6,9 +6,9 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ 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 { Agent, fetch as undiciFetch } from 'undici'; import { McpConnectionError } from '../errors'; import { withTimeout } from '../util/promiseUtils'; +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. */ @@ -33,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) => { @@ -197,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; 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..6fe908a4b --- /dev/null +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -0,0 +1,300 @@ +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, fetch as undiciFetch } from 'undici'; + +let allowedHosts: string[] = []; +let blockedHosts: string[] = []; +let guardEnabled = true; + +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 + ], + denyHostSuffixes: [ + '.local', + '.localhost', + '.localdomain', + '.internal', + '.svc', + '.cluster', + '.arpa', + '.lan', + '.intranet', + '.corp', + '.home', + '.test', + '.invalid', + '.example', + ], +}; + +const privateNets = new BlockList(); +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: { + enabled?: boolean; + allowedHosts: readonly string[]; + blockedHosts: readonly string[]; +}): void { + guardEnabled = config.enabled ?? true; + allowedHosts = config.allowedHosts.map(normalizeHost); + blockedHosts = config.blockedHosts.map(normalizeHost); +} + +function normalizeHost(hostname: string): string { + const host = hostname.replace(/\.$/, '').toLowerCase(); + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; +} + +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 blockedError(host: string, cause?: unknown): Error { + return new Error(`Outbound URL blocked for host "${host}"`, { cause }); +} + +function deny(host: string, cause?: unknown): never { + throw blockedError(host, cause); +} + +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.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_VERIFY.allowedProtocols.includes(url.protocol)) { + throw new Error('Outbound URL blocked: only http and https are allowed'); + } + return url; +} + +/** 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); + } catch (error) { + callback(error instanceof Error ? error : blockedError(host, error), ''); + return; + } + 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 BODY_TIMEOUT_MS = 30 * 60 * 1000; + +const outboundAgent = new Agent({ + // MCP SSE/streamable-HTTP stays idle between tool calls; undici's 300s bodyTimeout kills it. + bodyTimeout: BODY_TIMEOUT_MS, + connect: { lookup: guardedLookup }, +}); + +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) { + return; + } + let addresses: string[]; + try { + addresses = (await dnsLookupAsync(host, { all: true })).map(record => record.address); + } catch (error) { + deny(host, error); + } + if (addresses.some(isPrivateIp)) { + deny(host); + } +} + +function nextHop( + response: Response, + location: string, + current: URL, + 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 headers = new Headers(init.headers); + let method = (init.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 } }; +} + +function mergeRequestInit(input: string | URL | Request, init: RequestInit): RequestInit { + if (!(input instanceof Request)) { + return init; + } + return { + method: input.method, + headers: input.headers, + body: input.body, + signal: input.signal, + redirect: input.redirect, + ...init, + }; +} + +async function guardedFetch(input: string | URL | Request, init: RequestInit, hopsLeft: number): Promise { + const url = parseOutboundUrl(input); + if (guardEnabled) { + assertHost(normalizeHost(url.hostname)); + } + const merged = mergeRequestInit(input, init); + const redirect = merged.redirect ?? 'follow'; + const followsRedirects = guardEnabled && redirect === 'follow'; + const requestInit = { + redirect: followsRedirects ? 'manual' : redirect, + dispatcher: outboundAgent, + }; + // npm undici vs @types/node undici-types: FormData/Headers do not line up under exactOptionalPropertyTypes. + Object.assign(requestInit, merged, { + redirect: followsRedirects ? 'manual' : redirect, + dispatcher: outboundAgent, + ...(merged.body != null && typeof merged.body === 'object' && 'getReader' in merged.body + ? { duplex: 'half' as const } + : {}), + }); + const response = await undiciFetch(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, merged); + return guardedFetch(hop.url, hop.init, hopsLeft - 1); +} + +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/mcp/connectRemoteMcp.test.ts b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts index ae9340241..386cb15d8 100644 --- a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts +++ b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts @@ -78,7 +78,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, 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..e5d72d5ee --- /dev/null +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -0,0 +1,149 @@ +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://[::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/); + }); + + 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 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 () => { + 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/); + }); + + 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', () => { + 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(); + }); + + 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); + } + }); + + it('keeps method, headers, and body from a Request argument', async () => { + configureOutboundUrlGuard({ allowedHosts: ['127.0.0.1'], blockedHosts: [] }); + const { server, origin } = await listen((req, res) => { + const chunks: Buffer[] = []; + req.on('data', chunk => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + method: req.method, + authorization: req.headers.authorization ?? null, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }); + }); + try { + const response = await ssrfFetch( + new Request(`${origin}/echo`, { + method: 'POST', + headers: { authorization: 'Bearer t', 'content-type': 'text/plain' }, + body: 'hello', + }), + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + method: 'POST', + authorization: 'Bearer t', + body: 'hello', + }); + } finally { + await closeServer(server); + } + }); +}); diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 582176c3c..fe40529c7 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -113,6 +113,12 @@ TRUEFORGE_API_KEY=placeholder-value-please-generate-your-own ## Max bytes for one remote MCP tool-call HTTP response body (JSON / POST SSE). Default 50 MB. # MCP_TOOL_CALL_MAX_RESPONSE_BYTES=52428800 +## 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_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 5b8960620..e1026d0db 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 fcda35539..0f6625c81 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -661,6 +661,12 @@ 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 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[]; } export type StandaloneServerConfiguration = SharedServerConfiguration & { @@ -921,6 +927,13 @@ 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_HOSTS: parseCommaSeparatedEnvList(getEnv('OUTBOUND_URL_ALLOW_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 f2dc59083..eeb8fc68f 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,11 @@ try { getPublicUiBasePath, TrueForgeAuthMode, } = await import('./config')); + configureOutboundUrlGuard({ + enabled: configuration.ENABLE_SSRF, + allowedHosts: configuration.OUTBOUND_URL_ALLOW_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..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 { McpConnectionError, McpDcrConfigurationError } from '@truefoundry/trueforge-core/core'; +import { McpConnectionError, McpDcrConfigurationError, ssrfFetch } from '@truefoundry/trueforge-core/core'; import { randomBytes } from 'node:crypto'; import { isOAuthAccessTokenUsable, @@ -40,10 +40,10 @@ 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) => { 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/tests/unit/apis/mcpOAuth.test.ts b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts index 58b09090a..7555a4a93 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'; @@ -15,6 +16,17 @@ import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/Sq import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; +jest.mock('undici', () => { + const actual = jest.requireActual('undici'); + return { + ...actual, + fetch: (input: unknown, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + return globalThis.fetch(url, init); + }, + }; +}); + const AS_ORIGIN = 'https://auth.example.com'; const MCP_URL = 'https://mcp.example.com/sse'; const FE_RETURN_TO = '/mcp/connected'; @@ -84,6 +96,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 +139,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..c950c9141 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'; @@ -15,6 +16,17 @@ import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/Sq import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; +jest.mock('undici', () => { + const actual = jest.requireActual('undici'); + return { + ...actual, + fetch: (input: unknown, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + return globalThis.fetch(url, init); + }, + }; +}); + const putBody = { type: 'remote' as const, name: 'deepwiki', @@ -92,6 +104,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 +178,7 @@ describe('mcp-servers routers', () => { } afterAll(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); globalThis.fetch = originalFetch; }); @@ -202,6 +227,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..1c2960846 100644 --- a/packages/trueforge/tests/unit/mcp/auth/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/mcp/auth/mcpOAuth.test.ts @@ -1,8 +1,20 @@ /** * MCP OAuth / DCR helper tests (node:test style via jest). - * Global fetch is stubbed; production code uses real fetch only. + * Global fetch is stubbed; production ssrfFetch uses undici, so undici.fetch forwards here. */ +jest.mock('undici', () => { + const actual = jest.requireActual('undici'); + return { + ...actual, + fetch: (input: unknown, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + return globalThis.fetch(url, init); + }, + }; +}); + 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 +46,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..3ebac3a5d 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'; @@ -8,12 +9,27 @@ import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/Sqlite import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; import { getMcpConnection } from '../../../src/runtime/sessionResources'; +jest.mock('undici', () => { + const actual = jest.requireActual('undici'); + return { + ...actual, + fetch: (input: unknown, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + return globalThis.fetch(url, init); + }, + }; +}); + describe('getMcpConnection', () => { let db: ReturnType; let mcpServerStore: IMcpServerWithAuthStore; 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 +275,8 @@ describe('getMcpConnection', () => { }), ).resolves.toBeUndefined(); }); + + afterAll(() => { + configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); + }); }); diff --git a/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts b/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts index 3831f980c..0aaeb6929 100644 --- a/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/listAgentSkillsPaging.test.ts @@ -3,10 +3,13 @@ import { TrueFoundryServiceFoundryServerClient } from '../../../src/truefoundry/ const fetchMock = jest.fn(); -jest.mock('undici', () => ({ - Agent: class Agent {}, - 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 {