From 4b57c011f23dec5d27cc61e5bd3de41809bd9f07 Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 2 Oct 2026 17:44:21 +0200 Subject: [PATCH 1/2] Keep the SSRF guard on redirects and at connect time for all outbound calls Outbound calls to user-supplied URLs ran assertSafeOutboundUrl once and then let axios/fetch follow redirects and re-resolve the host unchecked. - common/guarded-http.util: ssrfGuardedAxiosOptions() (agents that check the address at connect time + a beforeRedirect hook for scheme and literal IPs), ssrfGuardedAxios() for soap, ssrfGuardedFetch() with manual redirects for the MCP client transport. - Applied to the REST, GraphQL, SOAP and MCP engines, the OAuth2, LOGIN_TOKEN and MCP OAuth services, and the OpenAPI, Postman, GraphQL and WSDL importers. The WSDL importer had no check at all. - The operator's HTTP(S)_PROXY host is exempt from the connect-time check. - The instance-wide SSRF allowlist routes are self-hosted only; in cloud the list comes from SSRF_ALLOWED_HOSTS, the settings card is hidden and the connection test no longer suggests allowlisting. --- .../src/common/guarded-http.util.spec.ts | 163 ++++++++++++++++++ .../backend/src/common/guarded-http.util.ts | 95 ++++++++++ packages/backend/src/common/ssrf.util.ts | 64 ++++++- .../src/connectors/connectors.service.ts | 5 +- .../engines/graphql-schema.service.ts | 2 + .../src/connectors/engines/graphql.engine.ts | 7 +- .../connectors/engines/login-token.service.ts | 3 + .../connectors/engines/mcp-client.engine.ts | 4 + .../engines/oauth2-token.service.ts | 2 + .../engines/rest.engine.redirect.spec.ts | 72 ++++++++ .../src/connectors/engines/rest.engine.ts | 2 + .../src/connectors/engines/soap.engine.ts | 6 +- .../src/connectors/mcp-oauth.service.spec.ts | 1 + .../src/connectors/mcp-oauth.service.ts | 9 +- .../src/connectors/parsers/graphql.parser.ts | 3 + .../src/connectors/parsers/openapi.parser.ts | 18 +- .../src/connectors/parsers/postman.parser.ts | 11 +- .../src/connectors/parsers/wsdl.parser.ts | 9 +- .../settings/site-settings.controller.spec.ts | 26 +++ .../src/settings/site-settings.controller.ts | 8 + .../frontend/src/app/admin/settings/page.tsx | 20 ++- 21 files changed, 506 insertions(+), 24 deletions(-) create mode 100644 packages/backend/src/common/guarded-http.util.spec.ts create mode 100644 packages/backend/src/common/guarded-http.util.ts create mode 100644 packages/backend/src/connectors/engines/rest.engine.redirect.spec.ts create mode 100644 packages/backend/src/settings/site-settings.controller.spec.ts diff --git a/packages/backend/src/common/guarded-http.util.spec.ts b/packages/backend/src/common/guarded-http.util.spec.ts new file mode 100644 index 00000000..63e3757f --- /dev/null +++ b/packages/backend/src/common/guarded-http.util.spec.ts @@ -0,0 +1,163 @@ +import axios from 'axios'; +import * as dns from 'dns'; +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { ssrfGuardedAxiosOptions, ssrfGuardedFetch } from './guarded-http.util'; +import { ssrfGuardedLookup } from './ssrf.util'; + +// Guard on; `localhost` is the only allowlisted name and stands in for a +// public host. Literal 127.0.0.1 stays blocked, like an internal service. +const GUARD_ENV = { SSRF_GUARD: 'enabled', SSRF_ALLOWED_HOSTS: 'localhost' }; + +type Handler = (req: http.IncomingMessage, body: string, res: http.ServerResponse) => void; +interface Hit { + method?: string; + url?: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +describe('guarded HTTP clients', () => { + const saved: Record = {}; + const servers: http.Server[] = []; + + async function serve(handler: Handler) { + const hits: Hit[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + hits.push({ method: req.method, url: req.url, headers: req.headers, body }); + handler(req, body, res); + }); + }); + await new Promise((ok) => server.listen(0, '127.0.0.1', ok)); + servers.push(server); + return { port: (server.address() as AddressInfo).port, hits }; + } + + const redirectTo = (location: string, status = 302): Handler => (_req, _body, res) => { + res.writeHead(status, { Location: location }); + res.end(); + }; + + beforeEach(() => { + for (const [k, v] of Object.entries(GUARD_ENV)) { + saved[k] = process.env[k]; + process.env[k] = v; + } + }); + + afterEach(async () => { + for (const k of Object.keys(GUARD_ENV)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + jest.restoreAllMocks(); + await Promise.all( + servers.splice(0).map( + (s) => new Promise((ok) => { + s.closeAllConnections(); + s.close(() => ok()); + }), + ), + ); + }); + + describe('axios with ssrfGuardedAxiosOptions', () => { + it('refuses a redirect to an internal IP and never contacts it', async () => { + const internal = await serve((_q, _b, res) => res.end('INTERNAL')); + const pub = await serve(redirectTo(`http://127.0.0.1:${internal.port}/meta`)); + await expect( + axios.get(`http://localhost:${pub.port}/`, ssrfGuardedAxiosOptions()), + ).rejects.toThrow(/not a public IP/); + expect(internal.hits).toHaveLength(0); + }); + + it('refuses a redirect to a name that resolves to an internal IP', async () => { + const internal = await serve((_q, _b, res) => res.end('INTERNAL')); + const pub = await serve(redirectTo(`http://inside.test:${internal.port}/`)); + const real = dns.promises.lookup; + jest.spyOn(dns.promises, 'lookup').mockImplementation(((host: string, opts: any) => + host === 'inside.test' + ? Promise.resolve([{ address: '127.0.0.1', family: 4 }]) + : real(host, opts)) as any); + await expect( + axios.get(`http://localhost:${pub.port}/`, ssrfGuardedAxiosOptions()), + ).rejects.toThrow(/non-public address '127\.0\.0\.1'/); + expect(internal.hits).toHaveLength(0); + }); + + it('still follows a redirect to an allowed host', async () => { + const target = await serve((_q, _b, res) => res.end('ok')); + const pub = await serve(redirectTo(`http://localhost:${target.port}/x`)); + const res = await axios.get(`http://localhost:${pub.port}/`, ssrfGuardedAxiosOptions()); + expect(res.data).toBe('ok'); + }); + + it('is inert when the guard is off (unit tests, SSRF_GUARD=disabled)', async () => { + process.env.SSRF_GUARD = 'disabled'; + const internal = await serve((_q, _b, res) => res.end('INTERNAL')); + const pub = await serve(redirectTo(`http://127.0.0.1:${internal.port}/`)); + const res = await axios.get(`http://localhost:${pub.port}/`, ssrfGuardedAxiosOptions()); + expect(res.data).toBe('INTERNAL'); + }); + }); + + describe('ssrfGuardedFetch', () => { + it('refuses a redirect to an internal IP and never contacts it', async () => { + const internal = await serve((_q, _b, res) => res.end('INTERNAL')); + const pub = await serve(redirectTo(`http://127.0.0.1:${internal.port}/meta`)); + await expect(ssrfGuardedFetch(`http://localhost:${pub.port}/`)).rejects.toThrow( + /not a public IP/, + ); + expect(internal.hits).toHaveLength(0); + }); + + it('follows an allowed redirect', async () => { + const target = await serve((_q, _b, res) => res.end('ok')); + const pub = await serve(redirectTo(`http://localhost:${target.port}/x`)); + const res = await ssrfGuardedFetch(`http://localhost:${pub.port}/`); + expect(await res.text()).toBe('ok'); + }); + + it('keeps method and body on 307, turns POST into GET on 302', async () => { + const target = await serve((_q, _b, res) => res.end('ok')); + const p307 = await serve(redirectTo(`http://localhost:${target.port}/a`, 307)); + const p302 = await serve(redirectTo(`http://localhost:${target.port}/b`, 302)); + await ssrfGuardedFetch(`http://localhost:${p307.port}/`, { method: 'POST', body: 'payload' }); + await ssrfGuardedFetch(`http://localhost:${p302.port}/`, { method: 'POST', body: 'payload' }); + expect(target.hits[0]).toMatchObject({ method: 'POST', url: '/a', body: 'payload' }); + expect(target.hits[1]).toMatchObject({ method: 'GET', url: '/b', body: '' }); + }); + + it('drops Authorization and Cookie when the redirect leaves the origin', async () => { + const other = await serve((_q, _b, res) => res.end('ok')); + const pub = await serve(redirectTo(`http://localhost:${other.port}/`)); + await ssrfGuardedFetch(`http://localhost:${pub.port}/`, { + headers: { Authorization: 'Bearer t', Cookie: 'a=b', Accept: 'application/json' }, + }); + expect(other.hits[0].headers.authorization).toBeUndefined(); + expect(other.hits[0].headers.cookie).toBeUndefined(); + expect(other.hits[0].headers.accept).toBe('application/json'); + }); + + it('stops after five redirects', async () => { + const loop = await serve(redirectTo('/again')); + await expect(ssrfGuardedFetch(`http://localhost:${loop.port}/`)).rejects.toThrow( + /Too many redirects/, + ); + }); + }); + + it("does not block the operator's own HTTP proxy", async () => { + const lookup = ssrfGuardedLookup({ + SSRF_GUARD: 'enabled', + HTTP_PROXY: 'http://localhost:3128', + } as NodeJS.ProcessEnv); + const addrs = await new Promise((resolve, reject) => + lookup('localhost', { all: true }, (err, a) => (err ? reject(err) : resolve(a))), + ); + expect(Array.isArray(addrs) && addrs.length > 0).toBe(true); + }); +}); diff --git a/packages/backend/src/common/guarded-http.util.ts b/packages/backend/src/common/guarded-http.util.ts new file mode 100644 index 00000000..562906c7 --- /dev/null +++ b/packages/backend/src/common/guarded-http.util.ts @@ -0,0 +1,95 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import * as http from 'http'; +import * as https from 'https'; +import { + assertSafeOutboundUrl, + assertSafeRedirectTarget, + createSsrfGuardedAgents, +} from './ssrf.util'; + +/** + * Wiring that makes an HTTP client keep the SSRF guard after the first check. + * + * Every outbound call to a user-supplied URL runs assertSafeOutboundUrl first. + * That check alone does not hold: axios and fetch follow redirects to hosts + * nobody checked, and resolve the name again when they connect. These helpers + * close both gaps for the two clients we use: + * - axios: agents that check the address at connect time, plus a + * `beforeRedirect` hook for the scheme and literal-IP targets. + * - fetch: redirects followed by hand, each target checked like the first. + */ + +let sharedAgents: { httpAgent: http.Agent; httpsAgent: https.Agent } | null = null; + +function guardedAgents() { + // The lookup reads process.env on every call, so one pair serves all. + sharedAgents ??= createSsrfGuardedAgents(); + return sharedAgents; +} + +function beforeRedirect(options: Record): void { + assertSafeRedirectTarget({ protocol: options.protocol, hostname: options.hostname }); +} + +/** + * Spread into any axios config that calls a user-supplied URL. A caller that + * needs its own agent (e.g. the operator's connector proxy) can set + * httpAgent/httpsAgent after the spread; `beforeRedirect` still applies. + */ +export function ssrfGuardedAxiosOptions(): Pick< + AxiosRequestConfig, + 'httpAgent' | 'httpsAgent' | 'beforeRedirect' +> { + const { httpAgent, httpsAgent } = guardedAgents(); + return { httpAgent, httpsAgent, beforeRedirect }; +} + +let sharedAxios: AxiosInstance | null = null; + +/** An axios instance with {@link ssrfGuardedAxiosOptions}, for libraries that take one (soap). */ +export function ssrfGuardedAxios(): AxiosInstance { + sharedAxios ??= axios.create(ssrfGuardedAxiosOptions()); + return sharedAxios; +} + +const MAX_FETCH_REDIRECTS = 5; + +/** + * A `fetch` that follows redirects itself and runs assertSafeOutboundUrl on + * every target. Same semantics as fetch otherwise: 307/308 keep method and + * body, 301/302/303 turn a non-GET into a body-less GET, and Authorization + * and Cookie are dropped when the redirect leaves the origin. + */ +export async function ssrfGuardedFetch( + input: string | URL, + init: RequestInit = {}, +): Promise { + let url = typeof input === 'string' ? input : input.toString(); + let current: RequestInit = { ...init }; + await assertSafeOutboundUrl(url); + for (let hop = 0; ; hop++) { + const res = await fetch(url, { ...current, redirect: 'manual' }); + const location = res.headers.get('location'); + if (![301, 302, 303, 307, 308].includes(res.status) || !location) return res; + if (init.redirect === 'manual') return res; + if (init.redirect === 'error') throw new TypeError(`Redirect refused for ${url}`); + if (hop >= MAX_FETCH_REDIRECTS) { + throw new TypeError(`Too many redirects from ${new URL(url).origin}`); + } + await res.body?.cancel().catch(() => undefined); + + const next = new URL(location, url).toString(); + await assertSafeOutboundUrl(next); + const method = (current.method ?? 'GET').toUpperCase(); + if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== 'GET' && method !== 'HEAD')) { + current = { ...current, method: 'GET', body: undefined }; + } + if (new URL(next).origin !== new URL(url).origin) { + const headers = new Headers(current.headers); + headers.delete('authorization'); + headers.delete('cookie'); + current = { ...current, headers }; + } + url = next; + } +} diff --git a/packages/backend/src/common/ssrf.util.ts b/packages/backend/src/common/ssrf.util.ts index 60ed4da1..1e51e289 100644 --- a/packages/backend/src/common/ssrf.util.ts +++ b/packages/backend/src/common/ssrf.util.ts @@ -65,6 +65,8 @@ function readPolicy(env: NodeJS.ProcessEnv = process.env): SsrfPolicy { * no-op when the service isn't wired (unit tests, scripts). */ let dbAllowedHostsProvider: (() => Promise) | null = null; +/** Last list the DB provider returned, for the synchronous redirect check. */ +let lastDbAllowedHosts: string[] = []; /** * Wire a DB-backed list provider into the guard. Called once by @@ -204,6 +206,7 @@ async function vetHost( if (dbAllowedHostsProvider) { try { const dbHosts = await dbAllowedHostsProvider(); + lastDbAllowedHosts = dbHosts; if (hostMatchesAllowlist(hostname, dbHosts)) return null; } catch { // Provider failure: fall through to IP-based checks rather than @@ -273,9 +276,12 @@ export function ssrfGuardedLookup( ): LookupFunction { return (hostname, options, callback) => { const policy = readPolicy(env); - const vetted = policy.enabled - ? vetHost(hostname, policy) - : Promise.resolve(null); + // The operator's own HTTP(S)_PROXY usually sits on a private address; + // when it is in use the proxy resolves the target, not us. + const vetted = + policy.enabled && !envProxyHosts(env).has(hostname.toLowerCase()) + ? vetHost(hostname, policy) + : Promise.resolve(null); vetted .then((addrs) => addrs ?? dns.lookup(hostname, { all: true })) .then((addrs) => { @@ -302,6 +308,52 @@ export function ssrfGuardedLookup( }; } +function envProxyHosts(env: NodeJS.ProcessEnv): Set { + const hosts = new Set(); + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) { + const value = env[key]; + if (!value) continue; + try { + hosts.add(new URL(value).hostname.toLowerCase()); + } catch { + /* not a URL: axios ignores it too */ + } + } + return hosts; +} + +/** + * Synchronous check for a redirect target, for HTTP clients whose redirect + * hook cannot wait (axios' `beforeRedirect`). Covers what a guarded lookup + * cannot see: the scheme, and literal IPs, which Node connects to without + * calling `lookup`. Hostnames are left to the lookup at connect time. + */ +export function assertSafeRedirectTarget( + target: { protocol?: string | null; hostname?: string | null }, + env: NodeJS.ProcessEnv = process.env, +): void { + const policy = readPolicy(env); + if (!policy.enabled) return; + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + throw new SsrfBlockedError( + `SSRF guard: protocol '${target.protocol}' is not allowed`, + ); + } + const hostname = (target.hostname ?? '').replace(/^\[|\]$/g, ''); + if (!isIP(hostname)) return; + if ( + hostMatchesAllowlist(hostname, policy.allowedHosts) || + hostMatchesAllowlist(hostname, lastDbAllowedHosts) + ) { + return; + } + if (!isPublicIp(hostname, policy)) { + throw new SsrfBlockedError( + `SSRF guard: address '${hostname}' is not a public IP`, + ); + } +} + /** * http and https agents whose connections go through {@link ssrfGuardedLookup}. * Pass both to axios (`httpAgent`, `httpsAgent`) together with `proxy: false`: @@ -312,9 +364,11 @@ export function createSsrfGuardedAgents(env: NodeJS.ProcessEnv = process.env): { httpsAgent: https.Agent; } { const lookup = ssrfGuardedLookup(env); + // Same socket reuse as Node's global agents, which these replace. + const options = { lookup, keepAlive: true, scheduling: 'lifo' as const, timeout: 5000 }; return { - httpAgent: new http.Agent({ lookup }), - httpsAgent: new https.Agent({ lookup }), + httpAgent: new http.Agent(options), + httpsAgent: new https.Agent(options), }; } diff --git a/packages/backend/src/connectors/connectors.service.ts b/packages/backend/src/connectors/connectors.service.ts index 17e9fee5..c0107756 100644 --- a/packages/backend/src/connectors/connectors.service.ts +++ b/packages/backend/src/connectors/connectors.service.ts @@ -395,7 +395,10 @@ export class ConnectorsService { // blocked-host variant, not just the DNS one — a private IP, 'localhost' // and an unresolvable Docker service name are all fixed by allowlisting, // and MCP bridges to a server on the local network hit exactly those. - const ssrfHostname = extractSsrfBlockedHostname(msg); + // Not in cloud: the allowlist there belongs to the operator (see + // SiteSettingsController), so the hint would point at a page that is gone. + const ssrfHostname = + process.env.DEPLOYMENT_MODE === 'cloud' ? undefined : extractSsrfBlockedHostname(msg); if (ssrfHostname) { return { ok: false, diff --git a/packages/backend/src/connectors/engines/graphql-schema.service.ts b/packages/backend/src/connectors/engines/graphql-schema.service.ts index 3a5c567e..d575d09b 100644 --- a/packages/backend/src/connectors/engines/graphql-schema.service.ts +++ b/packages/backend/src/connectors/engines/graphql-schema.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import axios from 'axios'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h @@ -54,6 +55,7 @@ export class GraphqlSchemaService { timeout: 30000, responseType: 'text', transformResponse: (v: unknown) => String(v), + ...ssrfGuardedAxiosOptions(), }); const sdl = String(res.data); this.cache.set(url, { sdl, fetchedAt: Date.now() }); diff --git a/packages/backend/src/connectors/engines/graphql.engine.ts b/packages/backend/src/connectors/engines/graphql.engine.ts index d441c4f2..e50228e4 100644 --- a/packages/backend/src/connectors/engines/graphql.engine.ts +++ b/packages/backend/src/connectors/engines/graphql.engine.ts @@ -8,6 +8,7 @@ import { } from './login-token.service'; import { GraphqlSchemaService } from './graphql-schema.service'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; /** * GraphqlEngine — executes GraphQL queries/mutations. @@ -170,7 +171,11 @@ export class GraphqlEngine { // request (incl. the 401-refresh retries below) through the // proxy / web-unblocker. The unblocker agent disables upstream TLS // verification (e.g. Zyte intercepts TLS) — see createUnblockerProxyAgent. - const axiosOpts: Record = { headers, timeout: 30000 }; + const axiosOpts: Record = { + headers, + timeout: 30000, + ...ssrfGuardedAxiosOptions(), + }; if (config.proxyUrl) { const agent = createUnblockerProxyAgent(config.proxyUrl); axiosOpts.httpsAgent = agent; diff --git a/packages/backend/src/connectors/engines/login-token.service.ts b/packages/backend/src/connectors/engines/login-token.service.ts index 4fc3e330..63b040d7 100644 --- a/packages/backend/src/connectors/engines/login-token.service.ts +++ b/packages/backend/src/connectors/engines/login-token.service.ts @@ -6,6 +6,7 @@ import { PrismaService } from '../../common/prisma.service'; import { encrypt, decrypt } from '../../common/crypto/encryption.util'; import { getRequiredSecret } from '../../common/secrets.util'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days const DEFAULT_PROACTIVE_REFRESH_SECONDS = 24 * 60 * 60; // 1 day @@ -207,6 +208,7 @@ export class LoginTokenService { params: method === 'GET' ? data : undefined, headers, timeout: 15000, + ...ssrfGuardedAxiosOptions(), }); let token: unknown; @@ -307,6 +309,7 @@ export class LoginTokenService { url, headers: src.headers, timeout: 10000, + ...ssrfGuardedAxiosOptions(), }); const path = src.responsePath || 'salt'; diff --git a/packages/backend/src/connectors/engines/mcp-client.engine.ts b/packages/backend/src/connectors/engines/mcp-client.engine.ts index 2e0e9eed..88dc6244 100644 --- a/packages/backend/src/connectors/engines/mcp-client.engine.ts +++ b/packages/backend/src/connectors/engines/mcp-client.engine.ts @@ -4,6 +4,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { OAuth2TokenService } from './oauth2-token.service'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; import { DEFAULT_MCP_PATH, resolveMcpEndpointUrl } from '../../common/url.util'; +import { ssrfGuardedFetch } from '../../common/guarded-http.util'; @Injectable() export class McpClientEngine { @@ -43,6 +44,7 @@ export class McpClientEngine { await this.injectAuth(headers, config.authType, config.authConfig, config.connectorId); const transport = new StreamableHTTPClientTransport(mcpUrl, { + fetch: ssrfGuardedFetch, requestInit: { headers }, }); @@ -78,6 +80,7 @@ export class McpClientEngine { retryHeaders['Authorization'] = `Bearer ${newToken}`; const retryTransport = new StreamableHTTPClientTransport(mcpUrl, { + fetch: ssrfGuardedFetch, requestInit: { headers: retryHeaders }, }); const retryClient = new Client({ @@ -137,6 +140,7 @@ export class McpClientEngine { await this.injectAuth(headers, config.authType, config.authConfig, config.connectorId); const transport = new StreamableHTTPClientTransport(mcpUrl, { + fetch: ssrfGuardedFetch, requestInit: { headers }, }); diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.ts b/packages/backend/src/connectors/engines/oauth2-token.service.ts index ffe18c66..ed169524 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.ts @@ -11,6 +11,7 @@ import { clientAssertionSettingsFrom, isPrivateKeyJwt, } from './client-assertion.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; /** Refresh tokens that expire within this window (5 minutes). */ const PROACTIVE_REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -283,6 +284,7 @@ export class OAuth2TokenService { { headers, timeout: 10000, + ...ssrfGuardedAxiosOptions(), }, ); diff --git a/packages/backend/src/connectors/engines/rest.engine.redirect.spec.ts b/packages/backend/src/connectors/engines/rest.engine.redirect.spec.ts new file mode 100644 index 00000000..2ee29a95 --- /dev/null +++ b/packages/backend/src/connectors/engines/rest.engine.redirect.spec.ts @@ -0,0 +1,72 @@ +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { RestEngine } from './rest.engine'; +import { OpenApiParser } from '../parsers/openapi.parser'; + +// Real axios, real sockets: a connector whose server answers with a redirect +// to an internal address must not get that address's answer back. `localhost` +// is allowlisted and plays the public API; 127.0.0.1 plays the internal host. +describe('outbound calls and redirects (real HTTP)', () => { + const saved = { guard: process.env.SSRF_GUARD, hosts: process.env.SSRF_ALLOWED_HOSTS }; + const servers: http.Server[] = []; + let internalHits = 0; + let internalPort = 0; + let apiPort = 0; + + async function listen(handler: http.RequestListener): Promise { + const server = http.createServer(handler); + await new Promise((ok) => server.listen(0, '127.0.0.1', ok)); + servers.push(server); + return (server.address() as AddressInfo).port; + } + + beforeAll(async () => { + process.env.SSRF_GUARD = 'enabled'; + process.env.SSRF_ALLOWED_HOSTS = 'localhost'; + internalPort = await listen((_req, res) => { + internalHits++; + res.setHeader('Content-Type', 'application/json'); + res.end('{"secret":"metadata"}'); + }); + apiPort = await listen((_req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${internalPort}/metadata/v1.json` }); + res.end(); + }); + }); + + afterAll(async () => { + process.env.SSRF_GUARD = saved.guard; + process.env.SSRF_ALLOWED_HOSTS = saved.hosts; + if (saved.guard === undefined) delete process.env.SSRF_GUARD; + if (saved.hosts === undefined) delete process.env.SSRF_ALLOWED_HOSTS; + await Promise.all( + servers.map( + (s) => new Promise((ok) => { + s.closeAllConnections(); + s.close(() => ok()); + }), + ), + ); + }); + + beforeEach(() => (internalHits = 0)); + + it('a REST tool call does not follow the redirect to the internal host', async () => { + const engine = new RestEngine({} as any, {} as any); + await expect( + engine.execute( + { baseUrl: `http://localhost:${apiPort}`, authType: 'NONE' }, + { method: 'GET', path: '/items' }, + {}, + ), + ).rejects.toThrow(/not a public IP/); + expect(internalHits).toBe(0); + }); + + it('an OpenAPI import from URL does not follow it either', async () => { + await expect( + new OpenApiParser().parseSpecFromUrl(`http://localhost:${apiPort}/openapi.json`), + ).rejects.toThrow(/not a public IP/); + expect(internalHits).toBe(0); + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index b6c4940c..57412fea 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -18,6 +18,7 @@ import { assertSafeOutboundUrl } from '../../common/ssrf.util'; import { assertNoUnresolvedPlaceholders } from '../../common/unresolved-placeholders.util'; import { XMLParser } from 'fast-xml-parser'; import { pickExposedHeaders } from './response-headers.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; /** * RestEngine — executes HTTP calls to REST APIs. @@ -176,6 +177,7 @@ export class RestEngine { ...resolvedEndpointHeaders, }, timeout: 30000, + ...ssrfGuardedAxiosOptions(), }; // Inject authentication diff --git a/packages/backend/src/connectors/engines/soap.engine.ts b/packages/backend/src/connectors/engines/soap.engine.ts index 3aa6ee77..7d11127c 100644 --- a/packages/backend/src/connectors/engines/soap.engine.ts +++ b/packages/backend/src/connectors/engines/soap.engine.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import * as soap from 'soap'; import { XMLParser } from 'fast-xml-parser'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { ssrfGuardedAxiosOptions, ssrfGuardedAxios } from '../../common/guarded-http.util'; /** * SoapEngine — executes SOAP calls using raw HTTP via axios. @@ -114,6 +115,7 @@ export class SoapEngine { timeout: 30000, // SOAP responses may have non-2xx status (SOAP faults return 500) validateStatus: (status) => status < 600, + ...ssrfGuardedAxiosOptions(), }); // Parse the SOAP response @@ -246,7 +248,9 @@ ${paramXml} }> { try { await assertSafeOutboundUrl(wsdlUrl); - const client = await soap.createClientAsync(wsdlUrl); + const client = await soap.createClientAsync(wsdlUrl, { + request: ssrfGuardedAxios() as any, + }); const wsdl = client.wsdl; const targetNamespace = diff --git a/packages/backend/src/connectors/mcp-oauth.service.spec.ts b/packages/backend/src/connectors/mcp-oauth.service.spec.ts index 0df99096..76c7a2c3 100644 --- a/packages/backend/src/connectors/mcp-oauth.service.spec.ts +++ b/packages/backend/src/connectors/mcp-oauth.service.spec.ts @@ -5,6 +5,7 @@ import { generateKeyPairSync, verify } from 'crypto'; jest.mock('axios'); // assertSafeOutboundUrl performs DNS/SSRF checks — stub it out for unit tests. jest.mock('../common/ssrf.util', () => ({ + ...jest.requireActual('../common/ssrf.util'), assertSafeOutboundUrl: jest.fn().mockResolvedValue(undefined), })); diff --git a/packages/backend/src/connectors/mcp-oauth.service.ts b/packages/backend/src/connectors/mcp-oauth.service.ts index 07eaf286..cb14c1a5 100644 --- a/packages/backend/src/connectors/mcp-oauth.service.ts +++ b/packages/backend/src/connectors/mcp-oauth.service.ts @@ -7,6 +7,7 @@ import { isPrivateKeyJwt, type ClientAssertionSettings, } from './engines/client-assertion.util'; +import { ssrfGuardedAxiosOptions } from '../common/guarded-http.util'; interface OAuthMetadata { issuer: string; @@ -184,7 +185,10 @@ export class McpOAuthService { private async fetchJson(url: string): Promise { await assertSafeOutboundUrl(url); - const response = await axios.get(url, { timeout: 10000 }); + const response = await axios.get(url, { + timeout: 10000, + ...ssrfGuardedAxiosOptions(), + }); return response.data; } @@ -245,7 +249,7 @@ export class McpOAuthService { response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', }, - { timeout: 10000 }, + { timeout: 10000, ...ssrfGuardedAxiosOptions() }, ); const clientId = response.data?.client_id; @@ -357,6 +361,7 @@ export class McpOAuthService { { headers, timeout: 10000, + ...ssrfGuardedAxiosOptions(), }, ); } catch (err: any) { diff --git a/packages/backend/src/connectors/parsers/graphql.parser.ts b/packages/backend/src/connectors/parsers/graphql.parser.ts index 1fb26cff..fd326503 100644 --- a/packages/backend/src/connectors/parsers/graphql.parser.ts +++ b/packages/backend/src/connectors/parsers/graphql.parser.ts @@ -3,6 +3,7 @@ import { ParsedTool } from './openapi.parser'; import { buildSchema, introspectionFromSchema } from 'graphql'; import axios from 'axios'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; const INTROSPECTION_QUERY = ` query IntrospectionQuery { @@ -68,6 +69,7 @@ export class GraphqlParser { { headers: { 'Content-Type': 'application/json', ...headers }, timeout: 15000, + ...ssrfGuardedAxiosOptions(), }, ); @@ -92,6 +94,7 @@ export class GraphqlParser { headers, timeout: 30000, responseType: 'text', + ...ssrfGuardedAxiosOptions(), }); return this.parseFromSdl(sdlResponse.data); } catch (err: any) { diff --git a/packages/backend/src/connectors/parsers/openapi.parser.ts b/packages/backend/src/connectors/parsers/openapi.parser.ts index 9b52c144..0709fd68 100644 --- a/packages/backend/src/connectors/parsers/openapi.parser.ts +++ b/packages/backend/src/connectors/parsers/openapi.parser.ts @@ -7,6 +7,7 @@ import axios from 'axios'; const yaml = require('js-yaml') as { load: (s: string) => unknown }; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; import { normalizeOpenApi31 } from './openapi-3.1-normalizer'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; export interface ParsedTool { name: string; @@ -191,7 +192,7 @@ export class OpenApiParser { this.logger.debug(`Fetching OpenAPI spec from: ${url}`); await assertSafeOutboundUrl(url); - const response = await axios.get(url, { timeout: 15000 }); + const response = await axios.get(url, { timeout: 15000, ...ssrfGuardedAxiosOptions() }); // If the response is already a valid spec object, parse directly if (typeof response.data === 'object' && response.data !== null) { @@ -241,7 +242,10 @@ export class OpenApiParser { this.logger.debug(`Found spec URL in HTML: ${specUrl}`); try { await assertSafeOutboundUrl(specUrl); - const specResp = await axios.get(specUrl, { timeout: 15000 }); + const specResp = await axios.get(specUrl, { + timeout: 15000, + ...ssrfGuardedAxiosOptions(), + }); return specResp.data; } catch { this.logger.debug(`Failed to fetch spec from extracted URL: ${specUrl}`); @@ -252,7 +256,10 @@ export class OpenApiParser { const initJsUrl = new URL('swagger-ui-init.js', pageUrl.endsWith('/') ? pageUrl : pageUrl + '/').href; try { await assertSafeOutboundUrl(initJsUrl); - const initResp = await axios.get(initJsUrl, { timeout: 15000 }); + const initResp = await axios.get(initJsUrl, { + timeout: 15000, + ...ssrfGuardedAxiosOptions(), + }); const initJs = typeof initResp.data === 'string' ? initResp.data : ''; // The spec is embedded as: let defined = { ... "swaggerDoc": { }, ... } const docMatch = initJs.match(/"swaggerDoc"\s*:\s*(\{[\s\S]+\})\s*,\s*"customOptions"/); @@ -290,7 +297,10 @@ export class OpenApiParser { try { const candidate = `${origin}${path}`; await assertSafeOutboundUrl(candidate); - const resp = await axios.get(candidate, { timeout: 5000 }); + const resp = await axios.get(candidate, { + timeout: 5000, + ...ssrfGuardedAxiosOptions(), + }); if ( typeof resp.data === 'object' && resp.data !== null && diff --git a/packages/backend/src/connectors/parsers/postman.parser.ts b/packages/backend/src/connectors/parsers/postman.parser.ts index 6c0cfc5b..c28bf4f0 100644 --- a/packages/backend/src/connectors/parsers/postman.parser.ts +++ b/packages/backend/src/connectors/parsers/postman.parser.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; import { ParsedTool } from './openapi.parser'; import { inferJsonSchema } from '../output-schema.util'; +import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; /** * Postman Collection v2.1 Parser. @@ -58,7 +59,10 @@ export class PostmanParser { this.logger.debug(`Detected Postman Documenter URL. Trying API: ${apiUrl}`); try { await assertSafeOutboundUrl(apiUrl); - const apiResp = await axios.get(apiUrl, { timeout: 15000 }); + const apiResp = await axios.get(apiUrl, { + timeout: 15000, + ...ssrfGuardedAxiosOptions(), + }); if (apiResp.data && typeof apiResp.data === 'object') { return this.parse(apiResp.data); } @@ -72,7 +76,10 @@ export class PostmanParser { } await assertSafeOutboundUrl(url); - const response = await axios.get(url, { timeout: 15000 }); + const response = await axios.get(url, { + timeout: 15000, + ...ssrfGuardedAxiosOptions(), + }); // Validate response is JSON, not HTML const contentType = (response.headers?.['content-type'] || '') as string; diff --git a/packages/backend/src/connectors/parsers/wsdl.parser.ts b/packages/backend/src/connectors/parsers/wsdl.parser.ts index 8ca5609b..c197f120 100644 --- a/packages/backend/src/connectors/parsers/wsdl.parser.ts +++ b/packages/backend/src/connectors/parsers/wsdl.parser.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { ParsedTool } from './openapi.parser'; import * as soap from 'soap'; +import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { ssrfGuardedAxios } from '../../common/guarded-http.util'; @Injectable() export class WsdlParser { @@ -9,7 +11,12 @@ export class WsdlParser { async parse(wsdlUrl: string): Promise { this.logger.debug(`Parsing WSDL from: ${wsdlUrl}`); - const client = await soap.createClientAsync(wsdlUrl); + // The URL comes from the user: check it, and keep checking WSDL/XSD + // imports and redirects through the guarded client. + await assertSafeOutboundUrl(wsdlUrl); + const client = await soap.createClientAsync(wsdlUrl, { + request: ssrfGuardedAxios() as any, + }); const description = client.describe(); const wsdl = client.wsdl; const tools: ParsedTool[] = []; diff --git a/packages/backend/src/settings/site-settings.controller.spec.ts b/packages/backend/src/settings/site-settings.controller.spec.ts new file mode 100644 index 00000000..7533ee8c --- /dev/null +++ b/packages/backend/src/settings/site-settings.controller.spec.ts @@ -0,0 +1,26 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { NotFoundException } from '@nestjs/common'; +import { SiteSettingsAdminController } from './site-settings.controller'; +import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; +import { DeploymentService } from '../common/deployment.service'; + +// The SSRF allowlist is instance-wide. In cloud every sign-up is the ADMIN of +// its own workspace, so the routes that read and write it must be closed there. +describe('SiteSettingsAdminController SSRF allowlist routes', () => { + const proto = SiteSettingsAdminController.prototype as any; + + it.each(['getSsrfAllowedHosts', 'setSsrfAllowedHosts'])( + '%s is self-hosted only', + (method) => { + const guards = Reflect.getMetadata(GUARDS_METADATA, proto[method]) ?? []; + expect(guards).toContain(SelfHostedOnlyGuard); + }, + ); + + it('the guard answers 404 in cloud and lets self-hosted through', () => { + const cloud = new SelfHostedOnlyGuard({ isSelfHosted: () => false } as DeploymentService); + const selfHosted = new SelfHostedOnlyGuard({ isSelfHosted: () => true } as DeploymentService); + expect(() => cloud.canActivate({} as any)).toThrow(NotFoundException); + expect(selfHosted.canActivate({} as any)).toBe(true); + }); +}); diff --git a/packages/backend/src/settings/site-settings.controller.ts b/packages/backend/src/settings/site-settings.controller.ts index c1c9a2af..34fef43f 100644 --- a/packages/backend/src/settings/site-settings.controller.ts +++ b/packages/backend/src/settings/site-settings.controller.ts @@ -18,6 +18,7 @@ import { SiteSettingsService } from './site-settings.service'; import { OrgSettingsService } from './org-settings.service'; import { EmailService } from './email.service'; import { SsrfPolicyService } from '../common/ssrf-policy.service'; +import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; class SmtpConfigDto { @ApiProperty({ description: 'SMTP server hostname.', example: 'smtp.sendgrid.net' }) @@ -193,7 +194,13 @@ export class SiteSettingsAdminController { return { message: 'Footer links saved' }; } + // The allowlist is instance-wide: a host added here is reachable by every + // connector of every workspace. That is the operator's call, and on a + // self-hosted instance the workspace ADMIN is the operator. In cloud every + // sign-up is the ADMIN of its own workspace, so the list is set through + // SSRF_ALLOWED_HOSTS on the server instead and these routes answer 404. @Get('ssrf-allowed-hosts') + @UseGuards(SelfHostedOnlyGuard) @ApiOperation({ summary: 'Get the admin-editable SSRF allowlist (ADMIN)', description: @@ -209,6 +216,7 @@ export class SiteSettingsAdminController { } @Put('ssrf-allowed-hosts') + @UseGuards(SelfHostedOnlyGuard) @ApiOperation({ summary: 'Replace the admin-editable SSRF allowlist (ADMIN)', description: diff --git a/packages/frontend/src/app/admin/settings/page.tsx b/packages/frontend/src/app/admin/settings/page.tsx index 890bf650..9338b802 100644 --- a/packages/frontend/src/app/admin/settings/page.tsx +++ b/packages/frontend/src/app/admin/settings/page.tsx @@ -8,7 +8,9 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; export default function AdminSettingsPage() { - const { token, user } = useAuth(); + const { token, user, deploymentMode, deploymentModeLoaded } = useAuth(); + // Instance-wide setting: the cloud operator manages it on the server. + const showSsrf = deploymentModeLoaded && deploymentMode !== 'cloud'; // SMTP const [smtpHost, setSmtpHost] = useState(''); @@ -44,12 +46,14 @@ export default function AdminSettingsPage() { adminSettings.getFooterLinks(token).then(setFooterLinks).catch(() => {}); - adminSettings.getSsrfAllowedHosts(token).then((data) => { - setSsrfHosts(data.hosts); - setSsrfEnvHosts(data.envHosts); - setSsrfDraft(data.hosts.join('\n')); - }).catch(() => {}); - }, [token]); + if (showSsrf) { + adminSettings.getSsrfAllowedHosts(token).then((data) => { + setSsrfHosts(data.hosts); + setSsrfEnvHosts(data.envHosts); + setSsrfDraft(data.hosts.join('\n')); + }).catch(() => {}); + } + }, [token, showSsrf]); const handleSaveSsrfHosts = async () => { if (!token) return; @@ -303,6 +307,7 @@ export default function AdminSettingsPage() { {/* SSRF Allowlist */} + {showSsrf && (

