Skip to content
Merged
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
178 changes: 178 additions & 0 deletions packages/backend/src/common/guarded-http.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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<string, string | undefined> = {};
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<void>((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<void>((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('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(
/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);
});
});
98 changes: 98 additions & 0 deletions packages/backend/src/common/guarded-http.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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<string, any>): 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 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,
init: RequestInit = {},
): Promise<Response> {
let url = typeof input === 'string' ? input : input.toString();
let current: RequestInit = { ...init };
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;
}
}
64 changes: 59 additions & 5 deletions packages/backend/src/common/ssrf.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>) | 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -302,6 +308,52 @@ export function ssrfGuardedLookup(
};
}

function envProxyHosts(env: NodeJS.ProcessEnv): Set<string> {
const hosts = new Set<string>();
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`:
Expand All @@ -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),
};
}

Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/connectors/connectors.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading