Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/ssrf-outbound-url-guard.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions charts/trueforge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions charts/trueforge/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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) -}}

Expand Down
7 changes: 7 additions & 0 deletions charts/trueforge/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/trueforge-core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions packages/trueforge-core/src/core/llm/VercelAILLM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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 } : {}),
});
Expand All @@ -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 } : {}),
});
Expand All @@ -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 } : {}),
});
Expand All @@ -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 } : {}),
});
Expand All @@ -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);
Expand Down
13 changes: 2 additions & 11 deletions packages/trueforge-core/src/core/mcp/remoteMcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to ssrfGuard.ts

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) => {
Expand Down Expand Up @@ -197,7 +188,7 @@ export async function connectRemoteMcp(params: {
}): Promise<RemoteMcpConnection> {
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;
Expand Down
Loading
Loading