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