SSRF allowlist

@@ -353,6 +358,7 @@ export default function AdminSettingsPage() {

)}
+ )} ); From 7d5f63f6283821f39caca19dd56458844f8c2f2a Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 2 Oct 2026 18:21:07 +0200 Subject: [PATCH 2/2] ssrfGuardedFetch: check redirect targets only, not every request The MCP transport calls fetch for each message of a session; checking the starting URL there added one DNS lookup per message. The engine already checks that URL once per call, as every caller does. --- .../backend/src/common/guarded-http.util.spec.ts | 15 +++++++++++++++ packages/backend/src/common/guarded-http.util.ts | 11 +++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/common/guarded-http.util.spec.ts b/packages/backend/src/common/guarded-http.util.spec.ts index 63e3757f..507b15aa 100644 --- a/packages/backend/src/common/guarded-http.util.spec.ts +++ b/packages/backend/src/common/guarded-http.util.spec.ts @@ -142,6 +142,21 @@ describe('guarded HTTP clients', () => { expect(other.hits[0].headers.accept).toBe('application/json'); }); + it('does not re-check the starting URL (the caller does, once per call)', async () => { + const s = await serve((_q, _b, res) => res.end('ok')); + const spy = jest.spyOn(dns.promises, 'lookup'); + process.env.SSRF_ALLOW_LOCALHOST = 'true'; + delete process.env.SSRF_ALLOWED_HOSTS; + try { + for (let i = 0; i < 5; i++) { + await (await ssrfGuardedFetch(`http://localhost:${s.port}/mcp`, { method: 'POST', body: '{}' })).text(); + } + expect(spy).not.toHaveBeenCalled(); + } finally { + delete process.env.SSRF_ALLOW_LOCALHOST; + } + }); + it('stops after five redirects', async () => { const loop = await serve(redirectTo('/again')); await expect(ssrfGuardedFetch(`http://localhost:${loop.port}/`)).rejects.toThrow( diff --git a/packages/backend/src/common/guarded-http.util.ts b/packages/backend/src/common/guarded-http.util.ts index 562906c7..ceedb138 100644 --- a/packages/backend/src/common/guarded-http.util.ts +++ b/packages/backend/src/common/guarded-http.util.ts @@ -56,9 +56,13 @@ const MAX_FETCH_REDIRECTS = 5; /** * A `fetch` that follows redirects itself and runs assertSafeOutboundUrl on - * every target. Same semantics as fetch otherwise: 307/308 keep method and - * body, 301/302/303 turn a non-GET into a body-less GET, and Authorization - * and Cookie are dropped when the redirect leaves the origin. + * every redirect target. Same semantics as fetch otherwise: 307/308 keep + * method and body, 301/302/303 turn a non-GET into a body-less GET, and + * Authorization and Cookie are dropped when the redirect leaves the origin. + * + * The URL passed in is NOT checked here: the caller checks it once, as every + * caller already does. An MCP transport calls this for each message of a + * session, and a DNS lookup per message would be pure overhead. */ export async function ssrfGuardedFetch( input: string | URL, @@ -66,7 +70,6 @@ export async function ssrfGuardedFetch( ): Promise { let url = typeof input === 'string' ? input : input.toString(); let current: RequestInit = { ...init }; - await assertSafeOutboundUrl(url); for (let hop = 0; ; hop++) { const res = await fetch(url, { ...current, redirect: 'manual' }); const location = res.headers.get('location');