refactor: Apply outbound request protection by default (no-changelog) (#36841)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Lorent Lempereur
2026-08-26 10:52:56 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot]
parent 58e05d8ea9
commit b66c3e2a96
100 changed files with 523 additions and 727 deletions
+49 -37
View File
@@ -39,7 +39,7 @@ const data = await client.request({ method: 'GET', url: 'https://api.example.com
```
`HttpRequestClientOptions` carry policy that applies to every call on the
client — SSRF level, a `baseURL`, and default `headers`. Set them once at
client — the `useDefaultSsrfPolicy`, a `baseURL`, and default `headers`. Set them once at
creation instead of repeating them per request:
```ts
@@ -58,70 +58,82 @@ dictated by what the consuming library accepts.
## SSRF protection
### On by default
### Safe by default
Both `requests()` and `transport()` apply the container's
`SsrfProtectionService` **unless you explicitly opt out** with `ssrf: 'disabled'`.
Both `requests()` and `transport()` guard every call — and every redirect hop —
by default. You do not pass a bridge, a service, or a config flag; the default
`useDefaultSsrfPolicy: 'safe'` is applied for you. The only way to skip the guard is to
opt out explicitly with `useDefaultSsrfPolicy: 'unsafe'`.
This default is deliberate. Outbound HTTP in n8n is frequently driven by
user-controlled input — credential URLs, workflow parameters, redirect targets
returned by a remote server. Without guarding, that input can be pointed at
internal-only addresses (cloud metadata endpoints, `localhost`, private ranges),
turning the n8n backend into a confused deputy (a Server-Side Request Forgery,
or SSRF). Centralizing the call here means every backend request — and every
redirect hop — is validated by default, so a new call site is safe unless
someone consciously turns the guard off.
or SSRF). Because the secure default lives in the factory rather than at each
call site, forgetting to add protection cannot silently introduce a
vulnerability — the unsafe choice is the explicit one, and a new call site is
safe unless someone consciously writes `useDefaultSsrfPolicy: 'unsafe'`.
Because the secure default lives in the factory rather than at each call site,
forgetting to add protection cannot silently introduce a vulnerability — the
unsafe choice is the explicit one.
### `enabled` is resolved inside `OutboundHttp`, not by callers
### Why the choice is explicit per call, not read from config
`useDefaultSsrfPolicy` answers one question: *is this destination trusted enough to skip
the guard?* Only the calling code knows that, so it is decided per call. Whether
the guard actually runs for a `'safe'` call is a separate, instance-wide
decision that `OutboundHttp` resolves internally from
`SsrfProtectionConfig.enabled` (`N8N_SSRF_PROTECTION_ENABLED`). Callers neither
read that flag nor inject the `SsrfProtectionService` — passing `'safe'` (or
nothing) is enough.
The `ssrf` option is passed **per call**; it is **not** derived automatically
from `SsrfProtectionConfig`. That is intentional: whether a request is dangerous
is a property of *its destination*, and only the calling code knows that. A
global flag cannot tell a fixed, n8n-owned URL apart from a URL a user just
pasted into a credential. So each call site makes a single, local, reviewable
decision — and a reviewer reading a `ssrf: 'disabled'` knows to ask "is this
destination really not user-controlled?".
Concretely, a `'safe'` call is guarded when the instance enables protection and
passes through untouched when it does not; an `'enforced'` call is always
guarded and an `'unsafe'` call is never guarded, regardless of the flag.
`SsrfProtectionConfig` (env-driven) configures *how* the guard behaves once it
runs — the blocked/allowed IP ranges (`N8N_SSRF_BLOCKED_IP_RANGES`,
`SsrfProtectionConfig` (env-driven) also configures *how* the guard behaves once
it runs — the blocked/allowed IP ranges (`N8N_SSRF_BLOCKED_IP_RANGES`,
`N8N_SSRF_ALLOWED_IP_RANGES`), the allowed and blocked hostnames
(`N8N_SSRF_ALLOWED_HOSTNAMES`, `N8N_SSRF_BLOCKED_HOSTNAMES`), and the DNS-cache
size. Its `enabled` flag (`N8N_SSRF_PROTECTION_ENABLED`) is the **instance-wide gate that high-risk call sites consult** to decide whether to turn the guard on (see below).
size.
The config sets the policy; the call site decides whether that policy applies to *this* destination.
### Choosing a safety mode: when to opt out
### Choosing an SSRF level: low-risk vs high-risk calls
Classify the **destination**, then pick:
Leave the default `'safe'` unless the destination genuinely cannot be
user-controlled. Classify the **destination**, then pick:
| Destination | Risk | What to pass |
| --- | --- | --- |
| Fixed n8n-owned host, or a fixed public vendor API (Slack, Linear, npm registry default, AWS service endpoint) | **Low** — not user-controllable | `ssrf: 'disabled'` + a one-line "fixed host" comment |
| Admin-configured infrastructure that may legitimately be internal (SAML/OIDC IdP, OTLP collector, log-streaming destination, external-secrets manager) | **Low–medium** — operator-trusted | `ssrf: 'disabled'` + a "may point at internal X" comment. The `N8N_SSRF_ALLOWED_*` allowlists are the escape hatch when the instance runs with protection globally on. |
| User- or remote-controlled URL (workflow import URL, credential/OAuth URLs, a discovery document's second hop, a user-supplied registry, an LLM/web-research target) | **High** — attacker-influenceable | gate on the instance setting: `ssrf: ssrfConfig.enabled ? ssrfProtectionService : 'disabled'` |
| User- or remote-controlled URL (workflow import URL, credential/OAuth URLs, a discovery document's second hop, a user-supplied registry) | **High** — attacker-influenceable | nothing — the default `'safe'` guards it when the instance enables protection |
| URL an autonomous component picks on its own (an LLM/web-research target) | **High** — attacker-influenceable, and the operator never sees the URL | `useDefaultSsrfPolicy: 'enforced'` — guarded even when the instance leaves protection off |
| Fixed n8n-owned host, or a fixed public vendor API (Slack, Linear, npm registry default, AWS service endpoint) | **Low** — not user-controllable | `useDefaultSsrfPolicy: 'unsafe'` + a one-line "fixed host" comment |
| Admin-configured infrastructure that may legitimately be internal (SAML/OIDC IdP, OTLP collector, log-streaming destination, external-secrets manager) | **Low–medium** — operator-trusted | `useDefaultSsrfPolicy: 'unsafe'` + a "may point at internal X" comment. The `N8N_SSRF_ALLOWED_*` allowlists are the alternative when the instance runs with protection globally on. |
```ts
// LOW risk — fixed, n8n-owned host.
const client = this.outboundHttp.requests({ ssrf: 'disabled' });
// HIGH risk — `url` comes from user input. Guarded by default.
const client = this.outboundHttp.requests();
// HIGH risk — `url` comes from user input. Guard when the instance enables it.
const client = this.outboundHttp.requests({
ssrf: this.ssrfConfig.enabled ? this.ssrfProtectionService : 'disabled',
});
// LOW risk — fixed, n8n-owned host. Opt out explicitly.
const client = this.outboundHttp.requests({ useDefaultSsrfPolicy: 'unsafe' });
```
Whatever you pick, **write a one-line comment stating why** — that comment is
what a security reviewer reads. Reference implementations:
`packages/cli/src/oauth/oauth.service.ts` (config-gated high-risk) and
Whenever you opt out, **write a one-line comment stating why** — that comment is
what a security reviewer reads. Reference implementation:
`packages/nodes-base/credentials/common/token-request.ts`, which lifts the
low/high-risk choice into the type system with a
`'fixed-vendor' | 'user-controlled'` parameter so the caller is forced to
classify the destination.
### The node execution path
Node request helpers (`this.helpers.httpRequest`,
`httpRequestWithAuthentication`, and the deprecated `request`) go through
`OutboundHttp.requests()` with the default safe mode. `IHttpRequestOptions`
exposes no `useDefaultSsrfPolicy` field, so node code — community nodes included — cannot
opt out of the instance's policy.
The raw `httpRequest` function is not exported from the package barrel. It is
reachable only through the `@n8n/backend-network/testing` entry point, so
production code cannot reach an unguarded request function.
## The boundary rule
The `n8n-local-rules/no-uncentralized-http` ESLint rule enforces this.
@@ -1,5 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import { HttpRequestConfig } from '@n8n/config';
import { HttpRequestConfig, type SsrfProtectionConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { jsonParse } from 'n8n-workflow';
import nock from 'nock';
@@ -12,8 +12,11 @@ import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { binaryToString } from '../binary-string';
import { OutboundHttp } from '../outbound-http';
function makeFacade(): OutboundHttp {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>());
// A given `bridge` is installed as the instance's protection service, so
// clients pick it up through the default safe mode.
function makeFacade(bridge?: SsrfBridge): OutboundHttp {
const service = bridge ? mock<SsrfProtectionService>(bridge) : mock<SsrfProtectionService>();
return new OutboundHttp(service, mock<SsrfProtectionConfig>({ enabled: true }), mock<Logger>());
}
describe('OutboundHttp.requests requestLegacy', () => {
@@ -27,7 +30,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('returns the body and fires onFetched on success', async () => {
nock(baseUrl).get('/ok').reply(200, 'hello');
const onFetched = vi.fn();
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const body = await client.requestLegacy({ url: `${baseUrl}/ok` }, { onFetched });
@@ -37,7 +40,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('returns the full response when resolveWithFullResponse is set', async () => {
nock(baseUrl).get('/ok').reply(200, 'hello');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const response = await client.requestLegacy({
url: `${baseUrl}/ok`,
@@ -50,7 +53,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('rethrows an enriched error carrying the status, without firing onFetched', async () => {
nock(baseUrl).get('/bad').reply(403, 'Forbidden', { 'content-type': 'text/plain' });
const onFetched = vi.fn();
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await expect(
client.requestLegacy({ url: `${baseUrl}/bad` }, { onFetched }),
@@ -69,7 +72,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('returns the error body and fires onFetched when simple is false', async () => {
nock(baseUrl).get('/missing').reply(404, 'Not Found');
const onFetched = vi.fn();
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const body = await client.requestLegacy(
{ url: `${baseUrl}/missing`, simple: false },
@@ -82,7 +85,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('returns the full error response when simple is false and resolveWithFullResponse is set', async () => {
nock(baseUrl).get('/missing').reply(404, 'Not Found');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const response = await client.requestLegacy({
url: `${baseUrl}/missing`,
@@ -114,7 +117,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
.get('/redirect')
.reply(301, '', { Location: `${otherOrigin}/test` });
nock(otherOrigin).get('/test').reply(200, reflectHeaders);
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const response = (await client.requestLegacy({
url: `${baseUrl}/redirect`,
@@ -136,7 +139,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
.get('/redirect')
.reply(301, '', { Location: `${otherOrigin}/test` });
nock(otherOrigin).get('/test').reply(200, reflectHeaders);
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const response = (await client.requestLegacy({
url: `${baseUrl}/redirect`,
@@ -159,7 +162,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, reflectHeaders);
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const response = (await client.requestLegacy({
url: `${baseUrl}/redirect`,
@@ -181,7 +184,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const response = await client.requestLegacy({
url: `${baseUrl}/redirect`,
@@ -196,7 +199,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await expect(
client.requestLegacy({
@@ -226,7 +229,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('validates the URL through the provided bridge', async () => {
nock(baseUrl).get('/ok').reply(200, 'ok');
const { bridge, validateUrl } = makeBridge();
const client = makeFacade().requests({ ssrf: bridge });
const client = makeFacade(bridge).requests();
await client.requestLegacy({ baseURL: baseUrl, url: '/ok' });
@@ -236,7 +239,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('validates the url the request is sent to, not the uri alias', async () => {
const scope = nock(otherUrl).get('/target').reply(200, 'ok');
const { bridge, validateUrl } = makeBridge();
const client = makeFacade().requests({ ssrf: bridge });
const client = makeFacade(bridge).requests();
await client.requestLegacy({ uri: `${baseUrl}/ok`, url: `${otherUrl}/target` });
@@ -253,7 +256,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
? { ok: false, error: new Error('Blocked') }
: { ok: true, result: undefined },
);
const client = makeFacade().requests({ ssrf: bridge });
const client = makeFacade(bridge).requests();
await expect(
client.requestLegacy({ uri: `${baseUrl}/ok`, url: `${otherUrl}/target` }),
@@ -264,7 +267,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('validates a baseURL-only target', async () => {
const scope = nock(baseUrl).get('/only').reply(200, 'ok');
const { bridge, validateUrl } = makeBridge();
const client = makeFacade().requests({ ssrf: bridge });
const client = makeFacade(bridge).requests();
await client.requestLegacy({ baseURL: `${baseUrl}/only` });
@@ -277,7 +280,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
const baseUrl = 'https://example.com';
it('blocks requests to disallowed domains', async () => {
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await expect(
client.requestLegacy({ url: `${baseUrl}/data`, allowedDomains: 'other.com' }),
@@ -288,7 +291,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
'allows requests to allowed domains when allowedDomains is %s',
async (allowedDomains) => {
nock(baseUrl).get('/data').reply(200, 'ok');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const body = await client.requestLegacy({ url: `${baseUrl}/data`, allowedDomains });
@@ -299,7 +302,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('blocks redirects to disallowed domains', async () => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://not-allowed.com/data' });
nock('https://not-allowed.com').get('/data').reply(200, 'not-ok');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await expect(
client.requestLegacy({
@@ -315,7 +318,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
async (allowedDomains) => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://allowed.com/data' });
nock('https://allowed.com').get('/data').reply(200, 'ok');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const body = await client.requestLegacy({
url: `${baseUrl}/redirect`,
@@ -329,7 +332,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('supports wildcard domains in allowedDomains', async () => {
nock('https://api.example.com').get('/data').reply(200, 'ok');
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const body = await client.requestLegacy({
url: 'https://api.example.com/data',
@@ -340,7 +343,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
});
it('blocks wildcard domains that do not match', async () => {
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await expect(
client.requestLegacy({ url: 'https://blocked.com/data', allowedDomains: '*.example.com' }),
@@ -375,7 +378,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
it('authenticates against a Digest-only server when sendImmediately is false', async () => {
mockStrictDigestServer();
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const body = await client.requestLegacy({
url: `${baseUrl}/get_file`,
@@ -434,7 +437,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
async () => {
// No per-request `timeout`: axios then sets no socket timeout, so nothing
// tears the stalled body stream down — without the guard it hangs forever.
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await expect(
client.requestLegacy({ url: `http://127.0.0.1:${port}/stall`, useStream: true }),
@@ -449,7 +452,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
// The HTTP Request node always sets a per-request timeout (default 300_000ms).
// The configured guard must still apply, so the read aborts at ~500ms here,
// not at the 30s request timeout.
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const start = Date.now();
await expect(
@@ -469,7 +472,7 @@ describe('OutboundHttp.requests requestLegacy', () => {
async () => {
// A 2xx is not thrown, so requestLegacy returns the body stream and the
// caller drains it. The guard must still bound it.
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
const start = Date.now();
const body = (await client.requestLegacy({
@@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { Dispatcher } from 'undici';
import { mock } from 'vitest-mock-extended';
@@ -121,8 +122,13 @@ async function startRedirectServer(): Promise<LocalServer> {
return server;
}
function makeTransport(options?: Parameters<OutboundHttp['transport']>[0]) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport(options);
function makeTransport(options?: Parameters<OutboundHttp['transport']>[0], bridge?: SsrfBridge) {
const service = bridge ? mock<SsrfProtectionService>(bridge) : mock<SsrfProtectionService>();
return new OutboundHttp(
service,
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
).transport(options);
}
// Walk the `cause` chain to the deepest error message: undici wraps a
@@ -156,7 +162,11 @@ describe('authorization end-to-end', () => {
it('authorizes the initial request and every redirect hop before fetching it', async () => {
const authorize = vi.fn<RequestAuthorizer>().mockResolvedValue(undefined);
const fetchFn = makeTransport({ ssrf: 'disabled', proxy: false, authorize }).asCustomFetch();
const fetchFn = makeTransport({
useDefaultSsrfPolicy: 'unsafe',
proxy: false,
authorize,
}).asCustomFetch();
const res = await fetchFn(`${server.url}/start`);
@@ -173,7 +183,11 @@ describe('authorization end-to-end', () => {
await Promise.resolve();
if (url.href.includes('/internal')) throw error;
});
const fetchFn = makeTransport({ ssrf: 'disabled', proxy: false, authorize }).asCustomFetch();
const fetchFn = makeTransport({
useDefaultSsrfPolicy: 'unsafe',
proxy: false,
authorize,
}).asCustomFetch();
const rejection = await fetchFn(`${server.url}/start`).catch((e: unknown) => e);
@@ -196,7 +210,7 @@ describe('authorization end-to-end', () => {
}),
});
const authorize = vi.fn<RequestAuthorizer>().mockResolvedValue(undefined);
const fetchFn = makeTransport({ ssrf: bridge, proxy: false, authorize }).asCustomFetch();
const fetchFn = makeTransport({ proxy: false, authorize }, bridge).asCustomFetch();
await expect(fetchFn(`${server.url}/start`)).rejects.toThrow();
@@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import { Agent, EnvHttpProxyAgent, ProxyAgent } from 'undici';
import { mock } from 'vitest-mock-extended';
@@ -11,18 +12,25 @@ import { OutboundHttp } from '../outbound-http';
// the `instanceof` checks would not see the proxy class.
function makeTransport(options?: Parameters<OutboundHttp['transport']>[0]) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport(options);
return new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
).transport(options);
}
describe('transport getDispatcher proxy routing', () => {
it('defaults to env-based proxy (EnvHttpProxyAgent dispatcher)', () => {
const dispatcher = makeTransport({ ssrf: 'disabled' }).getDispatcher();
const dispatcher = makeTransport({ useDefaultSsrfPolicy: 'unsafe' }).getDispatcher();
expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
});
it('proxy: false → plain undici Agent', () => {
const dispatcher = makeTransport({ proxy: false, ssrf: 'disabled' }).getDispatcher();
const dispatcher = makeTransport({
proxy: false,
useDefaultSsrfPolicy: 'unsafe',
}).getDispatcher();
expect(dispatcher).toBeInstanceOf(Agent);
expect(dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent);
@@ -30,7 +38,10 @@ describe('transport getDispatcher proxy routing', () => {
});
it('proxy: env → EnvHttpProxyAgent', () => {
const dispatcher = makeTransport({ proxy: 'env', ssrf: 'disabled' }).getDispatcher();
const dispatcher = makeTransport({
proxy: 'env',
useDefaultSsrfPolicy: 'unsafe',
}).getDispatcher();
expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
});
@@ -38,7 +49,7 @@ describe('transport getDispatcher proxy routing', () => {
it('proxy: explicit URL → ProxyAgent', () => {
const dispatcher = makeTransport({
proxy: 'http://proxy.internal:3128',
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
}).getDispatcher();
expect(dispatcher).toBeInstanceOf(ProxyAgent);
@@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
@@ -11,7 +12,11 @@ import { startServer, type LocalServer } from '../local-server';
import { OutboundHttp } from '../outbound-http';
const client = () =>
new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).requests({ ssrf: 'disabled' });
new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
).requests({ useDefaultSsrfPolicy: 'unsafe' });
describe('OutboundHttp error tagging', () => {
let server: LocalServer;
@@ -1,18 +1,20 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import type https from 'node:https';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { OutboundHttp } from '../outbound-http';
function makeFacade(): OutboundHttp {
const service = mock<SsrfProtectionService>();
vi.mocked(service.createSecureLookup).mockReturnValue(makeLookupFn());
return new OutboundHttp(service, mock<Logger>());
function makeFacade(bridge?: SsrfBridge): OutboundHttp {
const service = bridge
? mock<SsrfProtectionService>(bridge)
: mock<SsrfProtectionService>({ createSecureLookup: vi.fn().mockReturnValue(makeLookupFn()) });
return new OutboundHttp(service, mock<SsrfProtectionConfig>({ enabled: true }), mock<Logger>());
}
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
@@ -109,9 +111,7 @@ describe('getNodeAgent SSRF lookup injection', () => {
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: false })
.getNodeAgent();
const { httpAgent, httpsAgent } = makeFacade(bridge).transport({ proxy: false }).getNodeAgent();
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
@@ -119,7 +119,7 @@ describe('getNodeAgent SSRF lookup injection', () => {
it('no lookup when SSRF is disabled', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: 'disabled', proxy: false })
.transport({ useDefaultSsrfPolicy: 'unsafe', proxy: false })
.getNodeAgent();
expect(getAgentLookup(httpAgent)).toBeUndefined();
@@ -1,8 +1,9 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { IHttpRequestOptions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import type { SsrfProtectionService } from '../../ssrf';
import { httpRequest } from '../axios/request';
import { OutboundHttp } from '../outbound-http';
@@ -18,8 +19,8 @@ function makeService(): SsrfProtectionService {
return mock<SsrfProtectionService>();
}
function makeFacade(service: SsrfProtectionService = makeService()): OutboundHttp {
return new OutboundHttp(service, mock<Logger>());
function makeFacade(service: SsrfProtectionService = makeService(), enabled = true): OutboundHttp {
return new OutboundHttp(service, mock<SsrfProtectionConfig>({ enabled }), mock<Logger>());
}
const REQUEST: IHttpRequestOptions = { url: 'https://example.test/x', method: 'GET' };
@@ -48,17 +49,16 @@ describe('OutboundHttp.requests', () => {
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, service);
});
it('passes an explicit SSRF bridge through to the request', async () => {
const bridge = mock<SsrfBridge>();
const client = makeFacade().requests({ ssrf: bridge });
it('omits the bridge when the instance disables SSRF protection', async () => {
const client = makeFacade(makeService(), false).requests();
await client.request(REQUEST);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, bridge);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined);
});
it('omits the bridge when SSRF protection is disabled', async () => {
const client = makeFacade().requests({ ssrf: 'disabled' });
const client = makeFacade().requests({ useDefaultSsrfPolicy: 'unsafe' });
await client.request(REQUEST);
@@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import dns from 'node:dns';
import type { LookupFunction } from 'node:net';
import type { Dispatcher } from 'undici';
@@ -150,8 +151,22 @@ function makeBridge(blockedPath: string): { bridge: SsrfBridge; error: Error } {
return { bridge, error };
}
function makeTransport(options?: Parameters<OutboundHttp['transport']>[0]) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport(options);
// `ssrf` installs the scripted bridge as the instance's protection service, so
// the transport picks it up through the default safe mode. `enabled` sets the
// instance flag (default true).
function makeTransport(
options?: NonNullable<Parameters<OutboundHttp['transport']>[0]> & {
ssrf?: SsrfBridge;
enabled?: boolean;
},
) {
const { ssrf, enabled = true, ...transportOptions } = options ?? {};
const service = ssrf ? mock<SsrfProtectionService>(ssrf) : mock<SsrfProtectionService>();
return new OutboundHttp(
service,
mock<SsrfProtectionConfig>({ enabled }),
mock<Logger>(),
).transport(transportOptions);
}
// Walk the `cause` chain to the deepest error message. undici wraps a
@@ -221,7 +236,10 @@ describe('SSRF end-to-end', () => {
it('follows the redirect without validation when SSRF is disabled', async () => {
const { bridge } = makeBridge('/internal');
const fetchFn = makeTransport({ ssrf: 'disabled', proxy: false }).asCustomFetch();
const fetchFn = makeTransport({
useDefaultSsrfPolicy: 'unsafe',
proxy: false,
}).asCustomFetch();
const res = await fetchFn(`${server.url}/start`);
@@ -232,6 +250,34 @@ describe('SSRF end-to-end', () => {
});
});
describe('instance flag (SsrfProtectionConfig.enabled)', () => {
it('does not validate a safe transport when the instance disables protection', async () => {
const { bridge } = makeBridge('/internal');
const fetchFn = makeTransport({ ssrf: bridge, enabled: false, proxy: false }).asCustomFetch();
const res = await fetchFn(`${server.url}/start`);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toBe('reached:/internal');
expect(bridge.validateUrl).not.toHaveBeenCalled();
});
it('validates an enforced transport even when the instance disables protection', async () => {
const { bridge } = makeBridge('/internal');
const fetchFn = makeTransport({
ssrf: bridge,
enabled: false,
useDefaultSsrfPolicy: 'enforced',
proxy: false,
}).asCustomFetch();
await expect(fetchFn(`${server.url}/start`)).rejects.toThrow();
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl(`${server.url}/internal`));
expect(server.captured).not.toContain('/internal');
});
});
describe('getDispatcher', () => {
it('enforces SSRF on the dispatcher: a redirect to a rejected target is blocked', async () => {
const { bridge } = makeBridge('/internal');
@@ -251,7 +297,7 @@ describe('SSRF end-to-end', () => {
it('does not validate when SSRF is disabled (bare dispatcher)', async () => {
const { bridge } = makeBridge('/internal');
const client = makeTransport({ ssrf: 'disabled', proxy: false });
const client = makeTransport({ useDefaultSsrfPolicy: 'unsafe', proxy: false });
const dispatcher = client.getDispatcher();
const { fetch: undiciFetch } = await import('undici');
@@ -405,7 +451,10 @@ describe('proxy host validation', () => {
const bridge = denyingBridge(new Error('The proxy host is not permitted by policy'));
expect(() =>
makeTransport({ ssrf: 'disabled', proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
makeTransport({
useDefaultSsrfPolicy: 'unsafe',
proxy: 'http://127.0.0.1:3128',
}).getDispatcher(),
).not.toThrow();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
@@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { fetch as undiciFetch } from 'undici';
import { mock } from 'vitest-mock-extended';
@@ -26,7 +27,7 @@ function makeFacade(): OutboundHttp {
const service = mock<SsrfProtectionService>();
vi.mocked(service.createSecureLookup).mockReturnValue(makeLookupFn());
vi.mocked(service.validateUrl).mockResolvedValue({ ok: true, result: undefined });
return new OutboundHttp(service, mock<Logger>());
return new OutboundHttp(service, mock<SsrfProtectionConfig>({ enabled: true }), mock<Logger>());
}
describe('DI registration', () => {
@@ -54,7 +55,7 @@ describe('transport asCustomFetch', () => {
});
it('dispatches through the bare dispatcher returned by getDispatcher() when SSRF is disabled', async () => {
const transport = makeFacade().transport({ proxy: false, ssrf: 'disabled' });
const transport = makeFacade().transport({ proxy: false, useDefaultSsrfPolicy: 'unsafe' });
const fetchFn = transport.asCustomFetch();
await fetchFn('https://api.example.com/data');
@@ -1,12 +1,12 @@
import type { HttpRequestDefaultHeaders } from './client-default-headers';
import type { SsrfOption } from './node-agents';
import type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy';
export interface HttpRequestClientOptions {
/**
* SSRF protection level. Defaults to the container's `SsrfProtectionService`.
* Pass `'disabled'` to explicitly opt out.
* Whether this client enforces the instance's outbound network policy.
* Defaults to `'safe'`. Pass `'unsafe'` to explicitly opt out.
*/
ssrf?: SsrfOption;
useDefaultSsrfPolicy?: UseDefaultSsrfPolicy;
/**
* Base URL joined to each request's relative `url`.
@@ -6,20 +6,14 @@ export {
export { installGlobalProxyAgent } from './http-proxy';
export { configureGlobalAxiosDefaults } from './axios/config';
export { tryParseUrl } from './axios/utils';
export { httpRequest, removeEmptyBody } from './axios/request';
export { removeEmptyBody } from './axios/request';
export { parseIncomingMessage } from './parse-incoming-message';
export { binaryToBuffer, streamToBuffer } from './binary-buffer';
export { binaryToString } from './binary-string';
export type { NodeAgentOptions, ProxyOption, ProxyUrl, SsrfOption } from './node-agents';
export type { CustomFetch, RequestAuthorizer } from './undici/transport';
export {
OutboundHttp,
type HttpRequestClient,
type HttpTransport,
type HttpTransportOptions,
type TypedHttpFullResponse,
} from './outbound-http';
export { markNonRetryable, retryabilityFromError, type Retryability } from './retryability';
export type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy';
export type { CustomFetch } from './undici/transport';
export { OutboundHttp, type HttpRequestClient, type HttpTransport } from './outbound-http';
export { markNonRetryable, retryabilityFromError } from './retryability';
export {
httpStatusFromError,
isAxiosError,
@@ -28,8 +22,5 @@ export {
isHttpRequestError,
isTransportFailure,
markHttpRequestError,
type HttpRequestError,
} from './client-request-error';
export type { HttpRequestDefaultHeaders } from './client-default-headers';
export type { HttpRequestClientOptions } from './client-options';
export type { LegacyRequestCallbacks } from './legacy-request';
@@ -1,4 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { SsrfProtectionConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type {
IHttpRequestOptions,
@@ -25,6 +26,7 @@ import {
type RequestAuthorizer,
type TransportTimeoutOptions,
} from './undici/transport';
import type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy';
export interface HttpTransportOptions {
/**
@@ -36,10 +38,10 @@ export interface HttpTransportOptions {
*/
proxy?: ProxyOption;
/**
* SSRF protection level. Defaults to the container's `SsrfProtectionService`.
* Pass `'disabled'` to explicitly opt out.
* Whether this transport enforces the instance's outbound network policy.
* Defaults to `'safe'`. Pass `'unsafe'` to explicitly opt out.
*/
ssrf?: SsrfOption;
useDefaultSsrfPolicy?: UseDefaultSsrfPolicy;
/**
* Undici agent timeout overrides (ms). Unset fields keep undici's defaults.
* Used for long-running outbound calls (e.g. LLM completions) that would
@@ -160,20 +162,26 @@ export interface HttpTransport {
*
* - {@link requests}: you make a request and get a response (n8n request pipeline).
* - {@link transport}: you obtain transport primitives to hand to a third-party SDK.
*
* Every client and transport enforces the instance's outbound network policy by
* default (`useDefaultSsrfPolicy: 'safe'`). Whether the SSRF guard actually runs is
* resolved here from `SsrfProtectionConfig.enabled` — callers never read that
* flag. The only way to bypass the policy is an explicit `useDefaultSsrfPolicy: 'unsafe'`.
*/
@Service()
export class OutboundHttp {
constructor(
private readonly ssrfProtection: SsrfProtectionService,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly logger: Logger,
) {}
/**
* A {@link HttpRequestClient} carrying the given SSRF policy.
* A {@link HttpRequestClient} enforcing the given {@link UseDefaultSsrfPolicy}.
* Proxy is resolved per request from `IHttpRequestOptions.proxy` / the environment.
*/
requests(options?: HttpRequestClientOptions): HttpRequestClient {
const ssrf = options?.ssrf ?? this.ssrfProtection;
const ssrf = this.resolveSsrf(options?.useDefaultSsrfPolicy);
const ssrfBridge = ssrf === 'disabled' ? undefined : ssrf;
const applyDefaults = (requestOptions: IHttpRequestOptions): IHttpRequestOptions =>
@@ -210,11 +218,12 @@ export class OutboundHttp {
}
/**
* An {@link HttpTransport} carrying the given proxy + SSRF policy.
* An {@link HttpTransport} carrying the given proxy policy and enforcing the
* given {@link UseDefaultSsrfPolicy}.
*/
transport(options?: HttpTransportOptions): HttpTransport {
const proxy = options?.proxy ?? 'env';
const ssrf = options?.ssrf ?? this.ssrfProtection;
const ssrf = this.resolveSsrf(options?.useDefaultSsrfPolicy);
const timeouts = options?.timeouts;
const authorize = options?.authorize;
@@ -231,6 +240,21 @@ export class OutboundHttp {
agentOptions !== undefined ? buildNodeAgents(proxy, ssrf, agentOptions) : lazyNodeAgents(),
};
}
/**
* Resolves a caller's {@link UseDefaultSsrfPolicy} to the SSRF policy to enforce:
* the container's `SsrfProtectionService` always in `'enforced'` mode, and in
* `'safe'` mode when the instance enables protection, nothing otherwise.
*/
private resolveSsrf(useDefaultSsrfPolicy: UseDefaultSsrfPolicy = 'safe'): SsrfOption {
if (useDefaultSsrfPolicy === 'enforced') {
return this.ssrfProtection;
}
if (useDefaultSsrfPolicy === 'unsafe' || !this.ssrfConfig.enabled) {
return 'disabled';
}
return this.ssrfProtection;
}
}
function lazy<T>(factory: () => T): () => T {
@@ -0,0 +1,15 @@
/**
* Controls whether an outbound HTTP client is subject to the instance's
* outbound network policy (SSRF protection).
*
* - `'safe'` (default): the client enforces the instance policy. Whether the
* guard actually runs is decided inside `OutboundHttp` from
* `SsrfProtectionConfig.enabled` — callers never read that flag themselves.
* - `'enforced'`: the guard runs unconditionally, regardless of
* `SsrfProtectionConfig.enabled`. Reserve this for destinations that must
* stay guarded even on instances that leave protection off.
* - `'unsafe'`: the client bypasses the policy unconditionally. Reserve this
* for fixed, n8n-owned or operator-configured destinations, and state the
* reason in a comment at the call site.
*/
export type UseDefaultSsrfPolicy = 'safe' | 'enforced' | 'unsafe';
+1 -1
View File
@@ -6,7 +6,7 @@
* directly. Keep them out of the main barrel so the public surface stays
* limited to what production callers actually need.
*/
export { convertN8nRequestToAxios } from './http/axios/request';
export { convertN8nRequestToAxios, httpRequest } from './http/axios/request';
export { generateContentLengthHeader, isFormDataInstance } from './http/axios/utils';
export { buildRfcStyleUserAgent, getDefaultN8nOutboundUserAgent } from './http/axios/user-agent';
export { startServer, type LocalServer } from './http/local-server';
@@ -58,10 +58,10 @@ const blockedIpRangesSchema = z.string().transform(parseBlockedIpRanges);
*
* It does **not** decide, on its own, which outbound requests are guarded.
*
* That choice is made per call site via the `ssrf` option on `OutboundHttp.requests()` / `.transport()`,
* because whether a destination is dangerous depends on where its URL comes from.
* The one exception is {@link enabled}, which high-risk call sites read to decide
* whether to switch protection on for user-controlled URLs.
* `OutboundHttp` guards every request by default and resolves {@link enabled}
* internally. A call site can only bypass the guard by passing an explicit
* `useDefaultSsrfPolicy: 'unsafe'` on `OutboundHttp.requests()` / `.transport()` — used
* for fixed, n8n-owned or operator-configured destinations.
*
* Validation precedence inside the service: allowed hostname → blocked hostname
* → allowed IP range → blocked IP range → allow.
@@ -188,7 +188,7 @@ describe('TelemetryController', () => {
await controller.sourceConfig(req, res);
expect(res.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*');
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
@@ -126,7 +126,7 @@ export class TelemetryController {
const response = await this.outboundHttp
.requests({
ssrf: 'disabled', // the source-config host is fixed
useDefaultSsrfPolicy: 'unsafe', // the source-config host is fixed
})
.request({
method: 'GET',
+1 -1
View File
@@ -37,7 +37,7 @@ export class LicenseService {
outboundHttp: OutboundHttp,
) {
this.http = outboundHttp.requests({
ssrf: 'disabled', // Fixed, n8n-controlled host
useDefaultSsrfPolicy: 'unsafe', // Fixed, n8n-controlled host
timeout: REQUEST_TIMEOUT_MS,
});
}
@@ -1,14 +1,9 @@
import type { Mocked } from 'vitest';
import { type AgentJsonConfig } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type {
CustomFetch,
HttpTransport,
OutboundHttp,
SsrfProtectionService,
} from '@n8n/backend-network';
import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network';
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import type { GlobalConfig } from '@n8n/config';
import type {
User,
CredentialsEntity,
@@ -135,8 +130,6 @@ function makeRuntimeReconstructionService(
outboundHttp,
mock<AgentWorkspaceService>(),
mock<AgentKnowledgeMirrorService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<CredentialsFinderService>(),
mock<WorkflowFinderService>(),
mock<AgentChatAttachmentService>(),
@@ -2,13 +2,7 @@ import type * as agents from '@n8n/agents';
import type { CredentialProvider } from '@n8n/agents';
import type { AgentJsonConfig, AgentJsonToolConfig } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type {
CustomFetch,
HttpTransport,
OutboundHttp,
SsrfProtectionService,
} from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network';
import type { CredentialsEntity, User, WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
@@ -139,8 +133,6 @@ function makeService(overrides: {
outboundHttp,
mock<AgentWorkspaceService>(),
mock<AgentKnowledgeMirrorService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
credentialsFinderService,
workflowFinderService,
mock<AgentChatAttachmentService>(),
@@ -14,13 +14,7 @@ import {
type AgentJsonConfig,
type AgentTaskDto,
} from '@n8n/api-types';
import type {
CustomFetch,
HttpTransport,
OutboundHttp,
SsrfProtectionService,
} from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test';
import { NodeConnectionTypes } from 'n8n-workflow';
@@ -131,8 +125,6 @@ function makeService() {
outboundHttp,
dynamicNodeParametersService,
nodeTypes,
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<FreeAiCreditsService>(),
telemetry,
);
@@ -308,7 +300,7 @@ describe('AgentsBuilderToolsService', () => {
service.getTools(agentId, projectId, credentialProvider, user);
expect(outboundHttp.transport).toHaveBeenCalledWith(
expect.not.objectContaining({ ssrf: 'disabled' }),
expect.not.objectContaining({ useDefaultSsrfPolicy: 'unsafe' }),
);
});
@@ -15,13 +15,7 @@ import {
type AgentJsonConfig,
} from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type {
CustomFetch,
HttpTransport,
OutboundHttp,
SsrfProtectionService,
} from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network';
import type { UserRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
@@ -127,8 +121,6 @@ function makeReconstructionService(
outboundHttp,
agentWorkspaceService,
overrides.agentKnowledgeMirrorService ?? mock<AgentKnowledgeMirrorService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<CredentialsFinderService>(),
mock<WorkflowFinderService>(),
mock<AgentChatAttachmentService>(),
@@ -25,8 +25,7 @@ import {
type SubAgentTaskDifficulty,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
@@ -182,8 +181,6 @@ export class AgentRuntimeReconstructionService {
private readonly outboundHttp: OutboundHttp,
private readonly agentWorkspaceService: AgentWorkspaceService,
private readonly agentKnowledgeMirrorService: AgentKnowledgeMirrorService,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly workflowFinderService: WorkflowFinderService,
private readonly agentChatAttachmentService: AgentChatAttachmentService,
@@ -409,16 +406,10 @@ export class AgentRuntimeReconstructionService {
// Transport for LLM calls
const aiProxyFetch = createAiProxyFetch(this.outboundHttp);
// Transport for MCP calls
const aiMcpFetch =
instrumentation?.mcpFetch ??
createAiMcpFetch(this.outboundHttp, this.ssrfConfig, this.ssrfProtectionService);
const aiMcpFetch = instrumentation?.mcpFetch ?? createAiMcpFetch(this.outboundHttp);
// Transport for fallback web-search calls
const webSearchFetch = createWebSearchFetch(
this.outboundHttp,
this.ssrfConfig,
this.ssrfProtectionService,
);
const webSearchFetch = createWebSearchFetch(this.outboundHttp);
const buildMcpClient = async (server: AgentJsonMcpServerConfig) =>
await buildMcpClientForServer(server, {
@@ -34,8 +34,7 @@ import {
type AgentJsonConfig,
type ConfigValidationError,
} from '@n8n/api-types';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Operation } from 'fast-json-patch';
@@ -292,8 +291,6 @@ export class AgentsBuilderToolsService {
private readonly outboundHttp: OutboundHttp,
private readonly dynamicNodeParametersService: DynamicNodeParametersService,
private readonly nodeTypes: NodeTypes,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly freeAiCreditsService: FreeAiCreditsService,
private readonly telemetry: Telemetry,
) {}
@@ -936,11 +933,7 @@ export class AgentsBuilderToolsService {
credentialProvider,
oauthService: this.oauthService,
projectId,
proxyFetch: createAiMcpFetch(
this.outboundHttp,
this.ssrfConfig,
this.ssrfProtectionService,
),
proxyFetch: createAiMcpFetch(this.outboundHttp),
applyCredentialToMcpServer: async (serverName, credentialId) =>
await this.applyCredentialToMcpServer(agentId, projectId, serverName, credentialId, user),
}),
@@ -1,8 +1,7 @@
import type { StreamChunk } from '@n8n/agents';
import type { AgentIntegrationConfig } from '@n8n/api-types';
import type { Logger as BackendLogger } from '@n8n/backend-common';
import type { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { OutboundHttp } from '@n8n/backend-network';
import type { InstanceSettings } from 'n8n-core';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
@@ -169,8 +168,6 @@ function createIntegration() {
mock<AgentRepository>(),
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
mock<OutboundHttp>(),
{ enabled: false } as SsrfProtectionConfig,
mock<SsrfProtectionService>(),
);
}
@@ -100,7 +100,7 @@ describe('LinearIntegration', () => {
webhookSecret: 'sec',
userName: 'AgentName',
});
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(requestMock.mock.calls[0][0]).toMatchObject({
url: 'https://api.linear.app/graphql',
headers: { Authorization: 'Bearer oauth_token' },
@@ -279,7 +279,7 @@ export class AgentChatBridge {
Container.get(AgentChatAttachmentService),
integration.type === 'discord'
? Container.get(OutboundHttp).requests({
ssrf: 'disabled', // Discord attachment URLs are restricted to its fixed CDN host
useDefaultSsrfPolicy: 'unsafe', // Discord attachment URLs are restricted to its fixed CDN host
})
: undefined,
);
@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { Mock, Mocked, MockedFunction } from 'vitest';
import type { Logger } from '@n8n/backend-common';
import type { HttpRequestClient, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import type { Author } from 'chat';
import { createHmac } from 'crypto';
import { mock } from 'vitest-mock-extended';
@@ -56,7 +55,6 @@ const makeIntegration = (
urlService?: Mocked<UrlService>;
agentRepository?: Mocked<AgentRepository>;
encryptionKey?: string;
ssrfEnabled?: boolean;
} = {},
) => {
const urlService =
@@ -74,16 +72,12 @@ const makeIntegration = (
const requestMock = httpClient.request as Mock;
const outboundHttp = mock<OutboundHttp>();
outboundHttp.requests.mockReturnValue(httpClient);
const ssrfConfig = { enabled: opts.ssrfEnabled ?? false } as SsrfProtectionConfig;
const ssrfProtectionService = mock<SsrfProtectionService>();
const integration = new TelegramIntegration(
mock<Logger>(),
urlService,
agentRepository,
instanceSettings,
outboundHttp,
ssrfConfig,
ssrfProtectionService,
);
return {
integration,
@@ -92,7 +86,6 @@ const makeIntegration = (
instanceSettings,
outboundHttp,
requestMock,
ssrfProtectionService,
};
};
@@ -408,24 +401,13 @@ describe('TelegramIntegration secret token', () => {
expect(request.body.secret_token).toBe(expectedSecret('cluster-key', 'agent-Z', 'cred-Z'));
});
it('onAfterConnect applies SSRF protection when enabled (user-configurable Bot API host)', async () => {
const { integration, requestMock, outboundHttp, ssrfProtectionService } = makeIntegration({
ssrfEnabled: true,
});
it('onAfterConnect uses the default safe client (user-configurable Bot API host)', async () => {
const { integration, requestMock, outboundHttp } = makeIntegration();
requestMock.mockResolvedValue({ statusCode: 200, body: {} });
await integration.onAfterConnect(makeContext());
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: ssrfProtectionService });
});
it('onAfterConnect disables SSRF protection when the global flag is off', async () => {
const { integration, requestMock, outboundHttp } = makeIntegration({ ssrfEnabled: false });
requestMock.mockResolvedValue({ statusCode: 200, body: {} });
await integration.onAfterConnect(makeContext());
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.requests).toHaveBeenCalledWith();
});
it('onAfterConnect throws when Telegram rejects setWebhook', async () => {
@@ -194,7 +194,7 @@ export class DiscordIntegration extends AgentChatIntegration {
super();
this.gateway = new DiscordGateway(logger, instanceSettings);
this.httpClient = outboundHttp.requests({
ssrf: 'disabled', // the Discord API host is fixed and public
useDefaultSsrfPolicy: 'unsafe', // the Discord API host is fixed and public
});
}
@@ -167,7 +167,7 @@ export class LinearIntegration extends AgentChatIntegration {
try {
const response = await this.outboundHttp
.requests({
ssrf: 'disabled', // the GraphQL host is the fixed, public Linear API endpoint
useDefaultSsrfPolicy: 'unsafe', // the GraphQL host is the fixed, public Linear API endpoint
})
.request({
method: 'POST',
@@ -78,7 +78,8 @@ export class SlackMethodsService {
}
const response = await this.outboundHttp
.requests({
ssrf: 'disabled',
// Fixed public vendor host (slack.com), not user-controllable
useDefaultSsrfPolicy: 'unsafe',
})
.request({
method: 'POST',
@@ -1,8 +1,7 @@
import { AgentIntegrationConfig } from '@n8n/api-types';
import type { RichCardComponentType } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import type { Thread, Author } from 'chat';
@@ -145,8 +144,6 @@ export class TelegramIntegration extends AgentChatIntegration {
private readonly agentRepository: AgentRepository,
private readonly instanceSettings: InstanceSettings,
private readonly outboundHttp: OutboundHttp,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
) {
super();
}
@@ -338,10 +335,8 @@ export class TelegramIntegration extends AgentChatIntegration {
body?: Record<string, unknown>,
) {
return await this.outboundHttp
.requests({
// protection is applied because the Bot API host is user-configurable
ssrf: this.ssrfConfig.enabled ? this.ssrfProtectionService : 'disabled',
})
// the Bot API host is user-configurable, so the default safe mode applies
.requests()
.request({
method: 'POST',
url: this.botApiUrl(credential, method),
@@ -1,6 +1,5 @@
import { OutboundHttp, SsrfProtectionService, type HttpRequestClient } from '@n8n/backend-network';
import { OutboundHttp, type HttpRequestClient } from '@n8n/backend-network';
import { mockInstance } from '@n8n/backend-test-utils';
import { SsrfProtectionConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { executeNpmRequest } from '../npm-utils';
@@ -9,8 +8,6 @@ describe('executeNpmRequest SSRF gating', () => {
const request = vi.fn();
const requests = vi.fn().mockReturnValue(mock<HttpRequestClient>({ request }));
mockInstance(OutboundHttp, { requests });
const ssrfConfig = mockInstance(SsrfProtectionConfig);
const ssrfService = mockInstance(SsrfProtectionService);
beforeEach(() => {
vi.clearAllMocks();
@@ -18,25 +15,13 @@ describe('executeNpmRequest SSRF gating', () => {
request.mockResolvedValue({});
});
it('should pass the SSRF bridge when protection is enabled', async () => {
ssrfConfig.enabled = true;
it('should request the default safe client', async () => {
await executeNpmRequest('https://registry.example.com', 'pkg/1.0.0');
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfService });
});
it('should disable SSRF when protection is disabled', async () => {
ssrfConfig.enabled = false;
await executeNpmRequest('https://registry.example.com', 'pkg/1.0.0');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(requests).toHaveBeenCalledWith();
});
it('should map the registry GET request with auth header, timeout and JSON', async () => {
ssrfConfig.enabled = false;
await executeNpmRequest('https://registry.example.com/', 'pkg/1.0.0', {
authToken: 'tok',
timeout: 1234,
@@ -52,7 +37,6 @@ describe('executeNpmRequest SSRF gating', () => {
});
it('should re-throw the original error so callers can fall back to the npm CLI', async () => {
ssrfConfig.enabled = false;
const error = new Error('registry unreachable');
request.mockRejectedValueOnce(error);
@@ -73,7 +73,7 @@ export class CommunityPackagesService {
outboundHttp: OutboundHttp,
) {
this.http = outboundHttp.requests({
ssrf: 'disabled', // Fixed, n8n-controlled host
useDefaultSsrfPolicy: 'unsafe', // Fixed, n8n-controlled host
timeout: REQUEST_TIMEOUT_MS,
});
}
@@ -1,5 +1,4 @@
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import { jsonParse, UnexpectedError, LoggerProxy } from 'n8n-workflow';
import { execFile } from 'node:child_process';
@@ -267,12 +266,9 @@ export async function executeNpmRequest<T = unknown>(
LoggerProxy.debug('Executing npm registry request', { url, headers: redactedHeaders, timeout });
try {
const ssrfProtectionConfig = Container.get(SsrfProtectionConfig);
// User-configurable registry URL, so the default safe mode applies.
const data = (await Container.get(OutboundHttp)
.requests({
// User-configurable registry URL → SSRF on, gated on global config.
ssrf: ssrfProtectionConfig.enabled ? Container.get(SsrfProtectionService) : 'disabled',
})
.requests()
.request({
url,
method: 'GET',
@@ -51,17 +51,11 @@ describe('OAuth2TokenIntrospectionIdentifier (integration)', () => {
new DnsResolver(new InMemoryDnsCache(config)),
mockLogger(),
);
const outboundHttp = new OutboundHttp(ssrfService, mockLogger());
const outboundHttp = new OutboundHttp(ssrfService, config, mockLogger());
const cache = mock<CacheService>();
cache.get.mockResolvedValue(undefined);
cache.set.mockResolvedValue();
const httpClient = new OAuth2MetadataHttpClient(
mockLogger(),
cache,
outboundHttp,
ssrfService,
config,
);
const httpClient = new OAuth2MetadataHttpClient(mockLogger(), cache, outboundHttp);
return new OAuth2TokenIntrospectionIdentifier(mockLogger(), cache, httpClient);
};
@@ -1,6 +1,5 @@
import type { HttpRequestClient, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockLogger } from '@n8n/backend-test-utils';
import type { SsrfProtectionConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { mock } from 'vitest-mock-extended';
import type { IHttpRequestOptions } from 'n8n-workflow';
@@ -60,13 +59,7 @@ describe('OAuth2TokenIntrospectionIdentifier', () => {
beforeEach(() => {
vi.clearAllMocks();
outboundHttp.requests.mockReturnValue(mock<HttpRequestClient>({ request }));
const httpClient = new OAuth2MetadataHttpClient(
logger,
cache,
outboundHttp,
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
);
const httpClient = new OAuth2MetadataHttpClient(logger, cache, outboundHttp);
identifier = new OAuth2TokenIntrospectionIdentifier(logger, cache, httpClient);
cache.get.mockResolvedValue(undefined);
cache.set.mockResolvedValue();
@@ -1,6 +1,5 @@
import type { HttpRequestClient, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockLogger } from '@n8n/backend-test-utils';
import type { SsrfProtectionConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
@@ -15,17 +14,7 @@ describe('OAuth2MetadataHttpClient', () => {
const request = vi.fn();
const outboundHttp = mock<OutboundHttp>();
const buildClient = (
configOverrides: Partial<SsrfProtectionConfig>,
ssrfService = mock<SsrfProtectionService>(),
) =>
new OAuth2MetadataHttpClient(
logger,
cache,
outboundHttp,
ssrfService,
mock<SsrfProtectionConfig>(configOverrides),
);
const buildClient = () => new OAuth2MetadataHttpClient(logger, cache, outboundHttp);
beforeEach(() => {
vi.clearAllMocks();
@@ -35,28 +24,17 @@ describe('OAuth2MetadataHttpClient', () => {
});
describe('Client construction', () => {
test('builds the client with SSRF disabled when protection is off', () => {
buildClient({ enabled: false });
test('builds the client with the default safe mode and the request timeout', () => {
buildClient();
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled', timeout: 10_000 });
});
test('builds the client with the SSRF service when protection is on', () => {
const ssrfProtectionService = mock<SsrfProtectionService>();
buildClient({ enabled: true }, ssrfProtectionService);
expect(outboundHttp.requests).toHaveBeenCalledWith({
ssrf: ssrfProtectionService,
timeout: 10_000,
});
expect(outboundHttp.requests).toHaveBeenCalledWith({ timeout: 10_000 });
});
});
describe('requestFull', () => {
test('returns the full response without throwing on non-2xx', async () => {
request.mockResolvedValue({ statusCode: 404, body: { error: 'nope' } });
const client = buildClient({ enabled: true });
const client = buildClient();
const response = await client.requestFull({ url: 'https://auth.example.com', method: 'GET' });
@@ -77,7 +55,7 @@ describe('OAuth2MetadataHttpClient', () => {
test('fetches, validates, and caches the metadata', async () => {
request.mockResolvedValue({ statusCode: 200, body: { issuer: 'https://auth.example.com' } });
const client = buildClient({ enabled: true });
const client = buildClient();
const metadata = await client.fetchMetadata(schema, {
metadataUri: 'https://auth.example.com/.well-known/openid-configuration',
@@ -95,7 +73,7 @@ describe('OAuth2MetadataHttpClient', () => {
test('throws IdentifierValidationError when the metadata fetch fails', async () => {
request.mockResolvedValue({ statusCode: 500, body: {} });
const client = buildClient({ enabled: true });
const client = buildClient();
await expect(
client.fetchMetadata(schema, {
@@ -63,17 +63,11 @@ describe('OAuth2UserInfoIdentifier (integration)', () => {
new DnsResolver(new InMemoryDnsCache(config)),
mockLogger(),
);
const outboundHttp = new OutboundHttp(ssrfService, mockLogger());
const outboundHttp = new OutboundHttp(ssrfService, config, mockLogger());
const cache = mock<CacheService>();
cache.get.mockResolvedValue(undefined);
cache.set.mockResolvedValue();
const httpClient = new OAuth2MetadataHttpClient(
mockLogger(),
cache,
outboundHttp,
ssrfService,
config,
);
const httpClient = new OAuth2MetadataHttpClient(mockLogger(), cache, outboundHttp);
return new OAuth2UserInfoIdentifier(mockLogger(), cache, httpClient);
};
@@ -1,6 +1,5 @@
import type { HttpRequestClient, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockLogger } from '@n8n/backend-test-utils';
import type { SsrfProtectionConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import type { JSONWebKeySet, JWTPayload, KeyObject } from 'jose';
@@ -87,13 +86,7 @@ describe('OAuth2UserInfoIdentifier', () => {
beforeEach(() => {
vi.clearAllMocks();
outboundHttp.requests.mockReturnValue(mock<HttpRequestClient>({ request }));
const httpClient = new OAuth2MetadataHttpClient(
logger,
cache,
outboundHttp,
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
);
const httpClient = new OAuth2MetadataHttpClient(logger, cache, outboundHttp);
identifier = new OAuth2UserInfoIdentifier(logger, cache, httpClient);
cache.get.mockResolvedValue(undefined);
cache.set.mockResolvedValue();
@@ -1,6 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService, type HttpRequestClient } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp, type HttpRequestClient } from '@n8n/backend-network';
import { Time } from '@n8n/constants';
import { Service } from '@n8n/di';
import type { IHttpRequestOptions, IN8nHttpFullResponse } from 'n8n-workflow';
@@ -38,15 +37,12 @@ export class OAuth2MetadataHttpClient {
private readonly logger: Logger,
private readonly cache: CacheService,
outboundHttp: OutboundHttp,
ssrfProtectionService: SsrfProtectionService,
ssrfProtectionConfig: SsrfProtectionConfig,
) {
// We opt into SSRF protection (when the environment enables it) because the attack risk is higher here.
// This matters for the second hop too: the introspection/userinfo endpoints come from
// the remote metadata server, so they are fully third-party-controlled.
// Self-hosted users pointing the resolver at an internal IdP can allowlist via SsrfProtectionConfig.
// The default safe mode applies. This matters for the second hop too: the
// introspection/userinfo endpoints come from the remote metadata server, so
// they are fully third-party-controlled. Self-hosted users pointing the
// resolver at an internal IdP can allowlist via SsrfProtectionConfig.
this.http = outboundHttp.requests({
ssrf: ssrfProtectionConfig.enabled ? ssrfProtectionService : 'disabled',
timeout: REQUEST_TIMEOUT,
});
}
@@ -98,7 +98,7 @@ describe('EngineDataPlaneClient', () => {
});
it('opts out of SSRF protection for the n8n-controlled engine host', () => {
expect(clientOptions?.ssrf).toBe('disabled');
expect(clientOptions?.useDefaultSsrfPolicy).toBe('unsafe');
});
it('sends an identity token the engine accepts, proving the caller is the instance id', () => {
@@ -34,7 +34,7 @@ export class EngineDataPlaneClient implements EngineDataPlaneProvider {
) {
this.http = outboundHttp.requests({
// Fixed, n8n-controlled host.
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
// `engineConfig.host` is a bind address, not a destination, so it is not
// dialable. Default to loopback and let `N8N_ENGINE_BASE_URL` override
// when the engine answers somewhere else.
@@ -103,7 +103,7 @@ describe('AwsSecretsManager', () => {
}),
);
expect(outboundHttp.transport).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.transport).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
// Request connection reuse from our agents. Smithy treats a supplied agent as
// external and skips its own keep-alive defaults, so we pass them explicitly to
@@ -118,7 +118,7 @@ describe('InfisicalProvider', () => {
expect(requests).toHaveBeenCalledWith({
baseURL: SITE_URL,
headers: expect.any(Function),
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
});
});
@@ -102,7 +102,7 @@ describe('VaultProvider', () => {
expect(requests).toHaveBeenCalledWith({
baseURL: VAULT_URL,
headers: expect.any(Function),
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
});
});
@@ -142,7 +142,7 @@ export class AwsSecretsManager extends SecretsProvider {
// caps concurrent sockets, matching the AWS SDK's own defaults.
clientConfig.requestHandler = this.outboundHttp
.transport({
ssrf: 'disabled', // fixed AWS-resolved Secrets Manager host, not user-controlled
useDefaultSsrfPolicy: 'unsafe', // fixed AWS-resolved Secrets Manager host, not user-controlled
})
.getNodeAgent({ keepAlive: true, maxSockets: 50 });
@@ -175,7 +175,7 @@ export class InfisicalProvider extends SecretsProvider {
this.http = this.outboundHttp.requests({
baseURL: this.settings.siteURL,
headers: () => this.buildAuthHeaders(),
ssrf: 'disabled', // admin-configured infrastructure
useDefaultSsrfPolicy: 'unsafe', // admin-configured infrastructure
});
this.logger.debug('Infisical provider initialized');
@@ -289,7 +289,7 @@ export class VaultProvider extends SecretsProvider {
this.#http = this.outboundHttp.requests({
baseURL: new URL(this.settings.url).toString(), // Normalize here so a malformed URL fails at init time rather than on the first request.
headers: () => this.buildAuthHeaders(),
ssrf: 'disabled', // admin-configured infrastructure
useDefaultSsrfPolicy: 'unsafe', // admin-configured infrastructure
});
this.logger.debug('Vault provider initialized');
@@ -66,7 +66,7 @@ import type { DataTableService } from '@/modules/data-table/data-table.service';
import type { InstanceWriteAccessService } from '@/services/instance-write-access.service';
import type { NodeTypes } from '@/node-types';
import type { RoleService } from '@/services/role.service';
import type { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { OutboundHttp } from '@n8n/backend-network';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowTemplatesService } from '../workflow-templates.service';
@@ -163,7 +163,6 @@ const service = new InstanceAiAdapterService(
roleService,
telemetry,
aiBuilderTemporaryWorkflowRepository,
mock<SsrfProtectionService>(),
mock<OutboundHttp>(),
mock<AiGatewayService>(),
mock<WorkflowTemplatesService>(),
@@ -1332,15 +1332,14 @@ function createNodeAdapterServiceForTests(
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
{ isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
createMockCollaborationService() as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[35],
>[34],
nodeCatalogService,
);
@@ -1698,15 +1697,14 @@ function createDataTableAdapterForTests(overrides?: {
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
{ isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
createMockCollaborationService() as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[35],
>[34],
);
const adapter = service.createContext(mockUser, {
@@ -2029,15 +2027,14 @@ function createWorkflowAdapterForTests(overrides?: {
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
mockTelemetry as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
mockAiBuilderTemporaryWorkflowRepository as unknown as AiBuilderTemporaryWorkflowRepository,
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
{ isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
mockCollaborationService as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[35],
>[34],
);
const boundProjectId =
@@ -3145,15 +3142,14 @@ function createExecutionAdapterForTests(overrides?: { sharingEnabled?: boolean }
mockRoleService as unknown as RoleService,
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
{ isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
createMockCollaborationService() as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[35],
>[34],
);
const adapter = service.createContext(mockUser).executionService;
@@ -3413,15 +3409,14 @@ function createRunAdapterForTests(
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[28],
mockTelemetry as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[30],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[32],
mock<OutboundHttp>() as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[31],
{ isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[33],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[34],
>[32],
{} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[33],
createMockCollaborationService() as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[35],
>[34],
);
const adapter = service.createContext(mockUser, { threadId: options?.threadId }).executionService;
@@ -4069,7 +4064,7 @@ function createAdapterWithGatewayMock(
}),
};
const args = Array.from(
{ length: 35 },
{ length: 34 },
() => ({}) as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[number],
);
args[0] = {
@@ -4103,12 +4098,12 @@ function createAdapterWithGatewayMock(
args[29] = (overrides?.telemetry ?? {
track: vi.fn(),
}) as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[29];
args[32] = mock<OutboundHttp>() as unknown as ConstructorParameters<
args[31] = mock<OutboundHttp>() as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[31];
args[32] = aiGatewayService as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[32];
args[33] = aiGatewayService as unknown as ConstructorParameters<
typeof InstanceAiAdapterService
>[33];
return new InstanceAiAdapterService(
...(args as ConstructorParameters<typeof InstanceAiAdapterService>),
);
@@ -28,7 +28,7 @@ describe('WorkflowTemplatesService', () => {
makeService();
expect(requests).toHaveBeenCalledWith({
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
timeout: TEMPLATE_REQUEST_TIMEOUT_MS,
});
});
@@ -9,7 +9,7 @@ import {
} from '@n8n/api-types';
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { Logger, ModuleRegistry } from '@n8n/backend-common';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { OutboundHttp } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { User, ExecutionSummaries, EvaluationConfig } from '@n8n/db';
@@ -278,7 +278,6 @@ export class InstanceAiAdapterService {
private readonly roleService: RoleService,
private readonly telemetry: Telemetry,
private readonly aiBuilderTemporaryWorkflowRepository: AiBuilderTemporaryWorkflowRepository,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly outboundHttp: OutboundHttp,
private readonly aiGatewayService: AiGatewayService,
private readonly workflowTemplatesService: WorkflowTemplatesService,
@@ -2418,10 +2417,9 @@ export class InstanceAiAdapterService {
const searchCacheRef = this.searchCache;
const settingsService = this.settingsService;
const { outboundHttp, ssrfProtectionService } = this;
const sharedTransport = outboundHttp.transport({
ssrf: this.ssrfProtectionService, // LLM/user-chosen URLs
});
const { outboundHttp } = this;
// LLM/user-chosen URLs, guarded even when the instance leaves protection off
const sharedTransport = outboundHttp.transport({ useDefaultSsrfPolicy: 'enforced' });
const userId = user.id;
// Lazy search method that resolves credentials on first call
@@ -2478,7 +2476,7 @@ export class InstanceAiAdapterService {
const authorizeUrl = options?.authorizeUrl;
const transport = authorizeUrl
? outboundHttp.transport({
ssrf: ssrfProtectionService,
useDefaultSsrfPolicy: 'enforced',
authorize: async (target: URL) => await authorizeUrl(target.href),
})
: sharedTransport;
@@ -1,12 +1,6 @@
import type { BuiltTool } from '@n8n/agents';
import type { Logger } from '@n8n/backend-common';
import type {
CustomFetch,
HttpTransport,
OutboundHttp,
SsrfProtectionService,
} from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network';
import type { CredentialsEntity, User } from '@n8n/db';
import { QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
@@ -116,8 +110,6 @@ describe('InstanceAiMcpRegistryService', () => {
oauthService,
eventService,
outboundHttp,
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
);
return {
@@ -261,7 +253,7 @@ describe('InstanceAiMcpRegistryService', () => {
await service.getRegistryMcpServers(user);
expect(outboundHttp.transport).toHaveBeenCalledWith(
expect.not.objectContaining({ ssrf: 'disabled' }),
expect.not.objectContaining({ useDefaultSsrfPolicy: 'unsafe' }),
);
});
@@ -7,8 +7,7 @@ import type {
} from '@n8n/api-types';
import { isObjectLiteral, Logger } from '@n8n/backend-common';
import type { CustomFetch } from '@n8n/backend-network';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import type { CredentialsEntity, User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { McpServerConfig } from '@n8n/instance-ai';
@@ -159,8 +158,6 @@ export class InstanceAiMcpRegistryService {
private readonly oauthService: OauthService,
private readonly eventService: EventService,
private readonly outboundHttp: OutboundHttp,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
) {
this.logger = logger.scoped('instance-ai');
}
@@ -305,11 +302,7 @@ export class InstanceAiMcpRegistryService {
);
if (!resolvedServer) return disconnectedToolsResponse(connection.id);
const aiMcpFetch = createAiMcpFetch(
this.outboundHttp,
this.ssrfConfig,
this.ssrfProtectionService,
);
const aiMcpFetch = createAiMcpFetch(this.outboundHttp);
const requestFetch = await this.buildRegistryServerFetch(
resolvedServer,
user,
@@ -375,11 +368,7 @@ export class InstanceAiMcpRegistryService {
const slugCounts = new Map<string, number>();
// One proxy-aware, SSRF-protected transport shared across all resolved MCP connections.
const aiMcpFetch = createAiMcpFetch(
this.outboundHttp,
this.ssrfConfig,
this.ssrfProtectionService,
);
const aiMcpFetch = createAiMcpFetch(this.outboundHttp);
const resolved: McpServerConfig[] = [];
for (const connection of sortedConnections) {
@@ -4,10 +4,10 @@ import type {
CustomFetch,
HttpTransport,
SsrfBridge,
SsrfOption,
SsrfProtectionService,
} from '@n8n/backend-network';
import { OutboundHttp } from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { createResultError, createResultOk } from '@n8n/utils/result';
import dns from 'node:dns';
@@ -327,10 +327,17 @@ describe('fetchAndExtract', () => {
// Builds a real transport (the wiring the adapter service performs) so the
// SSRF (and optional authorize) interceptors run for real against the
// redirecting server below.
function realTransport(ssrf: SsrfOption, authorize?: (url: URL) => Promise<void>) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport({
ssrf,
// redirecting server below. A given bridge is installed as the instance's
// protection service; `'disabled'` builds an unsafe transport instead.
function realTransport(ssrf: SsrfBridge | 'disabled', authorize?: (url: URL) => Promise<void>) {
const service =
ssrf === 'disabled' ? mock<SsrfProtectionService>() : mock<SsrfProtectionService>(ssrf);
return new OutboundHttp(
service,
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
).transport({
useDefaultSsrfPolicy: ssrf === 'disabled' ? 'unsafe' : 'safe',
authorize,
});
}
@@ -23,7 +23,7 @@ export class WorkflowTemplatesService {
outboundHttp: OutboundHttp,
) {
this.http = outboundHttp.requests({
ssrf: 'disabled', // Fixed, n8n-controlled templates host.
useDefaultSsrfPolicy: 'unsafe', // Fixed, n8n-controlled templates host.
timeout: TEMPLATE_REQUEST_TIMEOUT_MS,
});
}
@@ -109,7 +109,7 @@ describe('MessageEventBusDestinationWebhook', () => {
url: 'https://example.com/webhook',
});
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(sentOptions).toMatchObject({
url: 'https://example.com/webhook',
method: 'POST',
@@ -415,7 +415,7 @@ export class MessageEventBusDestinationWebhook
try {
const requestResponse = await this.outboundHttp
.requests({ ssrf: 'disabled' }) // The destination URL is admin-configured, so SSRF protection is disabled.
.requests({ useDefaultSsrfPolicy: 'unsafe' }) // The destination URL is admin-configured, so SSRF protection is disabled.
.request(request);
if (requestResponse) {
if (this.responseCodeMustMatch) {
@@ -2,8 +2,7 @@ import { zodToJsonSchema } from '@n8n/agents';
import { APPROVAL_RESUME_SCHEMA } from '@n8n/agents/tool';
import type { AgentJsonConfig } from '@n8n/api-types';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import { User, type WorkflowRepository } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import type { Mock } from 'vitest';
@@ -159,8 +158,6 @@ describe('McpAgentToolsService', () => {
mockInstance(NodeTypes),
mockInstance(OauthService),
outboundHttp,
mockInstance(SsrfProtectionConfig),
mockInstance(SsrfProtectionService),
urlService,
projectScopeService,
);
@@ -17,8 +17,7 @@ import {
sanitizeAgentJsonConfig,
type AgentJsonConfig,
} from '@n8n/api-types';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
@@ -403,8 +402,6 @@ export class McpAgentToolsService {
private readonly nodeTypes: NodeTypes,
private readonly oauthService: OauthService,
private readonly outboundHttp: OutboundHttp,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly urlService: UrlService,
private readonly projectScopeService: ProjectScopeService,
) {}
@@ -1629,11 +1626,7 @@ export class McpAgentToolsService {
credentialProvider,
oauthService: this.oauthService,
projectId: input.projectId,
proxyFetch: createAiMcpFetch(
this.outboundHttp,
this.ssrfConfig,
this.ssrfProtectionService,
),
proxyFetch: createAiMcpFetch(this.outboundHttp),
},
);
return { ok: true, tools };
@@ -203,7 +203,7 @@ export class OtelService {
// HTTP response means the server is reachable. We only catch network errors.
// SSRF is disabled: the OTLP endpoint is admin-configured observability
// infrastructure and is commonly an internal/localhost collector.
await this.outboundHttp.transport({ ssrf: 'disabled' }).asCustomFetch()(url, {
await this.outboundHttp.transport({ useDefaultSsrfPolicy: 'unsafe' }).asCustomFetch()(url, {
method: 'HEAD',
signal: AbortSignal.timeout(timeoutMs),
});
@@ -23,7 +23,7 @@ describe('FirecrawlHandler', () => {
it('should create the request client with SSRF disabled for the fixed vendor host', () => {
const requests = vi.fn().mockReturnValue(mock<ReturnType<OutboundHttp['requests']>>());
new FirecrawlHandler(mock<OutboundHttp>({ requests }));
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled', timeout: 30_000 });
expect(requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe', timeout: 30_000 });
});
describe('setConfig', () => {
@@ -22,7 +22,7 @@ export class FirecrawlHandler implements IQuickConnectHandler {
constructor(outboundHttp: OutboundHttp) {
this.http = outboundHttp.requests({
ssrf: 'disabled', // Fixed third-party vendor host
useDefaultSsrfPolicy: 'unsafe', // Fixed third-party vendor host
timeout: REQUEST_TIMEOUT_MS,
});
}
@@ -5,7 +5,7 @@ import type { HttpTransport, SsrfProtectionService } from '@n8n/backend-network'
import { OutboundHttp } from '@n8n/backend-network';
import { type LocalServer, startServer } from '@n8n/backend-network/testing';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import type { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import type { AuthIdentityRepository, SettingsRepository, User, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
@@ -819,7 +819,7 @@ describe('OidcService', () => {
// The discovery / token / userinfo endpoints are admin-configured and may
// legitimately point at an internal IdP, so SSRF protection is disabled.
expect(outboundHttp.transport).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.transport).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
});
it('always calls discovery with the factory customFetch (no proxy/no-proxy branch)', async () => {
@@ -868,7 +868,11 @@ describe('OidcService', () => {
beforeEach(() => {
idpServer.clear();
const realOutboundHttp = new OutboundHttp(mock<SsrfProtectionService>(), logger);
const realOutboundHttp = new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
logger,
);
realOidcService = new OidcService(
settingsRepository,
authIdentityRepository,
@@ -801,7 +801,7 @@ export class OidcService {
// token/userinfo endpoints reached with the same `customFetch`) is
// admin-configured and may legitimately point at an internal IdP, so enabling
// SSRF protection here would block valid internal setups
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
// `proxy` defaults = `'env'`
})
.asCustomFetch() as unknown as openidClientTypes.CustomFetch;
@@ -1614,7 +1614,7 @@ describe('SamlService', () => {
expect(result).toBe(validMetadataXml);
// The metadata URL is admin-configured, so SSRF protection is disabled.
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(httpRequest).toHaveBeenCalledWith({
url: metadataUrl,
method: 'GET',
@@ -709,7 +709,7 @@ export class SamlService {
try {
const response = await this.outboundHttp
.requests({
ssrf: 'disabled', // The metadata URL is admin-configured and may point at an internal IdP, so SSRF protection is disabled.
useDefaultSsrfPolicy: 'unsafe', // The metadata URL is admin-configured and may point at an internal IdP, so SSRF protection is disabled.
})
.request({
url,
@@ -141,7 +141,7 @@ export class JwksResolverService {
options?.fetcher ??
this.outboundHttp
.transport({
ssrf: 'disabled', // JWKS sources are admin-configured trusted-key endpoints
useDefaultSsrfPolicy: 'unsafe', // JWKS sources are admin-configured trusted-key endpoints
})
.asCustomFetch();
const defaultTtl = options?.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
@@ -52,7 +52,7 @@ function buildService() {
mock<OAuthBrowserBindingService>(),
mock<EventService>(),
mock<CacheService>(),
new OutboundHttp(ssrf, mock<Logger>()),
new OutboundHttp(ssrf, mock<SsrfProtectionConfig>({ enabled: true }), mock<Logger>()),
ssrf,
mock<SsrfProtectionConfig>({ enabled: true }),
);
@@ -157,13 +157,11 @@ describe('OauthService', () => {
});
describe('constructor', () => {
it('builds its HTTP client with the injected SSRF protection service and a default timeout', () => {
// Guards the intent that outbound OAuth calls run with SSRF protection
// enabled per the configured env vars, rather than relying on the implicit
// `requests()` default, and that the shared request timeout is applied once
// on the client instead of being repeated per call.
it('builds its HTTP client with the default safe mode and a default timeout', () => {
// Guards the intent that outbound OAuth calls run through the default safe
// client, and that the shared request timeout is applied once on the
// client instead of being repeated per call.
expect(outboundHttp.requests).toHaveBeenCalledWith({
ssrf: ssrfProtectionService,
timeout: expect.any(Number),
});
});
+4 -6
View File
@@ -138,13 +138,11 @@ export class OauthService {
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly ssrfProtectionConfig: SsrfProtectionConfig,
) {
// Unlike most OutboundHttp callsites, here we opt into SSRF protection (when the environment enables it) because the attack risk is higher:
// these URLs can be user-, instance- or remote-server-supplied (discovery / dynamic client registration),
// so the service can't tell at runtime which are trustworthy.
// Self-hosted users with an internal OAuth/MCP server are accommodated via the SSRF allowlist config, not by disabling the guard.
// In the future, enabling SSRF "per feature" could be refined through configuration.
// These URLs can be user-, instance- or remote-server-supplied (discovery /
// dynamic client registration), so the default safe mode applies. Self-hosted
// users with an internal OAuth/MCP server are accommodated via the SSRF
// allowlist config, not by bypassing the guard.
this.http = outboundHttp.requests({
ssrf: this.getSsrfBridge() ?? 'disabled',
timeout: OAUTH_REQUEST_TIMEOUT_MS,
});
}
@@ -32,7 +32,7 @@ describe('InstanceRiskReporter', () => {
});
it('should create the request client with SSRF disabled for the fixed host', () => {
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled', timeout: 30_000 });
expect(requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe', timeout: 30_000 });
});
it('should fetch versions with the instance-id header and JSON parsing', async () => {
@@ -32,7 +32,7 @@ export class InstanceRiskReporter implements RiskReporter {
outboundHttp: OutboundHttp,
) {
this.http = outboundHttp.requests({
ssrf: 'disabled', // Fixed, n8n-controlled host
useDefaultSsrfPolicy: 'unsafe', // Fixed, n8n-controlled host
timeout: REQUEST_TIMEOUT_MS,
});
}
@@ -121,7 +121,7 @@ describe('AiGatewayService', () => {
const result = await service.getGatewayConfig();
expect(result).toEqual(MOCK_GATEWAY_CONFIG);
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET', url: `${BASE_URL}/v1/gateway/config` }),
);
@@ -1,5 +1,5 @@
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import type { HttpRequestClient } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import { RoutingNode } from 'n8n-core';
import type {
@@ -166,20 +166,17 @@ describe('CredentialsTester', () => {
});
// A node-defined credential test function may issue an outbound request to
// a credential-supplied host. `testCredentials` must hand it a context
// carrying the execution's egress policy, so that when SSRF protection is
// enabled the test honours the same restrictions as node execution. The
// legacy `this.helpers.request` helper routes through
// `OutboundHttp.requests({ ssrf })`; asserting that argument proves the
// bridge reaches the test function.
it('forwards the SSRF bridge from getBase to a function-based credential test', async () => {
// a credential-supplied host. `testCredentials` must hand it a context whose
// legacy `this.helpers.request` helper routes through the default (safe)
// `OutboundHttp.requests()` client, so the test honours the same egress
// policy as node execution.
it('routes a function-based credential test through the default safe client', async () => {
const requestLegacy = vi.fn().mockResolvedValue('ok');
const requests = vi.fn().mockReturnValue(mock<HttpRequestClient>({ requestLegacy }));
Container.set(OutboundHttp, mock<OutboundHttp>({ requests }));
const ssrfBridge = mock<SsrfBridge>();
vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(
mock<IWorkflowExecuteAdditionalData>({ ssrfBridge }),
mock<IWorkflowExecuteAdditionalData>(),
);
mockTestFunction.mockImplementation(async function (this: ICredentialTestFunctions) {
@@ -196,7 +193,7 @@ describe('CredentialsTester', () => {
});
expect(mockTestFunction).toHaveBeenCalled();
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfBridge });
expect(requests).toHaveBeenCalledWith();
});
it('should keep function-based tests working with the real routing engine untouched', async () => {
@@ -1,6 +1,6 @@
import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import type { HttpRequestClient } from '@n8n/backend-network';
import { mockInstance } from '@n8n/backend-test-utils';
import { CredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
import type { CredentialsEntity } from '@n8n/db';
@@ -229,8 +229,8 @@ describe('DynamicNodeParametersService', () => {
// protection is enabled, they honour the same restrictions as node execution.
// Many such methods use the legacy `this.helpers.request` helper (e.g. nodes
// that build the request and set auth by hand), which routes through
// `OutboundHttp.requests({ ssrf })` — asserting that argument proves the
// bridge is forwarded end to end.
// `OutboundHttp.requests()` — asserting the no-argument call proves they get
// the default safe client, so the instance's egress policy applies.
describe('egress policy for method-name requests', () => {
const requestLegacy = vi.fn();
const requests = vi.fn();
@@ -313,31 +313,16 @@ describe('DynamicNodeParametersService', () => {
];
it.each(scenarios)(
'forwards the SSRF bridge to requests made from the $name method',
'routes requests made from the $name method through the default safe client',
async ({ register, invoke }) => {
const method = requestingMethod();
register(method);
const ssrfBridge = mock<SsrfBridge>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
await invoke(additionalData);
expect(method).toHaveBeenCalled();
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfBridge });
},
);
it.each(scenarios)(
'disables SSRF for requests from the $name method when no bridge is attached',
async ({ register, invoke }) => {
const method = requestingMethod();
register(method);
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined });
await invoke(additionalData);
expect(method).toHaveBeenCalled();
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(requests).toHaveBeenCalledWith();
},
);
});
@@ -95,7 +95,7 @@ export class AiGatewayService {
): Promise<T> {
const response = await this.outboundHttp
.requests({
ssrf: 'disabled', // the gateway base URL is n8n-owned configuration
useDefaultSsrfPolicy: 'unsafe', // the gateway base URL is n8n-owned configuration
})
.request({
method: options.method,
@@ -113,7 +113,7 @@ describe('Telemetry', () => {
test('should route the RudderStack SDK through the outbound transport with SSRF disabled', async () => {
await initTelemetry.init();
expect(transport).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(transport).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(getNodeAgent).toHaveBeenCalled();
expect(rudderStackConstructor).toHaveBeenCalledWith(
'test-key',
+1 -1
View File
@@ -190,7 +190,7 @@ export class Telemetry {
const { httpAgent, httpsAgent } = this.outboundHttp
.transport({
ssrf: 'disabled', // The data-plane host is fixed and the SDK owns the request lifecycle, so SSRF is disabled.
useDefaultSsrfPolicy: 'unsafe', // The data-plane host is fixed and the SDK owns the request lifecycle, so SSRF is disabled.
})
.getNodeAgent();
const axiosConfig: AxiosRequestConfig = {
@@ -1,5 +1,4 @@
import type { HttpTransport, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { HttpTransport, OutboundHttp } from '@n8n/backend-network';
import { mock } from 'vitest-mock-extended';
import {
@@ -10,18 +9,23 @@ import {
} from '@/utils/ai-proxy-fetch';
describe('AI outbound fetch helpers', () => {
it('creates model fetch with SSRF disabled', () => {
const makeOutboundHttp = () => {
const fetch = vi.fn() as typeof global.fetch;
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(fetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
return { fetch, outboundHttp };
};
it('creates model fetch with the outbound network policy bypassed', () => {
const { fetch, outboundHttp } = makeOutboundHttp();
expect(createAiProxyFetch(outboundHttp)).toBe(fetch);
expect(outboundHttp.transport).toHaveBeenCalledWith({
proxy: 'env',
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
timeouts: {
headersTimeout: AI_REQUEST_TIMEOUT_MS,
bodyTimeout: AI_REQUEST_TIMEOUT_MS,
@@ -29,20 +33,13 @@ describe('AI outbound fetch helpers', () => {
});
});
it('creates MCP fetch with SSRF protection enabled when configured', () => {
const fetch = vi.fn() as typeof global.fetch;
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(fetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
const ssrfConfig = { enabled: true } as SsrfProtectionConfig;
const ssrfProtectionService = mock<SsrfProtectionService>();
it('creates MCP fetch with the default safe mode', () => {
const { fetch, outboundHttp } = makeOutboundHttp();
expect(createAiMcpFetch(outboundHttp, ssrfConfig, ssrfProtectionService)).toBe(fetch);
expect(createAiMcpFetch(outboundHttp)).toBe(fetch);
expect(outboundHttp.transport).toHaveBeenCalledWith({
proxy: 'env',
ssrf: ssrfProtectionService,
timeouts: {
headersTimeout: AI_REQUEST_TIMEOUT_MS,
bodyTimeout: AI_REQUEST_TIMEOUT_MS,
@@ -50,58 +47,13 @@ describe('AI outbound fetch helpers', () => {
});
});
it('creates MCP fetch with SSRF protection disabled when configured', () => {
const fetch = vi.fn() as typeof global.fetch;
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(fetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
const ssrfConfig = { enabled: false } as SsrfProtectionConfig;
const ssrfProtectionService = mock<SsrfProtectionService>();
it('creates web-search fetch with the default safe mode', () => {
const { fetch, outboundHttp } = makeOutboundHttp();
expect(createAiMcpFetch(outboundHttp, ssrfConfig, ssrfProtectionService)).toBe(fetch);
expect(createWebSearchFetch(outboundHttp)).toBe(fetch);
expect(outboundHttp.transport).toHaveBeenCalledWith({
proxy: 'env',
ssrf: 'disabled',
timeouts: {
headersTimeout: AI_REQUEST_TIMEOUT_MS,
bodyTimeout: AI_REQUEST_TIMEOUT_MS,
},
});
});
it('creates web-search fetch with SSRF protection enabled when configured', () => {
const fetch = vi.fn() as typeof global.fetch;
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(fetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
const ssrfConfig = { enabled: true } as SsrfProtectionConfig;
const ssrfProtectionService = mock<SsrfProtectionService>();
expect(createWebSearchFetch(outboundHttp, ssrfConfig, ssrfProtectionService)).toBe(fetch);
expect(outboundHttp.transport).toHaveBeenCalledWith({
proxy: 'env',
ssrf: ssrfProtectionService,
});
});
it('creates web-search fetch with SSRF protection disabled when configured', () => {
const fetch = vi.fn() as typeof global.fetch;
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(fetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
const ssrfConfig = { enabled: false } as SsrfProtectionConfig;
const ssrfProtectionService = mock<SsrfProtectionService>();
expect(createWebSearchFetch(outboundHttp, ssrfConfig, ssrfProtectionService)).toBe(fetch);
expect(outboundHttp.transport).toHaveBeenCalledWith({
proxy: 'env',
ssrf: 'disabled',
});
});
});
@@ -99,7 +99,7 @@ describe('Strapi utils', () => {
await paginatedRequest(baseUrl, { pagination: { page: 1, pageSize: 25 } });
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled', timeout: 6000 });
expect(requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe', timeout: 6000 });
expect(request).toHaveBeenCalledWith({
url: baseUrl,
method: 'GET',
+9 -20
View File
@@ -1,5 +1,4 @@
import type { CustomFetch, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { CustomFetch, OutboundHttp } from '@n8n/backend-network';
import { Time } from '@n8n/constants';
const DEFAULT_AI_REQUEST_TIMEOUT_MS = Time.hours.toMilliseconds;
@@ -25,17 +24,17 @@ function resolveTimeoutMs(raw: string | undefined): number {
* and AI-MCP calls, built through the single transport implementer
* (`@n8n/backend-network`).
*
* SSRF stays disabled: these targets are fixed provider hosts plus
* user-configured `baseURL`s (self-hosted Ollama, OpenAI-compatible gateways),
* which the previous hand-rolled helpers did not filter either. The long AI
* timeout is applied so slow completions are not cut off at undici's 5-minute
* default.
* The outbound network policy stays bypassed (`'unsafe'`): these targets are
* fixed provider hosts plus user-configured `baseURL`s (self-hosted Ollama,
* OpenAI-compatible gateways), which the previous hand-rolled helpers did not
* filter either. The long AI timeout is applied so slow completions are not
* cut off at undici's 5-minute default.
*/
export function createAiProxyFetch(outboundHttp: OutboundHttp): CustomFetch {
return outboundHttp
.transport({
proxy: 'env',
ssrf: 'disabled',
useDefaultSsrfPolicy: 'unsafe',
timeouts: {
headersTimeout: AI_REQUEST_TIMEOUT_MS,
bodyTimeout: AI_REQUEST_TIMEOUT_MS,
@@ -49,15 +48,10 @@ export function createAiProxyFetch(outboundHttp: OutboundHttp): CustomFetch {
* fetches, MCP URLs are user-controlled, so follow the configured SSRF policy
* while preserving the long AI timeout.
*/
export function createAiMcpFetch(
outboundHttp: OutboundHttp,
ssrfConfig: SsrfProtectionConfig,
ssrfProtectionService: SsrfProtectionService,
): CustomFetch {
export function createAiMcpFetch(outboundHttp: OutboundHttp): CustomFetch {
return outboundHttp
.transport({
proxy: 'env',
ssrf: ssrfConfig.enabled ? ssrfProtectionService : 'disabled',
timeouts: {
headersTimeout: AI_REQUEST_TIMEOUT_MS,
bodyTimeout: AI_REQUEST_TIMEOUT_MS,
@@ -70,15 +64,10 @@ export function createAiMcpFetch(
* A proxy-aware fetch for web-search providers (e.g. SearXNG) whose base URL
* is user-configured, so follow the configured SSRF policy.
*/
export function createWebSearchFetch(
outboundHttp: OutboundHttp,
ssrfConfig: SsrfProtectionConfig,
ssrfProtectionService: SsrfProtectionService,
): CustomFetch {
export function createWebSearchFetch(outboundHttp: OutboundHttp): CustomFetch {
return outboundHttp
.transport({
proxy: 'env',
ssrf: ssrfConfig.enabled ? ssrfProtectionService : 'disabled',
})
.asCustomFetch();
}
+1 -1
View File
@@ -86,7 +86,7 @@ export async function paginatedRequest<T>(
try {
response = await Container.get(OutboundHttp)
.requests({
ssrf: 'disabled', // n8n-controlled templates/Strapi host
useDefaultSsrfPolicy: 'unsafe', // n8n-controlled templates/Strapi host
timeout: REQUEST_TIMEOUT_MS,
})
.request<ResponseData<T>>({
@@ -1,9 +1,8 @@
import type { Mock } from 'vitest';
import type { ImportWorkflowFromUrlDto } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { HttpRequestClient, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { SsrfBlockedIpError } from '@n8n/backend-network';
import type { SsrfProtectionConfig } from '@n8n/config';
import type { AuthenticatedRequest, IExecutionResponse } from '@n8n/db';
import type { Response } from 'express';
import { mock } from 'vitest-mock-extended';
@@ -21,8 +20,6 @@ describe('WorkflowsController', () => {
const res = mock<Response>();
const projectService = mock<ProjectService>();
const logger = mock<Logger>();
const ssrfConfig = { enabled: false } as SsrfProtectionConfig;
const ssrfProtectionService = mock<SsrfProtectionService>();
const httpClient = mock<HttpRequestClient>();
const outboundHttp = mock<OutboundHttp>();
const requestMock = httpClient.request as Mock;
@@ -30,10 +27,7 @@ describe('WorkflowsController', () => {
beforeEach(() => {
controller.projectService = projectService;
controller.logger = logger;
controller.ssrfConfig = ssrfConfig;
controller.ssrfProtectionService = ssrfProtectionService;
controller.outboundHttp = outboundHttp;
ssrfConfig.enabled = false;
vi.clearAllMocks();
outboundHttp.requests.mockReturnValue(httpClient);
});
@@ -134,13 +128,12 @@ describe('WorkflowsController', () => {
});
});
describe('when URL protection is enabled', () => {
describe('URL protection', () => {
beforeEach(() => {
ssrfConfig.enabled = true;
projectService.getProjectWithScope.mockResolvedValue({} as any);
});
it('should create the client with the SSRF protection service', async () => {
it('should create the client with the default safe mode', async () => {
const mockWorkflowData = { nodes: [], connections: {} };
requestMock.mockResolvedValue(mockWorkflowData);
@@ -150,7 +143,7 @@ describe('WorkflowsController', () => {
};
await controller.getFromUrl(req, res, query);
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: ssrfProtectionService });
expect(outboundHttp.requests).toHaveBeenCalledWith();
expect(requestMock).toHaveBeenCalledWith({ method: 'GET', url: query.url });
});
@@ -180,23 +173,6 @@ describe('WorkflowsController', () => {
await expect(controller.getFromUrl(req, res, query)).rejects.toThrow(SsrfBlockedIpError);
});
});
describe('when URL protection is disabled', () => {
it('should create the client with SSRF disabled', async () => {
ssrfConfig.enabled = false;
projectService.getProjectWithScope.mockResolvedValue({} as any);
const mockWorkflowData = { nodes: [], connections: {} };
requestMock.mockResolvedValue(mockWorkflowData);
const query: ImportWorkflowFromUrlDto = {
url: 'https://example.com/workflow.json',
projectId,
};
await controller.getFromUrl(req, res, query);
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
});
});
describe('getLastSuccessfulExecution', () => {
@@ -11,8 +11,8 @@ import {
type WorkflowPublicationStatus,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp, SsrfBlockedIpError, SsrfProtectionService } from '@n8n/backend-network';
import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp, SsrfBlockedIpError } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
import {
AuthenticatedRequest,
ProjectRelationRepository,
@@ -89,8 +89,6 @@ export class WorkflowsController {
private readonly workflowFinderService: WorkflowFinderService,
private readonly executionService: ExecutionService,
private readonly collaborationService: CollaborationService,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly outboundHttp: OutboundHttp,
private readonly workflowPublicationStatusService: WorkflowPublicationStatusService,
private readonly ownershipService: OwnershipService,
@@ -730,10 +728,8 @@ export class WorkflowsController {
}
private async fetchWorkflowFromUrl(url: string) {
const client = this.outboundHttp.requests({
// user-supplied URL
ssrf: this.ssrfConfig.enabled ? this.ssrfProtectionService : 'disabled',
});
// user-supplied URL, so the default safe mode applies
const client = this.outboundHttp.requests();
try {
return await client.request<IWorkflowResponse>({ method: 'GET', url });
@@ -319,7 +319,7 @@ test('should send message to webhook ', async () => {
await confirmIdInAll(testMessage.id);
} else if (msg.command === 'confirmMessageSent') {
await confirmIdSent(testMessage.id);
expect(outboundHttp.requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(outboundHttp.requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(webhookRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: testWebhookDestination.url,
@@ -1,6 +1,7 @@
import type { Logger } from '@n8n/backend-common';
import { OutboundHttp, type SsrfProtectionService } from '@n8n/backend-network';
import { type LocalServer, startServer } from '@n8n/backend-network/testing';
import type { SsrfProtectionConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import * as client from 'openid-client';
@@ -8,9 +9,13 @@ describe('OIDC discovery (real HTTP round-trip through the factory)', () => {
let idpServer: LocalServer;
const buildCustomFetch = () => {
const outboundHttp = new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>());
const outboundHttp = new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
);
return outboundHttp
.transport({ ssrf: 'disabled' })
.transport({ useDefaultSsrfPolicy: 'unsafe' })
.asCustomFetch() as unknown as client.CustomFetch;
};
@@ -1,7 +1,6 @@
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import type { HttpRequestClient } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { CredentialTestContext } from '../credentials-test-context';
@@ -14,10 +13,10 @@ vi.mock('../utils/ssh-tunnel-helper-functions', () => ({
/**
* `CredentialTestContext` is the execution context for function-based credential
* tests. Its `helpers.request` must forward the execution's SSRF bridge so that
* test requests honour the same egress policy as regular node execution. These
* tests assert that wiring; the actual SSRF enforcement lives in
* `@n8n/backend-network`.
* tests. Its `helpers.request` must go through the default (safe) request
* client so that test requests honour the same egress policy as regular node
* execution. These tests assert that wiring; the actual SSRF enforcement lives
* in `@n8n/backend-network`.
*/
describe('CredentialTestContext', () => {
const requestLegacy = vi.fn();
@@ -31,29 +30,12 @@ describe('CredentialTestContext', () => {
Container.set(OutboundHttp, outboundHttp);
});
it('forwards the SSRF bridge from additionalData to the request', async () => {
const ssrfBridge = mock<SsrfBridge>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge });
const context = new CredentialTestContext(additionalData);
await context.helpers.request('https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfBridge });
});
it('disables SSRF when additionalData carries no bridge', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined });
const context = new CredentialTestContext(additionalData);
await context.helpers.request('https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
it('disables SSRF when no additionalData is provided', async () => {
it('routes requests through the default safe client', async () => {
const context = new CredentialTestContext();
await context.helpers.request('https://example.test');
const body = await context.helpers.request('https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(body).toBe('response-body');
expect(requests).toHaveBeenCalledWith();
expect(requestLegacy).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { httpRequest } from '@n8n/backend-network';
import { httpRequest } from '@n8n/backend-network/testing';
import {
buildRfcStyleUserAgent,
getDefaultN8nOutboundUserAgent,
@@ -1,7 +1,9 @@
import type { Logger } from '@n8n/backend-common';
import type { DnsResolver, SsrfBridge } from '@n8n/backend-network';
import { httpRequest, SsrfProtectionService } from '@n8n/backend-network';
import type { DnsResolver } from '@n8n/backend-network';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { httpRequest } from '@n8n/backend-network/testing';
import { SsrfProtectionConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type {
IHttpRequestOptions,
INode,
@@ -35,7 +37,7 @@ function createMockDnsResolver(
function createSsrfBridge(
configOverrides: Partial<SsrfProtectionConfig> = {},
dnsResolver = createMockDnsResolver(),
): { ssrfBridge: SsrfBridge; dnsResolver: MockProxy<DnsResolver> } {
): { ssrfBridge: SsrfProtectionService; dnsResolver: MockProxy<DnsResolver> } {
const scopedLogger = mock<Logger>();
const logger = mock<Logger>({ scoped: vi.fn().mockReturnValue(scopedLogger) });
const config = createConfig(configOverrides);
@@ -43,7 +45,19 @@ function createSsrfBridge(
return { ssrfBridge, dnsResolver };
}
function createRequestHelpers(ssrfBridge?: SsrfBridge) {
// Installs an `OutboundHttp` in the container wired like production: the given
// service as the SSRF policy and `enabled` mirroring whether one is provided,
// so `helpers.httpRequest` picks it up through the default safe mode.
function createRequestHelpers(ssrfBridge?: SsrfProtectionService) {
Container.set(
OutboundHttp,
new OutboundHttp(
ssrfBridge ?? mock<SsrfProtectionService>(),
createConfig({ enabled: ssrfBridge !== undefined }),
mock<Logger>(),
),
);
const workflow = mock<Workflow>();
const node = mock<INode>();
const hooks = mock<ExecutionLifecycleHooks>();
@@ -1,5 +1,5 @@
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import type { HttpRequestClient } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type {
INode,
@@ -15,12 +15,12 @@ type OnFetchedCallbacks = { onFetched?: () => Promise<void> | void };
/**
* `proxyRequestToAxios` is a thin adapter over {@link OutboundHttp}: it
* normalises the call arguments, derives the SSRF policy from the execution's
* `ssrfBridge`, and wires the `nodeFetchedData` hook to the client's `onFetched`
* callback. The actual request behaviour (SSRF enforcement, redirects, error
* shapes, domain allowlist) lives with `executeLegacyRequest` and is covered in
* `@n8n/backend-network`'s `legacy-request.test.ts`. These tests only assert the
* adapter's wiring contract, so the facade is mocked.
* normalises the call arguments, requests the default (safe) client, and wires
* the `nodeFetchedData` hook to the client's `onFetched` callback. The actual
* request behaviour (SSRF enforcement, redirects, error shapes, domain
* allowlist) lives with `executeLegacyRequest` and is covered in
* `@n8n/backend-network`'s `legacy-request.test.ts`. These tests only assert
* the adapter's wiring contract, so the facade is mocked.
*/
describe('proxyRequestToAxios', () => {
const workflow = mock<Workflow>({ id: 'workflow-id' });
@@ -38,27 +38,18 @@ describe('proxyRequestToAxios', () => {
});
describe('SSRF policy mapping', () => {
it('disables SSRF when the execution provides no bridge', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined });
it('always requests the default safe client', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>();
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(requests).toHaveBeenCalledWith();
});
it('forwards the execution SSRF bridge when present', async () => {
const ssrfBridge = mock<SsrfBridge>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge });
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfBridge });
});
it('disables SSRF when there is no additionalData at all', async () => {
it('requests the default safe client when there is no additionalData at all', async () => {
await proxyRequestToAxios(workflow, undefined, node, 'https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(requests).toHaveBeenCalledWith();
});
});
@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { httpRequest, removeEmptyBody } from '@n8n/backend-network';
import { OutboundHttp, removeEmptyBody } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type {
IAdditionalCredentialOptions,
IAllExecuteFunctions,
@@ -102,7 +103,7 @@ export async function httpRequestWithAuthentication(
workflow,
node,
);
return await httpRequest(requestOptions, additionalData.ssrfBridge);
return await Container.get(OutboundHttp).requests().request(requestOptions);
} catch (error) {
// if there is a pre authorization method defined and
// the method failed due to unauthorized request
@@ -136,7 +137,7 @@ export async function httpRequestWithAuthentication(
node,
);
}
return await httpRequest(requestOptions, additionalData.ssrfBridge);
return await Container.get(OutboundHttp).requests().request(requestOptions);
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
@@ -1,4 +1,5 @@
import { httpRequest } from '@n8n/backend-network';
import { OutboundHttp } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type {
IAllExecuteFunctions,
IExecuteData,
@@ -84,7 +85,7 @@ export const getRequestHelperFunctions = (
requestOptions.headers as Record<string, string>,
);
}
return await httpRequest(requestOptions, additionalData.ssrfBridge);
return await Container.get(OutboundHttp).requests().request(requestOptions);
},
getSecureEgressFilter: () => additionalData.ssrfBridge,
async requestWithAuthenticationPaginated(
@@ -22,11 +22,7 @@ export async function proxyRequestToAxios(
const configObject: IRequestOptions =
typeof uriOrObject === 'string' ? { uri: uriOrObject, ...options } : (uriOrObject ?? {});
// The legacy path only enforces SSRF when the execution provides a bridge;
// otherwise it connects directly (no protection), so default to `'disabled'`.
const client = Container.get(OutboundHttp).requests({
ssrf: additionalData?.ssrfBridge ?? 'disabled',
});
const client = Container.get(OutboundHttp).requests();
return await client.requestLegacy(configObject, {
onFetched: async () => {
@@ -1,6 +1,5 @@
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { Container } from '@n8n/di';
export const TOKEN_REQUEST_TIMEOUT = 30_000;
@@ -26,10 +25,7 @@ export const TOKEN_REQUEST_TIMEOUT = 30_000;
* layer entirely (no SSRF guard, no proxy, no timeout).
*/
export function getTokenRequestClient(host: 'fixed-vendor' | 'user-controlled'): HttpRequestClient {
const ssrf =
host === 'fixed-vendor' || !Container.get(SsrfProtectionConfig).enabled
? 'disabled'
: Container.get(SsrfProtectionService);
return Container.get(OutboundHttp).requests({ ssrf });
return host === 'fixed-vendor'
? Container.get(OutboundHttp).requests({ useDefaultSsrfPolicy: 'unsafe' })
: Container.get(OutboundHttp).requests();
}
@@ -1,5 +1,4 @@
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type { IHttpRequestOptions } from 'n8n-workflow';
@@ -10,8 +9,6 @@ describe('CiscoSecureEndpointApi Credential', () => {
const requestMock = vi.fn();
const requestsMock = vi.fn(() => ({ request: requestMock }));
const ssrfService = {} as SsrfProtectionService;
let ssrfEnabled = false;
const baseCredentials = {
region: 'eu.amp',
@@ -26,7 +23,6 @@ describe('CiscoSecureEndpointApi Credential', () => {
};
beforeEach(() => {
ssrfEnabled = false;
requestMock.mockReset();
requestMock
.mockResolvedValueOnce({ access_token: 'secure-x-token' })
@@ -35,8 +31,6 @@ describe('CiscoSecureEndpointApi Credential', () => {
vi.spyOn(Container, 'get').mockImplementation((token: unknown) => {
if (token === OutboundHttp) return { requests: requestsMock };
if (token === SsrfProtectionConfig) return { enabled: ssrfEnabled };
if (token === SsrfProtectionService) return ssrfService;
throw new Error('unexpected DI token');
});
});
@@ -85,20 +79,10 @@ describe('CiscoSecureEndpointApi Credential', () => {
);
});
it('disables SSRF protection when it is turned off in config', async () => {
ssrfEnabled = false;
it('requests the default safe client for the user-controlled token host', async () => {
await credential.authenticate(baseCredentials, requestOptions);
expect(requestsMock).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
it('enables SSRF protection when it is turned on in config', async () => {
ssrfEnabled = true;
await credential.authenticate(baseCredentials, requestOptions);
expect(requestsMock).toHaveBeenCalledWith({ ssrf: ssrfService });
expect(requestsMock).toHaveBeenCalledWith();
});
it('attaches the Secure Endpoint access token to the outgoing request', async () => {
@@ -86,7 +86,7 @@ describe('GoogleApi Credential', () => {
it('posts the JWT assertion as a form-urlencoded token request', async () => {
await credential.authenticate(baseCredentials, requestOptions);
expect(requestsMock).toHaveBeenCalledWith({ ssrf: 'disabled' });
expect(requestsMock).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://oauth2.googleapis.com/token',
@@ -49,9 +49,9 @@ describe('MicrosoftEntraServicePrincipalApi Credential', () => {
requestMock.mockResolvedValue({ access_token: 'abc', token_type: 'Bearer', expires_in: 3599 });
requestsMock.mockClear();
// Stub ONLY OutboundHttp. The `fixed-vendor` path short-circuits before reading
// SsrfProtectionConfig/Service, so reaching any other DI token signals a regression
// (e.g. switching to `user-controlled`) and must fail loudly.
// Stub ONLY OutboundHttp. `getTokenRequestClient` resolves nothing else from
// the container, so reaching any other DI token signals a regression and
// must fail loudly.
vi.spyOn(Container, 'get').mockImplementation((token: unknown) => {
if (token === OutboundHttp) return { requests: requestsMock };
throw new Error('unexpected DI token');
@@ -189,9 +189,9 @@ describe('MicrosoftEntraServicePrincipalApi Credential', () => {
it('mints via the SSRF-exempt fixed-vendor client', async () => {
await callPreAuthentication(baseCredentials);
// `{ ssrf: 'disabled' }` proves the fixed-vendor path; the `throw on other DI token`
// mock proves SsrfProtectionConfig/Service are never consulted.
expect(requestsMock).toHaveBeenCalledWith({ ssrf: 'disabled' });
// `{ useDefaultSsrfPolicy: 'unsafe' }` proves the fixed-vendor path; the `throw on
// other DI token` mock proves nothing else is resolved from the container.
expect(requestsMock).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe' });
});
it('re-mints on each call without internal memoization', async () => {
@@ -1,5 +1,4 @@
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { OutboundHttp } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import jwt from 'jsonwebtoken';
import type {
@@ -24,8 +23,6 @@ describe('SalesforceJwtApi Credential', () => {
const requestMock = vi.fn();
const requestsMock = vi.fn(() => ({ request: requestMock }));
const ssrfService = {} as SsrfProtectionService;
let ssrfEnabled = false;
// `this` for preAuthentication is unused now that the token POST goes through
// the shared HTTP client, but the signature still requires the helper context.
@@ -38,7 +35,6 @@ describe('SalesforceJwtApi Credential', () => {
};
beforeEach(() => {
ssrfEnabled = false;
requestMock.mockReset();
requestMock.mockResolvedValue({
access_token: 'abc123',
@@ -50,8 +46,6 @@ describe('SalesforceJwtApi Credential', () => {
vi.spyOn(Container, 'get').mockImplementation((token: unknown) => {
if (token === OutboundHttp) return { requests: requestsMock };
if (token === SsrfProtectionConfig) return { enabled: ssrfEnabled };
if (token === SsrfProtectionService) return ssrfService;
throw new Error('unexpected DI token');
});
});
@@ -242,28 +236,14 @@ describe('SalesforceJwtApi Credential', () => {
);
});
it('disables SSRF protection when it is turned off in config', async () => {
ssrfEnabled = false;
it('requests the default safe client for the user-controlled token host', async () => {
await callPreAuthentication({
...baseCredentials,
environment: 'production',
myDomainUrl: '',
});
expect(requestsMock).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
it('enables SSRF protection when it is turned on in config', async () => {
ssrfEnabled = true;
await callPreAuthentication({
...baseCredentials,
environment: 'production',
myDomainUrl: '',
});
expect(requestsMock).toHaveBeenCalledWith({ ssrf: ssrfService });
expect(requestsMock).toHaveBeenCalledWith();
});
it('returns the access token and instance URL to cache', async () => {