diff --git a/packages/@n8n/backend-network/README.md b/packages/@n8n/backend-network/README.md index 54ec59449f4..142fd73fb70 100644 --- a/packages/@n8n/backend-network/README.md +++ b/packages/@n8n/backend-network/README.md @@ -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. diff --git a/packages/@n8n/backend-network/src/http/__tests__/legacy-request.test.ts b/packages/@n8n/backend-network/src/http/__tests__/legacy-request.test.ts index 30cb38f3ed1..5da9580c2d8 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/legacy-request.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/legacy-request.test.ts @@ -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(), mock()); +// 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(bridge) : mock(); + return new OutboundHttp(service, mock({ enabled: true }), mock()); } 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({ diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts index f38e266a343..63cdbc66b29 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts @@ -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 { return server; } -function makeTransport(options?: Parameters[0]) { - return new OutboundHttp(mock(), mock()).transport(options); +function makeTransport(options?: Parameters[0], bridge?: SsrfBridge) { + const service = bridge ? mock(bridge) : mock(); + return new OutboundHttp( + service, + mock({ enabled: true }), + mock(), + ).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().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().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(); diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.dispatcher.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.dispatcher.test.ts index 50f757b4fe2..b07466680a7 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.dispatcher.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.dispatcher.test.ts @@ -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[0]) { - return new OutboundHttp(mock(), mock()).transport(options); + return new OutboundHttp( + mock(), + mock({ enabled: true }), + mock(), + ).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); diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.error-tagging.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.error-tagging.test.ts index bf2637ea73d..c39abc5c494 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.error-tagging.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.error-tagging.test.ts @@ -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(), mock()).requests({ ssrf: 'disabled' }); + new OutboundHttp( + mock(), + mock({ enabled: true }), + mock(), + ).requests({ useDefaultSsrfPolicy: 'unsafe' }); describe('OutboundHttp error tagging', () => { let server: LocalServer; diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.node-agent.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.node-agent.test.ts index a642d18eee9..bb03cdc9ceb 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.node-agent.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.node-agent.test.ts @@ -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(); - vi.mocked(service.createSecureLookup).mockReturnValue(makeLookupFn()); - return new OutboundHttp(service, mock()); +function makeFacade(bridge?: SsrfBridge): OutboundHttp { + const service = bridge + ? mock(bridge) + : mock({ createSecureLookup: vi.fn().mockReturnValue(makeLookupFn()) }); + return new OutboundHttp(service, mock({ enabled: true }), mock()); } // 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(); diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.requests.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.requests.test.ts index 2f09831d289..d8e15023425 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.requests.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.requests.test.ts @@ -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(); } -function makeFacade(service: SsrfProtectionService = makeService()): OutboundHttp { - return new OutboundHttp(service, mock()); +function makeFacade(service: SsrfProtectionService = makeService(), enabled = true): OutboundHttp { + return new OutboundHttp(service, mock({ enabled }), mock()); } 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(); - 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); diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.ssrf.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.ssrf.test.ts index d4a1408bc7f..f24b29b3f86 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.ssrf.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.ssrf.test.ts @@ -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[0]) { - return new OutboundHttp(mock(), mock()).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[0]> & { + ssrf?: SsrfBridge; + enabled?: boolean; + }, +) { + const { ssrf, enabled = true, ...transportOptions } = options ?? {}; + const service = ssrf ? mock(ssrf) : mock(); + return new OutboundHttp( + service, + mock({ enabled }), + mock(), + ).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(); }); diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.test.ts index 17ccac8becd..9edfb80d09d 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.test.ts @@ -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(); vi.mocked(service.createSecureLookup).mockReturnValue(makeLookupFn()); vi.mocked(service.validateUrl).mockResolvedValue({ ok: true, result: undefined }); - return new OutboundHttp(service, mock()); + return new OutboundHttp(service, mock({ enabled: true }), mock()); } 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'); diff --git a/packages/@n8n/backend-network/src/http/client-options.ts b/packages/@n8n/backend-network/src/http/client-options.ts index 5a6c2a0ad54..100a1ed8ad1 100644 --- a/packages/@n8n/backend-network/src/http/client-options.ts +++ b/packages/@n8n/backend-network/src/http/client-options.ts @@ -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`. diff --git a/packages/@n8n/backend-network/src/http/index.ts b/packages/@n8n/backend-network/src/http/index.ts index 29c92f1aaf8..78b5ec207f1 100644 --- a/packages/@n8n/backend-network/src/http/index.ts +++ b/packages/@n8n/backend-network/src/http/index.ts @@ -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'; diff --git a/packages/@n8n/backend-network/src/http/outbound-http.ts b/packages/@n8n/backend-network/src/http/outbound-http.ts index 38d1be809c8..e8c3009e9a8 100644 --- a/packages/@n8n/backend-network/src/http/outbound-http.ts +++ b/packages/@n8n/backend-network/src/http/outbound-http.ts @@ -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(factory: () => T): () => T { diff --git a/packages/@n8n/backend-network/src/http/use-default-ssrf-policy.ts b/packages/@n8n/backend-network/src/http/use-default-ssrf-policy.ts new file mode 100644 index 00000000000..ebcbbce90f0 --- /dev/null +++ b/packages/@n8n/backend-network/src/http/use-default-ssrf-policy.ts @@ -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'; diff --git a/packages/@n8n/backend-network/src/testing.ts b/packages/@n8n/backend-network/src/testing.ts index d0e46811d11..c18d084f711 100644 --- a/packages/@n8n/backend-network/src/testing.ts +++ b/packages/@n8n/backend-network/src/testing.ts @@ -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'; diff --git a/packages/@n8n/config/src/configs/ssrf-protection.config.ts b/packages/@n8n/config/src/configs/ssrf-protection.config.ts index 57d9028469d..4e27f108979 100644 --- a/packages/@n8n/config/src/configs/ssrf-protection.config.ts +++ b/packages/@n8n/config/src/configs/ssrf-protection.config.ts @@ -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. diff --git a/packages/cli/src/controllers/__tests__/telemetry.controller.test.ts b/packages/cli/src/controllers/__tests__/telemetry.controller.test.ts index 7494e150587..6ab11b2e9d8 100644 --- a/packages/cli/src/controllers/__tests__/telemetry.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/telemetry.controller.test.ts @@ -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', diff --git a/packages/cli/src/controllers/telemetry.controller.ts b/packages/cli/src/controllers/telemetry.controller.ts index e3374ec76f2..f6c0176e496 100644 --- a/packages/cli/src/controllers/telemetry.controller.ts +++ b/packages/cli/src/controllers/telemetry.controller.ts @@ -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', diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 4c863d8772f..df8e8d8a4a9 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -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, }); } diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index 48d896cea73..3fe8e6c8a8b 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -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(), mock(), - mock({ enabled: true }), - mock(), mock(), mock(), mock(), diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts index 9b08457e17f..9ccf5fb77f3 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts @@ -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(), mock(), - mock({ enabled: true }), - mock(), credentialsFinderService, workflowFinderService, mock(), diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 87df0111469..c9284a808f5 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -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({ enabled: true }), - mock(), mock(), 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' }), ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index d7241070bdc..aaca01dedad 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -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(), - mock({ enabled: true }), - mock(), mock(), mock(), mock(), diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index 0187c25f8af..055192b41ae 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -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, { diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index b03fcda32d1..dbd998ff57e 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -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), }), diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/telegram/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/telegram/replay-test-context.ts index 1079c194c1e..b63e3669f6e 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/telegram/replay-test-context.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/telegram/replay-test-context.ts @@ -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(), mock({ encryptionKey: 'test-encryption-key' }), mock(), - { enabled: false } as SsrfProtectionConfig, - mock(), ); } diff --git a/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts index 3a3bd9faf8c..c7d8ca1d811 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts @@ -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' }, diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index a5ad270237d..42155748bdb 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -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, ); diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts index a5effcb1264..74a8c9a6d7e 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts @@ -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; agentRepository?: Mocked; encryptionKey?: string; - ssrfEnabled?: boolean; } = {}, ) => { const urlService = @@ -74,16 +72,12 @@ const makeIntegration = ( const requestMock = httpClient.request as Mock; const outboundHttp = mock(); outboundHttp.requests.mockReturnValue(httpClient); - const ssrfConfig = { enabled: opts.ssrfEnabled ?? false } as SsrfProtectionConfig; - const ssrfProtectionService = mock(); const integration = new TelegramIntegration( mock(), 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 () => { diff --git a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts index ce42ce28663..cdd5d8c572b 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts @@ -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 }); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts index 33900d1b3c5..7ef91dd76a8 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts @@ -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', diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts index 2e50c99aa89..948dac9854b 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -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', diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index ba33cdb2406..24206868852 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -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, ) { 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), diff --git a/packages/cli/src/modules/community-packages/__tests__/npm-utils.ssrf.test.ts b/packages/cli/src/modules/community-packages/__tests__/npm-utils.ssrf.test.ts index f387aff0268..d976867a309 100644 --- a/packages/cli/src/modules/community-packages/__tests__/npm-utils.ssrf.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/npm-utils.ssrf.test.ts @@ -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({ 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); diff --git a/packages/cli/src/modules/community-packages/community-packages.service.ts b/packages/cli/src/modules/community-packages/community-packages.service.ts index f3458eb4769..c9da190b551 100644 --- a/packages/cli/src/modules/community-packages/community-packages.service.ts +++ b/packages/cli/src/modules/community-packages/community-packages.service.ts @@ -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, }); } diff --git a/packages/cli/src/modules/community-packages/npm-utils.ts b/packages/cli/src/modules/community-packages/npm-utils.ts index 9ebaa0c7d47..34127e8a5e3 100644 --- a/packages/cli/src/modules/community-packages/npm-utils.ts +++ b/packages/cli/src/modules/community-packages/npm-utils.ts @@ -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( 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', diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts index 621008cd4fa..403b767f881 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.integration.test.ts @@ -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(); 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); }; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts index 2b7ce52962f..f60b15e172a 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts @@ -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({ request })); - const httpClient = new OAuth2MetadataHttpClient( - logger, - cache, - outboundHttp, - mock(), - mock({ enabled: true }), - ); + const httpClient = new OAuth2MetadataHttpClient(logger, cache, outboundHttp); identifier = new OAuth2TokenIntrospectionIdentifier(logger, cache, httpClient); cache.get.mockResolvedValue(undefined); cache.set.mockResolvedValue(); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts index a53665b8a4b..ceadc3ee3ea 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-metadata-http-client.test.ts @@ -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(); - const buildClient = ( - configOverrides: Partial, - ssrfService = mock(), - ) => - new OAuth2MetadataHttpClient( - logger, - cache, - outboundHttp, - ssrfService, - mock(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(); - - 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, { diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts index 70d906a7b40..27283fdb476 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.integration.test.ts @@ -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(); 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); }; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts index 945edd8e3a4..d8c0587d964 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-userinfo-identifier.test.ts @@ -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({ request })); - const httpClient = new OAuth2MetadataHttpClient( - logger, - cache, - outboundHttp, - mock(), - mock({ enabled: true }), - ); + const httpClient = new OAuth2MetadataHttpClient(logger, cache, outboundHttp); identifier = new OAuth2UserInfoIdentifier(logger, cache, httpClient); cache.get.mockResolvedValue(undefined); cache.set.mockResolvedValue(); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts index 1750f577757..26e6f86e9cf 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-metadata-http-client.ts @@ -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, }); } diff --git a/packages/cli/src/modules/engine-v2/__tests__/engine-data-plane-client.test.ts b/packages/cli/src/modules/engine-v2/__tests__/engine-data-plane-client.test.ts index 0d7f30675dc..bdb9f7f564f 100644 --- a/packages/cli/src/modules/engine-v2/__tests__/engine-data-plane-client.test.ts +++ b/packages/cli/src/modules/engine-v2/__tests__/engine-data-plane-client.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/engine-v2/engine-data-plane-client.ts b/packages/cli/src/modules/engine-v2/engine-data-plane-client.ts index 55b83ed63df..bc8933e1ba9 100644 --- a/packages/cli/src/modules/engine-v2/engine-data-plane-client.ts +++ b/packages/cli/src/modules/engine-v2/engine-data-plane-client.ts @@ -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. diff --git a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/aws-secrets-manager.test.ts b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/aws-secrets-manager.test.ts index 028272e06b1..094b42139fd 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/aws-secrets-manager.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/aws-secrets-manager.test.ts @@ -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 diff --git a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts index 0e1645c6e4f..f9455a8a85a 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/infisical.test.ts @@ -118,7 +118,7 @@ describe('InfisicalProvider', () => { expect(requests).toHaveBeenCalledWith({ baseURL: SITE_URL, headers: expect.any(Function), - ssrf: 'disabled', + useDefaultSsrfPolicy: 'unsafe', }); }); diff --git a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/vault.test.ts b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/vault.test.ts index 6df31ddc7a1..a43d775f895 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/__tests__/vault.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/__tests__/vault.test.ts @@ -102,7 +102,7 @@ describe('VaultProvider', () => { expect(requests).toHaveBeenCalledWith({ baseURL: VAULT_URL, headers: expect.any(Function), - ssrf: 'disabled', + useDefaultSsrfPolicy: 'unsafe', }); }); diff --git a/packages/cli/src/modules/external-secrets.ee/providers/aws-secrets-manager.ts b/packages/cli/src/modules/external-secrets.ee/providers/aws-secrets-manager.ts index 43476c5f678..98a4e8459db 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/aws-secrets-manager.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/aws-secrets-manager.ts @@ -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 }); diff --git a/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts b/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts index 84d75714451..9974c0ba3aa 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/infisical.ts @@ -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'); diff --git a/packages/cli/src/modules/external-secrets.ee/providers/vault.ts b/packages/cli/src/modules/external-secrets.ee/providers/vault.ts index cf1ac62110a..bf64abfc1be 100644 --- a/packages/cli/src/modules/external-secrets.ee/providers/vault.ts +++ b/packages/cli/src/modules/external-secrets.ee/providers/vault.ts @@ -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'); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts index 9cbda23ed01..3eb56580381 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.security.test.ts @@ -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(), mock(), mock(), mock(), diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 017ce534049..a4077acb59b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1332,15 +1332,14 @@ function createNodeAdapterServiceForTests( {} as unknown as ConstructorParameters[28], {} as unknown as ConstructorParameters[29], {} as unknown as ConstructorParameters[30], - {} as unknown as ConstructorParameters[31], - mock() as unknown as ConstructorParameters[32], + mock() as unknown as ConstructorParameters[31], { isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters< typeof InstanceAiAdapterService - >[33], - {} as unknown as ConstructorParameters[34], + >[32], + {} as unknown as ConstructorParameters[33], createMockCollaborationService() as unknown as ConstructorParameters< typeof InstanceAiAdapterService - >[35], + >[34], nodeCatalogService, ); @@ -1698,15 +1697,14 @@ function createDataTableAdapterForTests(overrides?: { {} as unknown as ConstructorParameters[28], {} as unknown as ConstructorParameters[29], {} as unknown as ConstructorParameters[30], - {} as unknown as ConstructorParameters[31], - mock() as unknown as ConstructorParameters[32], + mock() as unknown as ConstructorParameters[31], { isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters< typeof InstanceAiAdapterService - >[33], - {} as unknown as ConstructorParameters[34], + >[32], + {} as unknown as ConstructorParameters[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[28], mockTelemetry as unknown as ConstructorParameters[29], mockAiBuilderTemporaryWorkflowRepository as unknown as AiBuilderTemporaryWorkflowRepository, - {} as unknown as ConstructorParameters[31], - mock() as unknown as ConstructorParameters[32], + mock() as unknown as ConstructorParameters[31], { isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters< typeof InstanceAiAdapterService - >[33], - {} as unknown as ConstructorParameters[34], + >[32], + {} as unknown as ConstructorParameters[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[29], {} as unknown as ConstructorParameters[30], - {} as unknown as ConstructorParameters[31], - mock() as unknown as ConstructorParameters[32], + mock() as unknown as ConstructorParameters[31], { isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters< typeof InstanceAiAdapterService - >[33], - {} as unknown as ConstructorParameters[34], + >[32], + {} as unknown as ConstructorParameters[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[28], mockTelemetry as unknown as ConstructorParameters[29], {} as unknown as ConstructorParameters[30], - {} as unknown as ConstructorParameters[31], - mock() as unknown as ConstructorParameters[32], + mock() as unknown as ConstructorParameters[31], { isEnabled: vi.fn().mockReturnValue(false) } as unknown as ConstructorParameters< typeof InstanceAiAdapterService - >[33], - {} as unknown as ConstructorParameters[34], + >[32], + {} as unknown as ConstructorParameters[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[number], ); args[0] = { @@ -4103,12 +4098,12 @@ function createAdapterWithGatewayMock( args[29] = (overrides?.telemetry ?? { track: vi.fn(), }) as unknown as ConstructorParameters[29]; - args[32] = mock() as unknown as ConstructorParameters< + args[31] = mock() 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), ); diff --git a/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts index a1b4a6abb65..842d63fb290 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/workflow-templates.service.test.ts @@ -28,7 +28,7 @@ describe('WorkflowTemplatesService', () => { makeService(); expect(requests).toHaveBeenCalledWith({ - ssrf: 'disabled', + useDefaultSsrfPolicy: 'unsafe', timeout: TEMPLATE_REQUEST_TIMEOUT_MS, }); }); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index b4559c32a76..82cbbe21873 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -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; diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts index 557eade8d26..384fd0cb7e2 100644 --- a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts @@ -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({ enabled: true }), - mock(), ); 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' }), ); }); diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts index ca61f0358dd..d7228cee548 100644 --- a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts @@ -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(); // 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) { diff --git a/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts b/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts index 37ce9f726a3..0584e8d31a3 100644 --- a/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts +++ b/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts @@ -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) { - return new OutboundHttp(mock(), mock()).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) { + const service = + ssrf === 'disabled' ? mock() : mock(ssrf); + return new OutboundHttp( + service, + mock({ enabled: true }), + mock(), + ).transport({ + useDefaultSsrfPolicy: ssrf === 'disabled' ? 'unsafe' : 'safe', authorize, }); } diff --git a/packages/cli/src/modules/instance-ai/workflow-templates.service.ts b/packages/cli/src/modules/instance-ai/workflow-templates.service.ts index d9e61d506b3..cb383597814 100644 --- a/packages/cli/src/modules/instance-ai/workflow-templates.service.ts +++ b/packages/cli/src/modules/instance-ai/workflow-templates.service.ts @@ -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, }); } diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts index 53469879397..5d5431e7279 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts @@ -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', diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts index e39ab34456f..23337a9bbb1 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts @@ -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) { diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index c109c9d1123..05cd5b6b2c2 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -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, ); diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 710ac75e007..252696394fc 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -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 }; diff --git a/packages/cli/src/modules/otel/otel.service.ts b/packages/cli/src/modules/otel/otel.service.ts index 438d017b57d..03a6a51ce4f 100644 --- a/packages/cli/src/modules/otel/otel.service.ts +++ b/packages/cli/src/modules/otel/otel.service.ts @@ -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), }); diff --git a/packages/cli/src/modules/quick-connect/handlers/__tests__/firecrawl.handler.test.ts b/packages/cli/src/modules/quick-connect/handlers/__tests__/firecrawl.handler.test.ts index 6f8f1d07cc3..b8499b45af2 100644 --- a/packages/cli/src/modules/quick-connect/handlers/__tests__/firecrawl.handler.test.ts +++ b/packages/cli/src/modules/quick-connect/handlers/__tests__/firecrawl.handler.test.ts @@ -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>()); new FirecrawlHandler(mock({ requests })); - expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled', timeout: 30_000 }); + expect(requests).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'unsafe', timeout: 30_000 }); }); describe('setConfig', () => { diff --git a/packages/cli/src/modules/quick-connect/handlers/firecrawl.handler.ts b/packages/cli/src/modules/quick-connect/handlers/firecrawl.handler.ts index 71ffeb79486..f1bbe242ab9 100644 --- a/packages/cli/src/modules/quick-connect/handlers/firecrawl.handler.ts +++ b/packages/cli/src/modules/quick-connect/handlers/firecrawl.handler.ts @@ -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, }); } diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts index 32da131a4ce..074d79b58d1 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts @@ -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(), logger); + const realOutboundHttp = new OutboundHttp( + mock(), + mock({ enabled: true }), + logger, + ); realOidcService = new OidcService( settingsRepository, authIdentityRepository, diff --git a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts index ded617584d2..d202b97aff8 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts @@ -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; diff --git a/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts b/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts index 30a748f197f..88b13153c87 100644 --- a/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts +++ b/packages/cli/src/modules/sso-saml/__tests__/saml.service.ee.test.ts @@ -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', diff --git a/packages/cli/src/modules/sso-saml/saml.service.ee.ts b/packages/cli/src/modules/sso-saml/saml.service.ee.ts index 1a38ea81874..99782a97d1d 100644 --- a/packages/cli/src/modules/sso-saml/saml.service.ee.ts +++ b/packages/cli/src/modules/sso-saml/saml.service.ee.ts @@ -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, diff --git a/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts b/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts index d87e1cf5a1d..c0b39cf7f82 100644 --- a/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts +++ b/packages/cli/src/modules/token-exchange/services/jwks-resolver.ts @@ -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; diff --git a/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts index 4482cd927d0..2616d54b757 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.integration.test.ts @@ -52,7 +52,7 @@ function buildService() { mock(), mock(), mock(), - new OutboundHttp(ssrf, mock()), + new OutboundHttp(ssrf, mock({ enabled: true }), mock()), ssrf, mock({ enabled: true }), ); diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index a610b4f4e7d..31571cbf420 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -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), }); }); diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index ec424853a03..110288df4ba 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -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, }); } diff --git a/packages/cli/src/security-audit/risk-reporters/__tests__/instance-risk-reporter.test.ts b/packages/cli/src/security-audit/risk-reporters/__tests__/instance-risk-reporter.test.ts index e57a268e5de..6f20049be28 100644 --- a/packages/cli/src/security-audit/risk-reporters/__tests__/instance-risk-reporter.test.ts +++ b/packages/cli/src/security-audit/risk-reporters/__tests__/instance-risk-reporter.test.ts @@ -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 () => { diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index 02792f394cf..17a7e350b22 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -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, }); } diff --git a/packages/cli/src/services/__tests__/ai-gateway.service.test.ts b/packages/cli/src/services/__tests__/ai-gateway.service.test.ts index f198226b6e8..4ad85ba3607 100644 --- a/packages/cli/src/services/__tests__/ai-gateway.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-gateway.service.test.ts @@ -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` }), ); diff --git a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts index 281acfb876f..fecd5e445a0 100644 --- a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts @@ -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({ requestLegacy })); Container.set(OutboundHttp, mock({ requests })); - const ssrfBridge = mock(); vi.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue( - mock({ ssrfBridge }), + mock(), ); 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 () => { diff --git a/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts b/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts index c2633e9a9cd..2b82e7f135b 100644 --- a/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts +++ b/packages/cli/src/services/__tests__/dynamic-node-parameters.service.test.ts @@ -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(); - const additionalData = mock({ ssrfBridge }); + const additionalData = mock(); 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({ ssrfBridge: undefined }); - - await invoke(additionalData); - - expect(method).toHaveBeenCalled(); - expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' }); + expect(requests).toHaveBeenCalledWith(); }, ); }); diff --git a/packages/cli/src/services/ai-gateway.service.ts b/packages/cli/src/services/ai-gateway.service.ts index 6a3eaef856c..7672b5729c2 100644 --- a/packages/cli/src/services/ai-gateway.service.ts +++ b/packages/cli/src/services/ai-gateway.service.ts @@ -95,7 +95,7 @@ export class AiGatewayService { ): Promise { 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, diff --git a/packages/cli/src/telemetry/__tests__/telemetry.test.ts b/packages/cli/src/telemetry/__tests__/telemetry.test.ts index c91f071ba72..0500c561555 100644 --- a/packages/cli/src/telemetry/__tests__/telemetry.test.ts +++ b/packages/cli/src/telemetry/__tests__/telemetry.test.ts @@ -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', diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 28c2293b608..1d04ecc74fc 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -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 = { diff --git a/packages/cli/src/utils/__tests__/ai-proxy-fetch.test.ts b/packages/cli/src/utils/__tests__/ai-proxy-fetch.test.ts index a96d0f51e9a..527e968b379 100644 --- a/packages/cli/src/utils/__tests__/ai-proxy-fetch.test.ts +++ b/packages/cli/src/utils/__tests__/ai-proxy-fetch.test.ts @@ -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(); transport.asCustomFetch.mockReturnValue(fetch); const outboundHttp = mock(); 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(); - transport.asCustomFetch.mockReturnValue(fetch); - const outboundHttp = mock(); - outboundHttp.transport.mockReturnValue(transport); - const ssrfConfig = { enabled: true } as SsrfProtectionConfig; - const ssrfProtectionService = mock(); + 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(); - transport.asCustomFetch.mockReturnValue(fetch); - const outboundHttp = mock(); - outboundHttp.transport.mockReturnValue(transport); - const ssrfConfig = { enabled: false } as SsrfProtectionConfig; - const ssrfProtectionService = mock(); + 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(); - transport.asCustomFetch.mockReturnValue(fetch); - const outboundHttp = mock(); - outboundHttp.transport.mockReturnValue(transport); - const ssrfConfig = { enabled: true } as SsrfProtectionConfig; - const ssrfProtectionService = mock(); - - 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(); - transport.asCustomFetch.mockReturnValue(fetch); - const outboundHttp = mock(); - outboundHttp.transport.mockReturnValue(transport); - const ssrfConfig = { enabled: false } as SsrfProtectionConfig; - const ssrfProtectionService = mock(); - - expect(createWebSearchFetch(outboundHttp, ssrfConfig, ssrfProtectionService)).toBe(fetch); - - expect(outboundHttp.transport).toHaveBeenCalledWith({ - proxy: 'env', - ssrf: 'disabled', }); }); }); diff --git a/packages/cli/src/utils/__tests__/strapi-utils.test.ts b/packages/cli/src/utils/__tests__/strapi-utils.test.ts index 0eb57f0a082..2fa367a5d64 100644 --- a/packages/cli/src/utils/__tests__/strapi-utils.test.ts +++ b/packages/cli/src/utils/__tests__/strapi-utils.test.ts @@ -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', diff --git a/packages/cli/src/utils/ai-proxy-fetch.ts b/packages/cli/src/utils/ai-proxy-fetch.ts index 0aa3ffabee4..8cd54cdd61f 100644 --- a/packages/cli/src/utils/ai-proxy-fetch.ts +++ b/packages/cli/src/utils/ai-proxy-fetch.ts @@ -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(); } diff --git a/packages/cli/src/utils/strapi-utils.ts b/packages/cli/src/utils/strapi-utils.ts index e9341bc0cb3..368ca7386ce 100644 --- a/packages/cli/src/utils/strapi-utils.ts +++ b/packages/cli/src/utils/strapi-utils.ts @@ -86,7 +86,7 @@ export async function paginatedRequest( 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>({ diff --git a/packages/cli/src/workflows/__tests__/workflows.controller.test.ts b/packages/cli/src/workflows/__tests__/workflows.controller.test.ts index ce6f051ef2b..30465234082 100644 --- a/packages/cli/src/workflows/__tests__/workflows.controller.test.ts +++ b/packages/cli/src/workflows/__tests__/workflows.controller.test.ts @@ -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(); const projectService = mock(); const logger = mock(); - const ssrfConfig = { enabled: false } as SsrfProtectionConfig; - const ssrfProtectionService = mock(); const httpClient = mock(); const outboundHttp = mock(); 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', () => { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 32d42bc7c2f..a8b7dc11f02 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -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({ method: 'GET', url }); diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index 19cc99d1424..bbdaf724f90 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -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, diff --git a/packages/cli/test/integration/oidc/oidc-discovery-http.test.ts b/packages/cli/test/integration/oidc/oidc-discovery-http.test.ts index 2ff266db5cb..397c6e1a30b 100644 --- a/packages/cli/test/integration/oidc/oidc-discovery-http.test.ts +++ b/packages/cli/test/integration/oidc/oidc-discovery-http.test.ts @@ -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(), mock()); + const outboundHttp = new OutboundHttp( + mock(), + mock({ enabled: true }), + mock(), + ); return outboundHttp - .transport({ ssrf: 'disabled' }) + .transport({ useDefaultSsrfPolicy: 'unsafe' }) .asCustomFetch() as unknown as client.CustomFetch; }; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/credentials-test-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/credentials-test-context.test.ts index 6ea728efccc..1a427ad5643 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/credentials-test-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/credentials-test-context.test.ts @@ -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(); - const additionalData = mock({ 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({ 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); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/outbound-user-agent.integration.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/outbound-user-agent.integration.test.ts index f968e1396d5..fa85ad7b3fe 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/outbound-user-agent.integration.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/outbound-user-agent.integration.test.ts @@ -1,4 +1,4 @@ -import { httpRequest } from '@n8n/backend-network'; +import { httpRequest } from '@n8n/backend-network/testing'; import { buildRfcStyleUserAgent, getDefaultN8nOutboundUserAgent, diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts index a5c6099bcfd..b450d811bba 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts @@ -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 = {}, dnsResolver = createMockDnsResolver(), -): { ssrfBridge: SsrfBridge; dnsResolver: MockProxy } { +): { ssrfBridge: SsrfProtectionService; dnsResolver: MockProxy } { const scopedLogger = mock(); const logger = mock({ 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(), + createConfig({ enabled: ssrfBridge !== undefined }), + mock(), + ), + ); + const workflow = mock(); const node = mock(); const hooks = mock(); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/__tests__/legacy-request-adapter.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/__tests__/legacy-request-adapter.test.ts index e225dc06b82..e563f6ceed2 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/__tests__/legacy-request-adapter.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/__tests__/legacy-request-adapter.test.ts @@ -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 }; /** * `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({ 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({ ssrfBridge: undefined }); + it('always requests the default safe client', async () => { + const additionalData = mock(); 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(); - const additionalData = mock({ 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(); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/authentication.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/authentication.ts index b8ac296cbcc..26840ddf648 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/authentication.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/authentication.ts @@ -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); } diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts index 489d1ae6401..4b4192ecff0 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts @@ -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, ); } - return await httpRequest(requestOptions, additionalData.ssrfBridge); + return await Container.get(OutboundHttp).requests().request(requestOptions); }, getSecureEgressFilter: () => additionalData.ssrfBridge, async requestWithAuthenticationPaginated( diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/legacy-request-adapter.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/legacy-request-adapter.ts index 392e65860b2..bd7f0de25bb 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/legacy-request-adapter.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/legacy-request-adapter.ts @@ -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 () => { diff --git a/packages/nodes-base/credentials/common/token-request.ts b/packages/nodes-base/credentials/common/token-request.ts index a271149dcb7..7f1abcbc21e 100644 --- a/packages/nodes-base/credentials/common/token-request.ts +++ b/packages/nodes-base/credentials/common/token-request.ts @@ -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(); } diff --git a/packages/nodes-base/credentials/test/CiscoSecureEndpointApi.credentials.test.ts b/packages/nodes-base/credentials/test/CiscoSecureEndpointApi.credentials.test.ts index f63d634882e..4bb20d6212c 100644 --- a/packages/nodes-base/credentials/test/CiscoSecureEndpointApi.credentials.test.ts +++ b/packages/nodes-base/credentials/test/CiscoSecureEndpointApi.credentials.test.ts @@ -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 () => { diff --git a/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts b/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts index a112510d725..8ad36388083 100644 --- a/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts +++ b/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts @@ -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', diff --git a/packages/nodes-base/credentials/test/MicrosoftEntraServicePrincipalApi.credentials.test.ts b/packages/nodes-base/credentials/test/MicrosoftEntraServicePrincipalApi.credentials.test.ts index b28f91acc40..01ca66b338b 100644 --- a/packages/nodes-base/credentials/test/MicrosoftEntraServicePrincipalApi.credentials.test.ts +++ b/packages/nodes-base/credentials/test/MicrosoftEntraServicePrincipalApi.credentials.test.ts @@ -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 () => { diff --git a/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts index 97dbb25d083..8f45073129a 100644 --- a/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts +++ b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts @@ -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 () => {