refactor(core): Define the outbound HTTP factory contract for backend-network (no-changelog) (#32245)

This commit is contained in:
Lorent Lempereur
2026-06-16 13:14:26 +02:00
committed by GitHub
parent de510ba2a3
commit ac9e5a87f0
47 changed files with 3302 additions and 1156 deletions
@@ -19,6 +19,25 @@
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts",
"require": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./src/testing.ts",
"require": "./dist/testing.js"
}
},
"typesVersions": {
"*": {
"testing": [
"dist/testing.d.ts"
]
}
},
"files": [
"dist/**/*"
],
@@ -37,6 +56,7 @@
"proxy-from-env": "catalog:",
"qs": "catalog:",
"reflect-metadata": "catalog:",
"undici": "catalog:undici-v7",
"zod": "catalog:"
},
"devDependencies": {
@@ -0,0 +1,60 @@
import http from 'node:http';
import type { MockInstance } from 'vitest';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
// drives which branch runs, so nothing hits the network.
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
proxyAddRequest: vi.fn(),
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
vi.mock('http-proxy-agent', () => ({
HttpProxyAgent: class {
addRequest = proxyAddRequest;
},
}));
const req = {} as http.ClientRequest;
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
describe('EnvProxyHttpAgent', () => {
let superAddRequest: MockInstance;
beforeEach(() => {
getProxyForUrl.mockReset();
proxyAddRequest.mockReset();
// `super.addRequest` is the only path that would open a real socket.
// `addRequest` is an internal Agent method untyped on the public types.
superAddRequest = vi
.spyOn(http.Agent.prototype, 'addRequest')
.mockImplementation(() => undefined) as unknown as MockInstance;
});
afterEach(() => superAddRequest.mockRestore());
it('delegates to the http proxy agent when a proxy applies', () => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
const opts = options({ host: 'a.example', port: 80 });
new EnvProxyHttpAgent().addRequest(req, opts);
expect(getProxyForUrl).toHaveBeenCalledWith('http://a.example');
expect(proxyAddRequest).toHaveBeenCalledWith(req, opts);
expect(superAddRequest).not.toHaveBeenCalled();
});
it('serves the request directly when no proxy applies', () => {
getProxyForUrl.mockReturnValue('');
const opts = options({ host: 'direct.example', port: 80 });
new EnvProxyHttpAgent().addRequest(req, opts);
expect(proxyAddRequest).not.toHaveBeenCalled();
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
});
});
@@ -0,0 +1,62 @@
import type http from 'node:http';
import https from 'node:https';
import type { MockInstance } from 'vitest';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
// drives which branch runs, so nothing hits the network.
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
proxyAddRequest: vi.fn(),
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
vi.mock('https-proxy-agent', () => ({
HttpsProxyAgent: class {
addRequest = proxyAddRequest;
},
}));
const req = {} as http.ClientRequest;
const options = (o: Partial<https.RequestOptions>): https.RequestOptions =>
o as https.RequestOptions;
describe('EnvProxyHttpsAgent', () => {
let superAddRequest: MockInstance;
beforeEach(() => {
getProxyForUrl.mockReset();
proxyAddRequest.mockReset();
// `super.addRequest` is the only path that would open a real socket.
// `addRequest` is an internal Agent method untyped on the public types.
superAddRequest = vi
.spyOn(https.Agent.prototype, 'addRequest')
.mockImplementation(() => undefined) as unknown as MockInstance;
});
afterEach(() => superAddRequest.mockRestore());
it('delegates to the https proxy agent when a proxy applies', () => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
const opts = options({ host: 'a.example', port: 443 });
new EnvProxyHttpsAgent().addRequest(req, opts);
expect(getProxyForUrl).toHaveBeenCalledWith('https://a.example');
expect(proxyAddRequest).toHaveBeenCalledWith(req, opts);
expect(superAddRequest).not.toHaveBeenCalled();
});
it('serves the request directly when no proxy applies', () => {
getProxyForUrl.mockReturnValue('');
const opts = options({ host: 'direct.example', port: 443 });
new EnvProxyHttpsAgent().addRequest(req, opts);
expect(proxyAddRequest).not.toHaveBeenCalled();
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
});
});
@@ -0,0 +1,113 @@
import { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import type http from 'node:http';
import { mock } from 'vitest-mock-extended';
import { EnvProxyRouter } from '../env-proxy-router';
// `getProxyForUrl` decides per request whether a proxy applies; the proxy agent
// is a plain factory callback, so the routing logic tests without any network
// or agent machinery.
const { getProxyForUrl } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
const createAgent = () => vi.fn((proxyUrl: string) => ({ proxyUrl }));
describe('EnvProxyRouter', () => {
beforeEach(() => getProxyForUrl.mockReset());
describe('proxy-resolution URL', () => {
it.each([
{ scheme: 'http', defaultPort: 80, opts: { host: 'h', port: 80 }, url: 'http://h' },
{ scheme: 'http', defaultPort: 80, opts: { host: 'h' }, url: 'http://h' },
{ scheme: 'http', defaultPort: 80, opts: { host: 'h', port: 8080 }, url: 'http://h:8080' },
{ scheme: 'http', defaultPort: 80, opts: { host: 'h', port: '8080' }, url: 'http://h:8080' },
{ scheme: 'http', defaultPort: 80, opts: { port: 80 }, url: 'http://localhost' },
{ scheme: 'https', defaultPort: 443, opts: { host: 'h', port: 443 }, url: 'https://h' },
{ scheme: 'https', defaultPort: 443, opts: { host: 'h', port: 8443 }, url: 'https://h:8443' },
{ scheme: 'http', defaultPort: 80, opts: { host: 'h', port: 'abc' }, url: 'http://h' },
] as const)('resolves $opts against $url', ({ scheme, defaultPort, opts, url }) => {
getProxyForUrl.mockReturnValue('');
new EnvProxyRouter(scheme, defaultPort, createAgent()).resolve(options(opts));
expect(getProxyForUrl).toHaveBeenCalledWith(url);
});
});
it('warns and falls back to the default port when the port is unparseable', () => {
const logger = mock<Logger>();
Container.set(Logger, logger);
getProxyForUrl.mockReturnValue('');
new EnvProxyRouter('http', 80, createAgent()).resolve(options({ host: 'h', port: 'abc' }));
expect(getProxyForUrl).toHaveBeenCalledWith('http://h');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('abc'));
});
it('does not warn when no port is provided', () => {
const logger = mock<Logger>();
Container.set(Logger, logger);
getProxyForUrl.mockReturnValue('');
new EnvProxyRouter('http', 80, createAgent()).resolve(options({ host: 'h' }));
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns undefined when no proxy applies', () => {
getProxyForUrl.mockReturnValue('');
expect(
new EnvProxyRouter('http', 80, createAgent()).resolve(options({ host: 'h' })),
).toBeUndefined();
});
it('creates one proxy agent per proxy URL and reuses it', () => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
const createProxyAgent = createAgent();
const router = new EnvProxyRouter('http', 80, createProxyAgent);
const first = router.resolve(options({ host: 'a.example' }));
const second = router.resolve(options({ host: 'b.example' }));
expect(createProxyAgent).toHaveBeenCalledTimes(1);
expect(first).toBe(second);
});
it('warns once and stops caching when the cache cap is exceeded', () => {
const logger = mock<Logger>();
Container.set(Logger, logger);
// A distinct proxy URL per request, so every call is a fresh cache entry.
getProxyForUrl.mockImplementation((url: string) => `http://proxy-${url}:3128`);
const createProxyAgent = createAgent();
const router = new EnvProxyRouter('http', 80, createProxyAgent);
// 64 entries fill the cache; the 65th and 66th exceed the cap.
for (let i = 0; i < 66; i++) {
router.resolve(options({ host: `h${i}.example` }));
}
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('reached its limit'));
});
it('creates a separate proxy agent per distinct proxy URL', () => {
getProxyForUrl
.mockReturnValueOnce('http://proxy-a:3128')
.mockReturnValueOnce('http://proxy-b:3128');
const createProxyAgent = createAgent();
const router = new EnvProxyRouter('http', 80, createProxyAgent);
const a = router.resolve(options({ host: 'a.example' }));
const b = router.resolve(options({ host: 'b.example' }));
expect(createProxyAgent).toHaveBeenCalledTimes(2);
expect(a).not.toBe(b);
});
});
@@ -4,6 +4,8 @@ import type { AddressInfo } from 'net';
import nock from 'nock';
import { promisify } from 'util';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
import { installGlobalProxyAgent, resolveProxyUrl, uninstallGlobalProxyAgent } from '../http-proxy';
interface TestResponse {
@@ -258,6 +260,35 @@ describe('HTTP Proxy Tests', () => {
});
});
describe('global agent lifecycle', () => {
test('installs env-proxy agents when a proxy env var is set', () => {
process.env.HTTP_PROXY = proxyServer.url;
installGlobalProxyAgent();
expect(http.globalAgent).toBeInstanceOf(EnvProxyHttpAgent);
expect(https.globalAgent).toBeInstanceOf(EnvProxyHttpsAgent);
});
test('is a no-op when no proxy env var is set', () => {
installGlobalProxyAgent();
expect(http.globalAgent).not.toBeInstanceOf(EnvProxyHttpAgent);
expect(https.globalAgent).not.toBeInstanceOf(EnvProxyHttpsAgent);
});
test('uninstall restores plain agents', () => {
process.env.HTTP_PROXY = proxyServer.url;
installGlobalProxyAgent();
uninstallGlobalProxyAgent();
expect(http.globalAgent).toBeInstanceOf(http.Agent);
expect(http.globalAgent).not.toBeInstanceOf(EnvProxyHttpAgent);
expect(https.globalAgent).not.toBeInstanceOf(EnvProxyHttpsAgent);
});
});
function setupDirectRequestMock(targetUrl: string) {
if (!nock.isActive()) nock.activate();
const url = new URL(targetUrl);
@@ -0,0 +1,298 @@
import type { Logger } from '@n8n/backend-common';
import nock from 'nock';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { OutboundHttp } from '../outbound-http';
function makeFacade(): OutboundHttp {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>());
}
describe('OutboundHttp.requests requestLegacy', () => {
beforeEach(() => {
nock.cleanAll();
});
describe('request handling', () => {
const baseUrl = 'https://example.test';
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 body = await client.requestLegacy({ url: `${baseUrl}/ok` }, { onFetched });
expect(body).toBe('hello');
expect(onFetched).toHaveBeenCalledTimes(1);
});
it('returns the full response when resolveWithFullResponse is set', async () => {
nock(baseUrl).get('/ok').reply(200, 'hello');
const client = makeFacade().requests({ ssrf: 'disabled' });
const response = await client.requestLegacy({
url: `${baseUrl}/ok`,
resolveWithFullResponse: true,
});
expect(response).toMatchObject({ body: 'hello', statusCode: 200 });
});
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' });
await expect(
client.requestLegacy({ url: `${baseUrl}/bad` }, { onFetched }),
).rejects.toMatchObject({
statusCode: 403,
status: 403,
message: '403 - "Forbidden"',
config: undefined,
request: undefined,
options: { method: 'get', url: `${baseUrl}/bad` },
response: { status: 403 },
});
expect(onFetched).not.toHaveBeenCalled();
});
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 body = await client.requestLegacy(
{ url: `${baseUrl}/missing`, simple: false },
{ onFetched },
);
expect(body).toBe('Not Found');
expect(onFetched).toHaveBeenCalledTimes(1);
});
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 response = await client.requestLegacy({
url: `${baseUrl}/missing`,
simple: false,
resolveWithFullResponse: true,
});
expect(response).toMatchObject({
body: 'Not Found',
statusCode: 404,
statusMessage: 'Not Found',
});
});
});
describe('redirects', () => {
const baseUrl = 'https://example.test';
const otherOrigin = 'https://otherdomain.test';
const basicAuth = 'Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk';
const reflectHeaders = function (this: { req: { headers: unknown } }) {
return this.req.headers;
};
it.each([[undefined], [true]])(
'forwards the authorization header on cross-origin redirects when sendCredentialsOnCrossOriginRedirect is %s',
async (sendCredentialsOnCrossOriginRedirect) => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${otherOrigin}/test` });
nock(otherOrigin).get('/test').reply(200, reflectHeaders);
const client = makeFacade().requests({ ssrf: 'disabled' });
const response = (await client.requestLegacy({
url: `${baseUrl}/redirect`,
auth: { username: 'testuser', password: 'testpassword' },
headers: { 'X-Other-Header': 'otherHeaderContent' },
resolveWithFullResponse: true,
sendCredentialsOnCrossOriginRedirect,
})) as { statusCode: number; body: string };
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBe(basicAuth);
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
},
);
it('does not forward the authorization header on cross-origin redirects when sendCredentialsOnCrossOriginRedirect is false', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${otherOrigin}/test` });
nock(otherOrigin).get('/test').reply(200, reflectHeaders);
const client = makeFacade().requests({ ssrf: 'disabled' });
const response = (await client.requestLegacy({
url: `${baseUrl}/redirect`,
auth: { username: 'testuser', password: 'testpassword' },
headers: { 'X-Other-Header': 'otherHeaderContent' },
resolveWithFullResponse: true,
sendCredentialsOnCrossOriginRedirect: false,
})) as { statusCode: number; body: string };
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBeUndefined();
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
});
it.each([[undefined], [true], [false]])(
'forwards the authorization header on same-origin redirects when sendCredentialsOnCrossOriginRedirect is %s',
async (sendCredentialsOnCrossOriginRedirect) => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, reflectHeaders);
const client = makeFacade().requests({ ssrf: 'disabled' });
const response = (await client.requestLegacy({
url: `${baseUrl}/redirect`,
auth: { username: 'testuser', password: 'testpassword' },
headers: { 'X-Other-Header': 'otherHeaderContent' },
resolveWithFullResponse: true,
sendCredentialsOnCrossOriginRedirect,
})) as { statusCode: number; body: string };
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBe(basicAuth);
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
},
);
it('follows redirects by default', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const client = makeFacade().requests({ ssrf: 'disabled' });
const response = await client.requestLegacy({
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
});
expect(response).toMatchObject({ body: 'Redirected', statusCode: 200 });
});
it('does not follow redirects when followRedirect is false', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const client = makeFacade().requests({ ssrf: 'disabled' });
await expect(
client.requestLegacy({
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
followRedirect: false,
}),
).rejects.toMatchObject({ statusCode: 301 });
});
});
describe('SSRF policy', () => {
const baseUrl = 'https://example.test';
it('validates the URL through the provided bridge', async () => {
nock(baseUrl).get('/ok').reply(200, 'ok');
const validateUrl = vi.fn().mockResolvedValue({ ok: true, result: undefined });
const bridge = {
validateUrl,
validateIp: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
} as unknown as SsrfBridge;
const client = makeFacade().requests({ ssrf: bridge });
await client.requestLegacy({ baseURL: baseUrl, url: '/ok' });
expect(validateUrl).toHaveBeenCalledWith(new URL(`${baseUrl}/ok`));
});
});
describe('domain allowlist', () => {
const baseUrl = 'https://example.com';
it('blocks requests to disallowed domains', async () => {
const client = makeFacade().requests({ ssrf: 'disabled' });
await expect(
client.requestLegacy({ url: `${baseUrl}/data`, allowedDomains: 'other.com' }),
).rejects.toThrow('Domain not allowed');
});
it.each([['example.com'], [undefined]])(
'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 body = await client.requestLegacy({ url: `${baseUrl}/data`, allowedDomains });
expect(body).toBe('ok');
},
);
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' });
await expect(
client.requestLegacy({
url: `${baseUrl}/redirect`,
allowedDomains: 'example.com',
followAllRedirects: true,
}),
).rejects.toThrow('Domain not allowed');
});
it.each([['example.com, allowed.com'], [undefined]])(
'allows redirects to allowed domains when allowedDomains is %s',
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 body = await client.requestLegacy({
url: `${baseUrl}/redirect`,
allowedDomains,
followAllRedirects: true,
});
expect(body).toBe('ok');
},
);
it('supports wildcard domains in allowedDomains', async () => {
nock('https://api.example.com').get('/data').reply(200, 'ok');
const client = makeFacade().requests({ ssrf: 'disabled' });
const body = await client.requestLegacy({
url: 'https://api.example.com/data',
allowedDomains: '*.example.com',
});
expect(body).toBe('ok');
});
it('blocks wildcard domains that do not match', async () => {
const client = makeFacade().requests({ ssrf: 'disabled' });
await expect(
client.requestLegacy({ url: 'https://blocked.com/data', allowedDomains: '*.example.com' }),
).rejects.toThrow('Domain not allowed');
});
});
});
@@ -0,0 +1,201 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import http from 'node:http';
import https from 'node:https';
import type { SsrfBridge } from '../../ssrf';
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
import { buildNodeAgents, installConnectionGuard } from '../node-agents';
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
// (unlike http.Agent and HttpProxyAgent which use `options`).
function getAgentLookup(agent: http.Agent | https.Agent): unknown {
const a = agent as {
options?: { lookup?: unknown };
connectOpts?: { lookup?: unknown };
};
return a.options?.lookup ?? a.connectOpts?.lookup;
}
// `http.Agent['options']` is not exposed on the public Node types.
function getAgentOptions(agent: http.Agent | https.Agent): { keepAlive?: boolean } {
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
}
// ---------------------------------------------------------------------------
// buildNodeAgents — shared builder (single source of truth for the undici
// factory, the axios transport layer, and the global proxy agents)
// ---------------------------------------------------------------------------
describe('buildNodeAgents', () => {
describe('agent classes per proxy mode', () => {
it('proxy: false → plain http/https.Agent (no proxy class)', () => {
const { httpAgent, httpsAgent } = buildNodeAgents(false, 'disabled');
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
expect(httpAgent).not.toBeInstanceOf(HttpProxyAgent);
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
});
it('proxy: env → EnvProxy agents', () => {
const { httpAgent, httpsAgent } = buildNodeAgents('env', 'disabled');
expect(httpAgent).toBeInstanceOf(EnvProxyHttpAgent);
expect(httpsAgent).toBeInstanceOf(EnvProxyHttpsAgent);
});
it('proxy: explicit URL → HttpProxyAgent / HttpsProxyAgent', () => {
const { httpAgent, httpsAgent } = buildNodeAgents('http://proxy.internal:3128', 'disabled');
expect(httpAgent).toBeInstanceOf(HttpProxyAgent);
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
});
});
describe('agent options forwarding', () => {
it('forwards options to plain agents (proxy: false)', () => {
const { httpAgent, httpsAgent } = buildNodeAgents(false, 'disabled', { keepAlive: true });
expect(getAgentOptions(httpAgent).keepAlive).toBe(true);
expect(getAgentOptions(httpsAgent).keepAlive).toBe(true);
});
it('forwards options to the env agent (which serves NO_PROXY targets directly)', () => {
const { httpAgent } = buildNodeAgents('env', 'disabled', { keepAlive: true });
expect(getAgentOptions(httpAgent).keepAlive).toBe(true);
});
});
describe('rejects a caller-provided lookup (managed by the SSRF policy)', () => {
const lookup = makeLookupFn();
it.each([
['ssrf disabled', 'disabled' as const],
['ssrf active', makeSsrfBridge()],
])('throws when agentOptions.lookup is set (%s)', (_label, ssrf) => {
expect(() => buildNodeAgents(false, ssrf, { lookup })).toThrow(
'`agentOptions.lookup` is not supported',
);
});
it('allows other agentOptions without a lookup', () => {
expect(() => buildNodeAgents(false, 'disabled', { keepAlive: true })).not.toThrow();
});
});
describe('SSRF lookup placement (direct connections only)', () => {
it('proxy: false → injects the secure lookup on both agents', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents(false, bridge);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('proxy: env → injects the secure lookup for the direct path', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents('env', bridge);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('proxy: explicit URL → does NOT inject the lookup (proxy validates the target)', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents('http://proxy.internal:3128', bridge);
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
it('ssrf disabled → no lookup on the direct path', () => {
const { httpAgent, httpsAgent } = buildNodeAgents('env', 'disabled');
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});
describe('direct-IP validation (installConnectionGuard)', () => {
type ConnFn = (
options: { host?: string | null; hostname?: string | null; port?: number },
onConnect?: (error: Error | null, stream?: unknown) => void,
) => unknown;
const connectionOf = (agent: http.Agent): ConnFn =>
(agent as unknown as { createConnection: ConnFn }).createConnection;
function guarded(bridge: SsrfBridge) {
const original = vi.fn().mockReturnValue('SOCKET');
const agent = { createConnection: original } as unknown as http.Agent;
installConnectionGuard(agent, bridge);
return { createConnection: connectionOf(agent), original };
}
it('blocks a connection the bridge rejects without opening a socket', () => {
const error = new Error('blocked');
const bridge = makeSsrfBridge({
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const { createConnection, original } = guarded(bridge);
const onCreate = vi.fn();
const result = createConnection({ host: '169.254.169.254', port: 80 }, onCreate);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('169.254.169.254');
expect(onCreate).toHaveBeenCalledWith(error);
expect(original).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('delegates to the underlying connection when the bridge allows the host', () => {
const bridge = makeSsrfBridge();
const { createConnection, original } = guarded(bridge);
const socket = createConnection({ host: '93.184.216.34', port: 80 }, vi.fn());
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('93.184.216.34');
expect(original).toHaveBeenCalledTimes(1);
expect(socket).toBe('SOCKET');
});
it('passes the raw host through to the bridge (normalization is the services job)', () => {
const bridge = makeSsrfBridge();
const { createConnection } = guarded(bridge);
createConnection({ host: '[::1]', port: 80 }, vi.fn());
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('[::1]');
});
it.each(['false', 'env'] as const)(
'buildNodeAgents (proxy: %s) blocks rejected direct connections on both agents',
(mode) => {
const error = new Error('blocked');
const bridge = makeSsrfBridge({
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const proxy = mode === 'false' ? false : 'env';
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, bridge);
const onHttp = vi.fn();
const onHttps = vi.fn();
connectionOf(httpAgent)({ host: '10.0.0.1', port: 80 }, onHttp);
connectionOf(httpsAgent)({ host: '10.0.0.1', port: 443 }, onHttps);
expect(onHttp).toHaveBeenCalledWith(error);
expect(onHttps).toHaveBeenCalledWith(error);
},
);
});
});
@@ -0,0 +1,54 @@
import type { Logger } from '@n8n/backend-common';
import { Agent, EnvHttpProxyAgent, ProxyAgent } from 'undici';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
import { OutboundHttp } from '../outbound-http';
// `getDispatcher()` proxy routing. SSRF is disabled in the proxy-class
// assertions so we inspect the concrete underlying dispatcher: with SSRF
// enabled `getDispatcher()` returns an undici `ComposedDispatcher` wrapper and
// 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);
}
describe('transport getDispatcher proxy routing', () => {
it('defaults to env-based proxy (EnvHttpProxyAgent dispatcher)', () => {
const dispatcher = makeTransport({ ssrf: 'disabled' }).getDispatcher();
expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
});
it('proxy: false → plain undici Agent', () => {
const dispatcher = makeTransport({ proxy: false, ssrf: 'disabled' }).getDispatcher();
expect(dispatcher).toBeInstanceOf(Agent);
expect(dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent);
expect(dispatcher).not.toBeInstanceOf(ProxyAgent);
});
it('proxy: env → EnvHttpProxyAgent', () => {
const dispatcher = makeTransport({ proxy: 'env', ssrf: 'disabled' }).getDispatcher();
expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
});
it('proxy: explicit URL → ProxyAgent', () => {
const dispatcher = makeTransport({
proxy: 'http://proxy.internal:3128',
ssrf: 'disabled',
}).getDispatcher();
expect(dispatcher).toBeInstanceOf(ProxyAgent);
expect(dispatcher).not.toBeInstanceOf(Agent);
expect(dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent);
});
it('returns the same dispatcher instance on repeated calls', () => {
const transport = makeTransport();
expect(transport.getDispatcher()).toBe(transport.getDispatcher());
});
});
@@ -0,0 +1,140 @@
import type { Logger } from '@n8n/backend-common';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import http from 'node:http';
import https from 'node:https';
import { mock } from 'vitest-mock-extended';
import type { 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>());
}
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
// (unlike http.Agent and HttpProxyAgent which use `options`).
function getAgentLookup(agent: http.Agent | https.Agent): unknown {
const a = agent as {
options?: { lookup?: unknown };
connectOpts?: { lookup?: unknown };
};
return a.options?.lookup ?? a.connectOpts?.lookup;
}
// ---------------------------------------------------------------------------
// getNodeAgent — proxy routing
// ---------------------------------------------------------------------------
describe('getNodeAgent', () => {
it('proxy: false → plain http/https.Agent (no proxy class)', () => {
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: false }).getNodeAgent();
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
expect(httpAgent).not.toBeInstanceOf(HttpProxyAgent);
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
});
it('proxy: explicit URL → HttpProxyAgent / HttpsProxyAgent', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ proxy: 'http://proxy.internal:3128' })
.getNodeAgent();
expect(httpAgent).toBeInstanceOf(HttpProxyAgent);
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
});
it('proxy: env → custom env-routing agents (http/https.Agent subclasses)', () => {
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: 'env' }).getNodeAgent();
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
});
it('returns the same agent instances on repeated calls', () => {
const client = makeFacade().transport();
const a1 = client.getNodeAgent();
const a2 = client.getNodeAgent();
expect(a1.httpAgent).toBe(a2.httpAgent);
expect(a1.httpsAgent).toBe(a2.httpsAgent);
});
it('builds fresh agents that forward per-call agent options', () => {
const client = makeFacade().transport({ proxy: false });
const cached = client.getNodeAgent();
const custom = client.getNodeAgent({ rejectUnauthorized: false });
expect(custom.httpsAgent).not.toBe(cached.httpsAgent);
expect(custom.httpsAgent.options.rejectUnauthorized).toBe(false);
});
});
// ---------------------------------------------------------------------------
// getNodeAgent — SSRF lookup injection
// ---------------------------------------------------------------------------
describe('getNodeAgent SSRF lookup injection', () => {
describe('proxy: false', () => {
it('injects createSecureLookup when SSRF is enabled', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: false })
.getNodeAgent();
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('does NOT inject lookup when SSRF is disabled', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: 'disabled', proxy: false })
.getNodeAgent();
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});
describe('proxy: explicit URL', () => {
it('does NOT inject lookup behind an explicit proxy (proxy validates the target)', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: 'http://proxy.internal:3128' })
.getNodeAgent();
// SSRF lookup is applied to direct connections only. Behind a proxy it
// would resolve the proxy host, not the final target, so it is omitted.
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});
describe('proxy: env', () => {
it('injects createSecureLookup when SSRF is enabled', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: 'env' })
.getNodeAgent();
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
// EnvProxy* agents inherit from http/https.Agent and pass lookup to super()
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
});
});
@@ -0,0 +1,67 @@
import type { Logger } from '@n8n/backend-common';
import type { IHttpRequestOptions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { httpRequest } from '../axios/request';
import { OutboundHttp } from '../outbound-http';
vi.mock('../axios/request', () => ({
httpRequest: vi.fn().mockResolvedValue({ statusCode: 200, body: 'ok' }),
}));
const mockedHttpRequest = vi.mocked(httpRequest);
// `httpRequest` is mocked, so the service is only passed through as the SSRF
// bridge — its methods are never actually invoked here.
function makeService(): SsrfProtectionService {
return mock<SsrfProtectionService>();
}
function makeFacade(service: SsrfProtectionService = makeService()): OutboundHttp {
return new OutboundHttp(service, mock<Logger>());
}
const REQUEST: IHttpRequestOptions = { url: 'https://example.test/x', method: 'GET' };
describe('OutboundHttp.requests', () => {
beforeEach(() => {
mockedHttpRequest.mockClear();
});
it('forwards the request and returns the response', async () => {
const client = makeFacade().requests();
const res = await client.request(REQUEST);
expect(res).toEqual({ statusCode: 200, body: 'ok' });
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, expect.anything());
});
it('uses the container SSRF service as the bridge by default', async () => {
const service = makeService();
const client = makeFacade(service).requests();
await client.request(REQUEST);
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 });
await client.request(REQUEST);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, bridge);
});
it('omits the bridge when SSRF protection is disabled', async () => {
const client = makeFacade().requests({ ssrf: 'disabled' });
await client.request(REQUEST);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined);
});
});
@@ -0,0 +1,257 @@
import type { Logger } from '@n8n/backend-common';
import type { Dispatcher } from 'undici';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { type LocalServer, startServer } from '../local-server';
import { OutboundHttp } from '../outbound-http';
import { createSsrfInterceptor } from '../undici/transport';
// SSRF enforcement lives in a single place: the dispatcher interceptor.
// This file proves it at two levels:
// (a) a direct unit test of `createSsrfInterceptor`, and
// (b) end-to-end tests against a real local server (no mocked `fetch`), so the
// interceptor actually runs and we assert that a 30x cannot smuggle a
// request past SSRF protection — via both `asCustomFetch()` and the
// dispatcher returned by `getDispatcher()`.
// Drain the microtask queue so the interceptor's async `validateUrl().then(...)`
// has settled before we assert.
const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0));
// The interceptor hands `validateUrl` a `URL` object (not a string), so we match
// on its `href` rather than comparing against a raw string.
const validatedUrl = (href: string) => expect.objectContaining({ href }) as unknown as URL;
// ---------------------------------------------------------------------------
// (a) createSsrfInterceptor — unit
// ---------------------------------------------------------------------------
function makeInterceptedDispatch(bridge: SsrfBridge) {
const innerDispatch = vi.fn();
const dispatch = createSsrfInterceptor(bridge)(
innerDispatch as unknown as Dispatcher['dispatch'],
);
return { innerDispatch, dispatch };
}
function makeHandler() {
return { onResponseError: vi.fn(), onError: vi.fn() } as unknown as Dispatcher.DispatchHandler & {
onResponseError: ReturnType<typeof vi.fn>;
onError: ReturnType<typeof vi.fn>;
};
}
function makeOpts(path: string, origin?: string) {
return { path, origin } as unknown as Dispatcher.DispatchOptions;
}
describe('createSsrfInterceptor', () => {
it('validates the reconstructed target URL and dispatches when allowed', async () => {
const bridge = makeSsrfBridge();
const { innerDispatch, dispatch } = makeInterceptedDispatch(bridge);
const handler = makeHandler();
const ret = dispatch(makeOpts('/data', 'https://api.example.com'), handler);
await flush();
expect(ret).toBe(true);
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl('https://api.example.com/data'));
expect(innerDispatch).toHaveBeenCalledTimes(1);
expect(handler.onResponseError).not.toHaveBeenCalled();
});
it('fails the dispatch and does not dispatch when SSRF rejects the target', async () => {
const error = new Error('SSRF: blocked');
const bridge = makeSsrfBridge({
validateUrl: vi.fn().mockResolvedValue({ ok: false, error }),
});
const { innerDispatch, dispatch } = makeInterceptedDispatch(bridge);
const handler = makeHandler();
dispatch(makeOpts('/secret', 'http://10.0.0.1'), handler);
await flush();
expect(innerDispatch).not.toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalledWith(null, error);
});
it('fails closed when the target URL cannot be derived', async () => {
const bridge = makeSsrfBridge();
const { innerDispatch, dispatch } = makeInterceptedDispatch(bridge);
const handler = makeHandler();
dispatch(makeOpts('not a url'), handler);
await flush();
expect(bridge.validateUrl).not.toHaveBeenCalled();
expect(innerDispatch).not.toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalled();
});
it('falls back to onError when onResponseError is unavailable', async () => {
const error = new Error('SSRF: blocked');
const bridge = makeSsrfBridge({
validateUrl: vi.fn().mockResolvedValue({ ok: false, error }),
});
const { dispatch } = makeInterceptedDispatch(bridge);
const handler = { onError: vi.fn() } as unknown as Dispatcher.DispatchHandler & {
onError: ReturnType<typeof vi.fn>;
};
dispatch(makeOpts('/secret', 'http://10.0.0.1'), handler);
await flush();
expect(handler.onError).toHaveBeenCalledWith(error);
});
});
// ---------------------------------------------------------------------------
// (b) end-to-end — real local server, real interceptor
// ---------------------------------------------------------------------------
async function startRedirectServer(): Promise<LocalServer> {
let serverUrl = '';
const server = await startServer((req, res) => {
if (req.url === '/start') {
res.writeHead(302, { Location: `${serverUrl}/internal` });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`reached:${req.url}`);
});
serverUrl = server.url;
return server;
}
function makeBridge(blockedPath: string): { bridge: SsrfBridge; error: Error } {
const error = new Error(`SSRF: blocked ${blockedPath}`);
const bridge = makeSsrfBridge({
validateUrl: vi.fn(async (url: string | URL) => {
const href = typeof url === 'string' ? url : url.href;
return href.includes(blockedPath)
? { ok: false as const, error }
: { ok: true as const, result: undefined };
}),
});
return { bridge, error };
}
function makeTransport(options?: Parameters<OutboundHttp['transport']>[0]) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport(options);
}
// Walk the `cause` chain to the deepest error message. undici wraps a
// pre-dispatch failure as `TypeError: fetch failed` with the original error in
// `.cause`, so the SSRF reason lives down the chain.
function rootCauseMessage(error: unknown): string {
let current = error;
const seen = new Set<unknown>();
while (
current instanceof Error &&
current.cause !== undefined &&
current.cause !== null &&
!seen.has(current)
) {
seen.add(current);
current = current.cause;
}
return current instanceof Error ? current.message : String(current);
}
describe('SSRF end-to-end', () => {
let server: LocalServer;
beforeEach(async () => {
server = await startRedirectServer();
});
afterEach(async () => {
await server.close();
});
describe('asCustomFetch', () => {
it('blocks the initial request when SSRF rejects its URL', async () => {
const { bridge, error } = makeBridge('/start');
const fetchFn = makeTransport({ ssrf: bridge, proxy: false }).asCustomFetch();
const rejection = await fetchFn(`${server.url}/start`).catch((e: unknown) => e);
expect(rejection).toBeInstanceOf(Error);
expect(rootCauseMessage(rejection)).toBe(error.message);
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl(`${server.url}/start`));
expect(server.captured).not.toContain('/start');
});
it('blocks a redirect to a target that SSRF rejects, even though the initial URL is allowed', async () => {
const { bridge } = makeBridge('/internal');
const fetchFn = makeTransport({ ssrf: bridge, proxy: false }).asCustomFetch();
await expect(fetchFn(`${server.url}/start`)).rejects.toThrow();
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl(`${server.url}/start`));
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl(`${server.url}/internal`));
expect(server.captured).toContain('/start');
expect(server.captured).not.toContain('/internal');
});
it('follows a redirect when every hop passes SSRF validation', async () => {
const { bridge } = makeBridge('/never-matches');
const fetchFn = makeTransport({ ssrf: bridge, proxy: false }).asCustomFetch();
const res = await fetchFn(`${server.url}/start`);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toBe('reached:/internal');
expect(server.captured).toEqual(['/start', '/internal']);
});
it('follows the redirect without validation when SSRF is disabled', async () => {
const { bridge } = makeBridge('/internal');
const fetchFn = makeTransport({ ssrf: 'disabled', 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();
expect(server.captured).toEqual(['/start', '/internal']);
});
});
describe('getDispatcher', () => {
it('enforces SSRF on the dispatcher: a redirect to a rejected target is blocked', async () => {
const { bridge } = makeBridge('/internal');
const client = makeTransport({ ssrf: bridge, proxy: false });
const dispatcher = client.getDispatcher();
const { fetch: undiciFetch } = await import('undici');
await expect(undiciFetch(`${server.url}/start`, { dispatcher })).rejects.toThrow();
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl(`${server.url}/start`));
expect(bridge.validateUrl).toHaveBeenCalledWith(validatedUrl(`${server.url}/internal`));
expect(server.captured).toContain('/start');
expect(server.captured).not.toContain('/internal');
await dispatcher.close();
});
it('does not validate when SSRF is disabled (bare dispatcher)', async () => {
const { bridge } = makeBridge('/internal');
const client = makeTransport({ ssrf: 'disabled', proxy: false });
const dispatcher = client.getDispatcher();
const { fetch: undiciFetch } = await import('undici');
const res = await undiciFetch(`${server.url}/start`, { dispatcher });
expect(res.status).toBe(200);
await expect(res.text()).resolves.toBe('reached:/internal');
expect(bridge.validateUrl).not.toHaveBeenCalled();
expect(server.captured).toEqual(['/start', '/internal']);
await dispatcher.close();
});
});
});
@@ -0,0 +1,97 @@
import type { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { fetch as undiciFetch } from 'undici';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { OutboundHttp } from '../outbound-http';
// Core facade wiring. `undiciFetch` is stubbed so no real network call is made
// and the SSRF dispatcher interceptor never runs here
vi.mock('undici', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const mod = (await importOriginal()) as Record<string, unknown>;
return {
...mod,
fetch: vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => 'ok',
}),
};
});
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>());
}
describe('DI registration', () => {
it('should be resolvable from the container', () => {
expect(Container.get(OutboundHttp)).toBeInstanceOf(OutboundHttp);
});
});
describe('transport asCustomFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('dispatches through the same guarded dispatcher returned by getDispatcher() when SSRF is enabled', async () => {
const transport = makeFacade().transport({ proxy: false });
const fetchFn = transport.asCustomFetch();
await fetchFn('https://api.example.com/data');
const [, calledInit] = vi.mocked(undiciFetch).mock.calls[0] as [
unknown,
{ dispatcher: unknown },
];
expect(calledInit.dispatcher).toBe(transport.getDispatcher());
});
it('dispatches through the bare dispatcher returned by getDispatcher() when SSRF is disabled', async () => {
const transport = makeFacade().transport({ proxy: false, ssrf: 'disabled' });
const fetchFn = transport.asCustomFetch();
await fetchFn('https://api.example.com/data');
const [, calledInit] = vi.mocked(undiciFetch).mock.calls[0] as [
unknown,
{ dispatcher: unknown },
];
expect(calledInit.dispatcher).toBe(transport.getDispatcher());
});
it('forwards the input unchanged to undiciFetch', async () => {
const fetchFn = makeFacade().transport().asCustomFetch();
const url = new URL('https://api.example.com/data');
await fetchFn(url);
const [calledInput] = vi.mocked(undiciFetch).mock.calls[0];
expect(calledInput).toBe(url);
});
it('passes init options through to undiciFetch', async () => {
const fetchFn = makeFacade().transport().asCustomFetch();
const init: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json' } };
await fetchFn('https://api.example.com/data', init);
const [, calledInit] = vi.mocked(undiciFetch).mock.calls[0] as [
unknown,
Record<string, unknown>,
];
expect(calledInit).toMatchObject({ method: 'POST' });
});
it('returns a new function on each call (fresh closure)', () => {
const transport = makeFacade().transport();
expect(transport.asCustomFetch()).not.toBe(transport.asCustomFetch());
});
});
@@ -0,0 +1,201 @@
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import dns from 'node:dns';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { getBeforeRedirectFn, setAxiosAgents } from '../axios/utils';
import { type LocalServer, startServer } from '../local-server';
import { buildNodeAgents } from '../node-agents';
// End-to-end parity tests for the outbound transport layer. These spin up real
// local HTTP servers (a target and a proxy) and drive requests through the
// agents produced by `buildNodeAgents` / `setAxiosAgents` — exactly the way
// axios uses them — so they assert observable routing behaviour rather than
// implementation details.
async function httpGetWithAgent(url: string, agent: http.Agent): Promise<string> {
return await new Promise((resolve, reject) => {
const req = http.get(url, { agent, timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('timeout'));
});
});
}
describe('outbound transport integration', () => {
let target: LocalServer;
let proxy: LocalServer;
const ORIGINAL_ENV = { ...process.env };
beforeAll(async () => {
target = await startServer((req, res) => {
if (req.url === '/redirect') {
res.writeHead(301, { Location: `${target.url}/final` });
res.end();
return;
}
const message = req.url === '/final' ? 'final' : 'direct';
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message }));
});
proxy = await startServer((_req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'proxied' }));
});
});
afterAll(async () => {
await target.close();
await proxy.close();
});
beforeEach(() => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.ALL_PROXY;
target.clear();
proxy.clear();
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
describe('setAxiosAgents routing', () => {
it('routes through an explicit custom proxy', async () => {
const config: AxiosRequestConfig = { url: `${target.url}/x`, method: 'GET', proxy: false };
setAxiosAgents(config, undefined, proxy.url);
const res = await axios(config);
expect(res.data.message).toBe('proxied');
expect(proxy.captured.length).toBeGreaterThan(0);
expect(target.captured).toHaveLength(0);
});
it('routes through the env proxy (HTTP_PROXY)', async () => {
process.env.HTTP_PROXY = proxy.url;
const config: AxiosRequestConfig = {
url: 'http://proxied-target.invalid/x',
method: 'GET',
proxy: false,
};
setAxiosAgents(config, undefined, undefined);
const res = await axios(config);
expect(res.data.message).toBe('proxied');
expect(proxy.captured.length).toBeGreaterThan(0);
});
it('connects directly when no proxy is configured', async () => {
const config: AxiosRequestConfig = { url: `${target.url}/x`, method: 'GET', proxy: false };
setAxiosAgents(config, undefined, undefined);
const res = await axios(config);
expect(res.data.message).toBe('direct');
expect(proxy.captured).toHaveLength(0);
});
it('bypasses the env proxy for NO_PROXY targets', async () => {
process.env.HTTP_PROXY = proxy.url;
process.env.NO_PROXY = '127.0.0.1';
const config: AxiosRequestConfig = { url: `${target.url}/x`, method: 'GET', proxy: false };
setAxiosAgents(config, undefined, undefined);
const res = await axios(config);
expect(res.data.message).toBe('direct');
expect(proxy.captured).toHaveLength(0);
});
});
describe('SSRF secure lookup is applied to direct connections only', () => {
it('invokes the secure lookup for a direct (hostname) connection', async () => {
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
dns.lookup(hostname, options, onResult as never),
);
const bridge = makeSsrfBridge({
createSecureLookup: () => lookupSpy as unknown as LookupFunction,
});
const config: AxiosRequestConfig = {
url: `http://localhost:${target.hostWithPort.split(':')[1]}/x`,
method: 'GET',
proxy: false,
};
setAxiosAgents(config, undefined, undefined, bridge);
const res = await axios(config);
expect(res.data.message).toBe('direct');
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
});
it('does NOT invoke the secure lookup when routed through a proxy', async () => {
process.env.HTTP_PROXY = proxy.url;
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
dns.lookup(hostname, options, onResult as never),
);
const bridge = makeSsrfBridge({
createSecureLookup: () => lookupSpy as unknown as LookupFunction,
});
const config: AxiosRequestConfig = {
url: 'http://proxied-target.invalid/x',
method: 'GET',
proxy: false,
};
setAxiosAgents(config, undefined, undefined, bridge);
const res = await axios(config);
expect(res.data.message).toBe('proxied');
// The proxy host is an IP (no lookup) and the proxy resolves the final
// target, so the secure lookup is never consulted on our side.
expect(lookupSpy).not.toHaveBeenCalled();
});
});
describe('env-proxy agent caching', () => {
it('reuses a single cached proxy agent for the same proxy URL', async () => {
process.env.HTTP_PROXY = proxy.url;
const { httpAgent } = buildNodeAgents('env', 'disabled');
await httpGetWithAgent('http://host-a.invalid/x', httpAgent);
await httpGetWithAgent('http://host-b.invalid/x', httpAgent);
const proxyCache = (httpAgent as unknown as { router: { proxyCache: Map<string, unknown> } })
.router.proxyCache;
expect(proxyCache.size).toBe(1);
expect(proxy.captured.length).toBeGreaterThanOrEqual(2);
});
});
describe('getBeforeRedirectFn', () => {
it('rebuilds working agents and follows a redirect to completion', async () => {
const config: AxiosRequestConfig = {
url: `${target.url}/redirect`,
method: 'GET',
proxy: false,
maxRedirects: 5,
};
config.beforeRedirect = getBeforeRedirectFn({}, config, undefined, true);
setAxiosAgents(config, {}, undefined);
const res = await axios(config);
expect(res.data.message).toBe('final');
});
});
});
@@ -3,7 +3,7 @@ import { Container } from '@n8n/di';
import type { InternalAxiosRequestConfig } from 'axios';
import axios, { AxiosHeaders } from 'axios';
import { configureGlobalAxiosDefaults } from '../axios-config';
import { configureGlobalAxiosDefaults } from '../config';
// Registers the axios defaults and the vendor-header interceptor under test.
configureGlobalAxiosDefaults();
@@ -0,0 +1,271 @@
import FormData from 'form-data';
import type { Agent as HttpsAgent } from 'https';
import type { IHttpRequestMethods, IRequestOptions } from 'n8n-workflow';
import nock from 'nock';
import type { SecureContextOptions } from 'tls';
import { mock } from 'vitest-mock-extended';
import { buildAxiosConfigFromLegacyRequest } from '../legacy';
const TEST_CA_CERT = '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----';
describe('buildAxiosConfigFromLegacyRequest', () => {
test('should handle basic request options', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: { key: 'value' },
});
expect(axiosOptions).toEqual(
expect.objectContaining({
url: 'https://example.com',
method: 'POST',
headers: {
accept: '*/*',
'content-type': 'application/json',
'User-Agent': 'n8n',
},
data: { key: 'value' },
maxRedirects: 0,
}),
);
});
test('should set default User-Agent when none provided', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
method: 'GET',
});
expect(axiosOptions.headers).toMatchObject({ 'User-Agent': 'n8n' });
});
test('should preserve a caller-supplied User-Agent header', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
method: 'GET',
headers: { 'User-Agent': 'MyCustomNode/1.0' },
});
expect(axiosOptions.headers).toMatchObject({ 'User-Agent': 'MyCustomNode/1.0' });
expect(axiosOptions.headers).not.toMatchObject({ 'User-Agent': 'n8n' });
});
test('should set correct headers for FormData', async () => {
const formData = new FormData();
formData.append('key', 'value');
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
formData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
accept: '*/*',
'content-length': 163,
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
expect(axiosOptions.data).toBeInstanceOf(FormData);
});
test('should handle FormData from a different module copy (duck-typing)', async () => {
// Simulate a FormData created by a different copy of the form-data package.
// instanceof FormData would return false, but duck-type check should pass.
const realFormData = new FormData();
realFormData.append('key', 'value');
// Create a wrapper that breaks instanceof but preserves the interface
const foreignFormData: Record<string, unknown> = Object.create(null);
for (const prop of Object.getOwnPropertyNames(Object.getPrototypeOf(realFormData))) {
const value = (realFormData as unknown as Record<string, unknown>)[prop];
if (typeof value === 'function') {
foreignFormData[prop] = value.bind(realFormData);
}
}
for (const prop of Object.getOwnPropertyNames(realFormData)) {
foreignFormData[prop] = (realFormData as unknown as Record<string, unknown>)[prop];
}
// Verify it's NOT an instanceof FormData
expect(foreignFormData instanceof FormData).toBe(false);
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
formData: foreignFormData as unknown as FormData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
});
test('should forward a string form body unchanged', async () => {
const form = 'user=john%20doe&token=a%2Bb';
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
method: 'POST',
// The legacy `request` library accepted pre-encoded string form bodies,
// even though the type only models object/FormData forms.
form: form as unknown as IRequestOptions['form'],
});
expect(axiosOptions.data).toBe(form);
expect(axiosOptions.headers).toMatchObject({
'Content-Type': 'application/x-www-form-urlencoded',
});
});
test('should serialize an object form body as x-www-form-urlencoded', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.com',
method: 'POST',
form: { foo: 'bar', baz: 'qux' },
});
expect(axiosOptions.data).toBe('foo=bar&baz=qux');
expect(axiosOptions.headers).toMatchObject({
'Content-Type': 'application/x-www-form-urlencoded',
});
});
test('should not use Host header for SNI', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: 'https://example.de/foo/bar',
headers: { Host: 'other.host.com' },
});
expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toEqual('example.de');
});
describe('should set SSL certificates', () => {
const agentOptions: SecureContextOptions = {
ca: TEST_CA_CERT,
};
const requestObject: IRequestOptions = {
method: 'GET',
uri: 'https://example.de',
agentOptions,
};
test('on regular requests', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest(requestObject);
expect((axiosOptions.httpsAgent as HttpsAgent).options).toMatchObject({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
test('on redirected requests', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest(requestObject);
expect(axiosOptions.beforeRedirect).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const redirectOptions: Record<string, any> = {
agents: {},
hostname: 'example.de',
href: requestObject.uri,
};
axiosOptions.beforeRedirect!(redirectOptions, mock(), mock());
expect(redirectOptions.agent).toEqual(redirectOptions.agents.https);
expect((redirectOptions.agent as HttpsAgent).options).toMatchObject({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
});
describe('when followRedirect is true', () => {
test.each(['GET', 'HEAD'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should not set maxRedirects on %s ',
async (method) => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(0);
},
);
});
describe('when followAllRedirects is true', () => {
test.each(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
method,
followAllRedirects: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
});
describe('domain allowlist enforcement', () => {
const baseUrl = 'https://example.com';
beforeEach(() => {
nock.cleanAll();
});
test('should pass allowedDomains to beforeRedirect', async () => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: `${baseUrl}/test`,
allowedDomains: 'example.com',
});
const redirectOptions = {
agents: {},
hostname: 'not-allowed.com',
href: 'https://not-allowed.com/data',
};
expect(axiosOptions.beforeRedirect).toBeDefined();
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock(), mock())).toThrow(
'Domain not allowed',
);
});
test.each([['example.com'], [undefined]])(
'should not block redirects when allowedDomains is %s',
async (allowedDomains) => {
const axiosOptions = await buildAxiosConfigFromLegacyRequest({
url: `${baseUrl}/test`,
allowedDomains,
});
const redirectOptions = {
agents: {},
hostname: 'example.com',
href: 'https://example.com/data',
};
expect(axiosOptions.beforeRedirect).toBeDefined();
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock(), mock())).not.toThrow();
},
);
});
});
@@ -6,14 +6,9 @@ import type { IHttpRequestMethods, IHttpRequestOptions, IRequestOptions } from '
import nock from 'nock';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge } from '../../ssrf';
import { configureGlobalAxiosDefaults } from '../axios-config';
import {
convertN8nRequestToAxios,
httpRequest,
invokeAxios,
removeEmptyBody,
} from '../http-request';
import type { SsrfBridge } from '../../../ssrf';
import { configureGlobalAxiosDefaults } from '../config';
import { convertN8nRequestToAxios, httpRequest, invokeAxios, removeEmptyBody } from '../request';
// Sets axios defaults and registers the vendor-header interceptor.
configureGlobalAxiosDefaults();
@@ -636,6 +631,7 @@ describe('SSRF protection', () => {
const createSsrfBridge = (overrides?: Partial<SsrfBridge>): SsrfBridge => ({
validateIp: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),
validateConnectionHost: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
...overrides,
@@ -2,7 +2,7 @@ import { HttpRequestConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import * as nodePathActual from 'node:path';
import { buildRfcStyleUserAgent, getDefaultN8nOutboundUserAgent } from '../outbound-user-agent';
import { buildRfcStyleUserAgent, getDefaultN8nOutboundUserAgent } from '../user-agent';
describe('outbound-user-agent', () => {
const originalConfig = new HttpRequestConfig();
@@ -69,7 +69,7 @@ describe('outbound-user-agent', () => {
const di = await import('@n8n/di');
const config = await import('@n8n/config');
const mod = await import('../outbound-user-agent');
const mod = await import('../user-agent');
di.Container.set(config.HttpRequestConfig, {
enforceGlobalUserAgent: true,
@@ -1,6 +1,11 @@
import { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import FormData from 'form-data';
import { mock } from 'vitest-mock-extended';
import { makeSsrfBridge } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { buildNodeAgents } from '../../node-agents';
import {
buildTargetUrl,
createFormDataObject,
@@ -13,12 +18,18 @@ import {
searchForHeader,
setAxiosAgents,
tryParseUrl,
} from '../axios-utils';
import { createHttpProxyAgent, createHttpsProxyAgent } from '../http-proxy';
} from '../utils';
vi.mock('../http-proxy', () => ({
createHttpProxyAgent: vi.fn((_proxy, _url, opts) => ({ type: 'http', ...opts })),
createHttpsProxyAgent: vi.fn((_proxy, _url, opts) => ({ type: 'https', ...opts })),
// Agent construction is owned by `buildNodeAgents` (./factory).
// SSRF lookup injection is exercised there; here we only assert the transport
// policy (proxy + ssrf option) that the axios utils forward to it.
vi.mock('../../node-agents', () => ({
buildNodeAgents: vi.fn((_proxy, _ssrf, opts) => ({
httpAgent: { type: 'http', ...opts },
httpsAgent: { type: 'https', ...opts },
})),
isSupportedProxyUrl: (value: string) =>
value.startsWith('http://') || value.startsWith('https://'),
}));
describe('isIgnoreStatusErrorConfig', () => {
@@ -141,6 +152,7 @@ describe('getBeforeRedirectFn', () => {
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateIp: vi.fn(),
validateUrl: vi.fn(),
validateConnectionHost: vi.fn(),
};
const beforeRedirect = getBeforeRedirectFn(
@@ -163,9 +175,8 @@ describe('getBeforeRedirectFn', () => {
expect(ssrfBridge.validateRedirectSync).toHaveBeenCalledWith('https://example.com/other');
});
test('should resolve proxy URL from proxyConfig and pass it to agent factories', () => {
vi.mocked(createHttpProxyAgent).mockClear();
vi.mocked(createHttpsProxyAgent).mockClear();
test('should resolve proxy URL from proxyConfig and pass it to buildNodeAgents', () => {
vi.mocked(buildNodeAgents).mockClear();
const beforeRedirect = getBeforeRedirectFn(
agentOptions,
@@ -182,14 +193,9 @@ describe('getBeforeRedirectFn', () => {
beforeRedirect(redirectedRequest);
expect(createHttpProxyAgent).toHaveBeenCalledWith(
expect(buildNodeAgents).toHaveBeenCalledWith(
'http://proxy:8080',
'https://example.com/other',
expect.objectContaining({ servername: 'example.com' }),
);
expect(createHttpsProxyAgent).toHaveBeenCalledWith(
'http://proxy:8080',
'https://example.com/other',
'disabled',
expect.objectContaining({ servername: 'example.com' }),
);
});
@@ -449,15 +455,14 @@ describe('setAxiosAgents', () => {
vi.clearAllMocks();
});
it('should set httpAgent and httpsAgent on config', () => {
it('should set httpAgent and httpsAgent on config (env proxy by default)', () => {
const config: AxiosRequestConfig = { url: 'https://example.com/api' };
setAxiosAgents(config);
expect(config.httpAgent).toEqual({ type: 'http' });
expect(config.httpsAgent).toEqual({ type: 'https' });
expect(createHttpProxyAgent).toHaveBeenCalledWith(null, 'https://example.com/api', undefined);
expect(createHttpsProxyAgent).toHaveBeenCalledWith(null, 'https://example.com/api', undefined);
expect(buildNodeAgents).toHaveBeenCalledWith('env', 'disabled', undefined);
});
it('should not override existing agents', () => {
@@ -470,7 +475,7 @@ describe('setAxiosAgents', () => {
setAxiosAgents(config);
expect(config.httpAgent).toBe(existingAgent);
expect(createHttpProxyAgent).not.toHaveBeenCalled();
expect(buildNodeAgents).not.toHaveBeenCalled();
});
it('should not set agents when url is missing', () => {
@@ -482,39 +487,44 @@ describe('setAxiosAgents', () => {
expect(config.httpsAgent).toBeUndefined();
});
it('should pass proxy URL to agent factories', () => {
it('should pass a custom proxy URL through to buildNodeAgents', () => {
const config: AxiosRequestConfig = { url: 'https://example.com' };
setAxiosAgents(config, undefined, 'http://proxy:8080');
expect(createHttpProxyAgent).toHaveBeenCalledWith(
'http://proxy:8080',
'https://example.com',
undefined,
);
expect(buildNodeAgents).toHaveBeenCalledWith('http://proxy:8080', 'disabled', undefined);
});
it('should inject secureLookup when no proxy is configured', () => {
it('should forward the ssrf option to buildNodeAgents when no proxy is configured', () => {
const config: AxiosRequestConfig = { url: 'https://example.com' };
const secureLookup = vi.fn();
const ssrf = makeSsrfBridge();
setAxiosAgents(config, {}, undefined, secureLookup as never);
setAxiosAgents(config, {}, undefined, ssrf);
expect(createHttpProxyAgent).toHaveBeenCalledWith(null, 'https://example.com', {
lookup: secureLookup,
});
// SSRF lookup injection is buildNodeAgents' responsibility (env path
// applies it to direct connections only).
expect(buildNodeAgents).toHaveBeenCalledWith('env', ssrf, {});
});
it('should not inject secureLookup when proxy is configured', () => {
it('should forward the ssrf option to buildNodeAgents when a proxy is configured', () => {
const config: AxiosRequestConfig = { url: 'https://example.com' };
const secureLookup = vi.fn();
const ssrf = makeSsrfBridge();
setAxiosAgents(config, {}, 'http://proxy:8080', secureLookup as never);
setAxiosAgents(config, {}, 'http://proxy:8080', ssrf);
expect(createHttpProxyAgent).toHaveBeenCalledWith(
'http://proxy:8080',
'https://example.com',
{},
);
// Behind a proxy, buildNodeAgents omits the lookup; the proxy validates
// the final target.
expect(buildNodeAgents).toHaveBeenCalledWith('http://proxy:8080', ssrf, {});
});
it('should fall back to env proxy and warn on an unsupported proxy URL', () => {
const logger = mock<Logger>();
Container.set(Logger, logger);
const config: AxiosRequestConfig = { url: 'https://example.com' };
setAxiosAgents(config, undefined, 'socks5://proxy:1080');
expect(buildNodeAgents).toHaveBeenCalledWith('env', 'disabled', undefined);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('socks5://proxy:1080'));
});
});
@@ -4,7 +4,7 @@ import axios from 'axios';
import type { InternalAxiosRequestConfig } from 'axios';
import { stringify } from 'qs';
import { setAxiosAgents } from './axios-utils';
import { setAxiosAgents } from './utils';
let configured = false;
@@ -0,0 +1,327 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { AxiosHeaders, AxiosRequestConfig } from 'axios';
import crypto from 'crypto';
import type FormData from 'form-data';
import { type AgentOptions } from 'https';
import type { GenericValue, IRequestOptions } from 'n8n-workflow';
import { stringify } from 'qs';
import { applyDefaultOutboundUserAgent } from './user-agent';
import {
createFormDataObject,
generateContentLengthHeader,
getBeforeRedirectFn,
getHostFromRequestObject,
isFormDataInstance,
searchForHeader,
setAxiosAgents,
} from './utils';
import type { SsrfBridge } from '../../ssrf';
/**
* This function is a temporary implementation that translates all http requests
* done via the request library to axios directly.
* We are not using n8n's interface as it would an unnecessary step,
* considering the `request` helper has been be deprecated and should be removed.
* @deprecated This is only used by legacy request helpers, that are also deprecated
*/
export async function buildAxiosConfigFromLegacyRequest(
requestObject: IRequestOptions,
ssrfBridge?: SsrfBridge,
): Promise<AxiosRequestConfig> {
const axiosConfig: AxiosRequestConfig = {};
if (requestObject.headers !== undefined) {
axiosConfig.headers = requestObject.headers as AxiosHeaders;
}
// Let's start parsing the hardest part, which is the request body.
// The process here is as following?
// - Check if we have a `content-type` header. If this was set,
// we will follow
// - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded
// - Check if the `formData` property exists. If yes, then it's multipart/form-data
// - Lastly, we should have a regular `body` that is probably a JSON.
const contentTypeHeaderKeyName =
axiosConfig.headers &&
Object.keys(axiosConfig.headers).find(
(headerName) => headerName.toLowerCase() === 'content-type',
);
const contentType =
contentTypeHeaderKeyName &&
(axiosConfig.headers?.[contentTypeHeaderKeyName] as string | undefined);
if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) {
// there are nodes incorrectly created, informing the content type header
// and also using formData. Request lib takes precedence for the formData.
// We will do the same.
// Merge body and form properties.
if (typeof requestObject.body === 'string') {
axiosConfig.data = requestObject.body;
} else {
const allData = Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
string,
string
>;
if (requestObject.useQuerystring === true) {
axiosConfig.data = stringify(allData, { arrayFormat: 'repeat' });
} else {
axiosConfig.data = stringify(allData);
}
}
} else if (contentType?.includes('multipart/form-data')) {
if (requestObject.formData !== undefined && isFormDataInstance(requestObject.formData)) {
axiosConfig.data = requestObject.formData;
} else {
const allData: Partial<FormData> = {
...(requestObject.body as object | undefined),
...(requestObject.formData as object | undefined),
};
axiosConfig.data = createFormDataObject(allData);
}
// replace the existing header with a new one that
// contains the boundary property.
delete axiosConfig.headers?.[contentTypeHeaderKeyName!];
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
// If we have only form
axiosConfig.data =
typeof requestObject.form === 'string'
? requestObject.form
: stringify(requestObject.form).toString();
if (axiosConfig.headers !== undefined) {
const headerName = searchForHeader(axiosConfig, 'content-type');
if (headerName) {
delete axiosConfig.headers[headerName];
}
axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else {
axiosConfig.headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
}
} else if (requestObject.formData !== undefined) {
// remove any "content-type" that might exist.
if (axiosConfig.headers !== undefined) {
const headers = Object.keys(axiosConfig.headers);
headers.forEach((header) => {
if (header.toLowerCase() === 'content-type') {
delete axiosConfig.headers?.[header];
}
});
}
if (isFormDataInstance(requestObject.formData)) {
axiosConfig.data = requestObject.formData;
} else {
axiosConfig.data = createFormDataObject(requestObject.formData as Record<string, unknown>);
}
// Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined && requestObject.body) {
// merge both objects when exist.
requestObject.body = Object.assign(requestObject.body, requestObject.form);
}
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
}
}
if (requestObject.uri !== undefined) {
axiosConfig.url = requestObject.uri?.toString();
}
if (requestObject.url !== undefined) {
axiosConfig.url = requestObject.url?.toString();
}
if (requestObject.baseURL !== undefined) {
axiosConfig.baseURL = requestObject.baseURL?.toString();
}
if (requestObject.method !== undefined) {
axiosConfig.method = requestObject.method;
}
if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) {
axiosConfig.params = requestObject.qs;
}
function hasArrayFormatOptions(
arg: IRequestOptions,
): arg is Required<Pick<IRequestOptions, 'qsStringifyOptions'>> {
if (
typeof arg.qsStringifyOptions === 'object' &&
arg.qsStringifyOptions !== null &&
!Array.isArray(arg.qsStringifyOptions) &&
'arrayFormat' in arg.qsStringifyOptions
) {
return true;
}
return false;
}
if (
requestObject.useQuerystring === true ||
(hasArrayFormatOptions(requestObject) &&
requestObject.qsStringifyOptions.arrayFormat === 'repeat')
) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'repeat' });
};
} else if (requestObject.useQuerystring === false) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'indices' });
};
}
if (
hasArrayFormatOptions(requestObject) &&
requestObject.qsStringifyOptions.arrayFormat === 'brackets'
) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'brackets' });
};
}
if (requestObject.auth !== undefined) {
// Check support for sendImmediately
if (requestObject.auth.bearer !== undefined) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Authorization: `Bearer ${requestObject.auth.bearer}`,
});
} else {
const authObj = requestObject.auth;
// Request accepts both user/username and pass/password
axiosConfig.auth = {
username: (authObj.user || authObj.username) as string,
password: (authObj.password || authObj.pass) as string,
};
}
}
// Only set header if we have a body, otherwise it may fail
if (requestObject.json === true) {
// Add application/json headers - do not set charset as it breaks a lot of stuff
// only add if no other accept headers was sent.
const acceptHeaderExists =
axiosConfig.headers === undefined
? false
: Object.keys(axiosConfig.headers)
.map((headerKey) => headerKey.toLowerCase())
.includes('accept');
if (!acceptHeaderExists) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Accept: 'application/json',
});
}
}
if (requestObject.json === false || requestObject.json === undefined) {
// Prevent json parsing
axiosConfig.transformResponse = (res) => res;
}
// Axios will follow redirects by default, so we simply tell it otherwise if needed.
const { method } = requestObject;
if (
(requestObject.followRedirect !== false &&
(!method || method === 'GET' || method === 'HEAD')) ||
requestObject.followAllRedirects
) {
axiosConfig.maxRedirects = requestObject.maxRedirects;
} else {
axiosConfig.maxRedirects = 0;
}
const host = getHostFromRequestObject(requestObject);
const agentOptions: AgentOptions = { ...requestObject.agentOptions };
if (host) {
agentOptions.servername = host;
}
if (requestObject.rejectUnauthorized === false) {
agentOptions.rejectUnauthorized = false;
agentOptions.secureOptions = crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT;
}
if (requestObject.timeout !== undefined) {
axiosConfig.timeout = requestObject.timeout;
}
setAxiosAgents(axiosConfig, agentOptions, requestObject.proxy, ssrfBridge ?? 'disabled');
axiosConfig.beforeRedirect = getBeforeRedirectFn(
agentOptions,
axiosConfig,
requestObject.proxy,
requestObject.sendCredentialsOnCrossOriginRedirect ?? true,
requestObject.allowedDomains,
ssrfBridge ?? 'disabled',
);
if (requestObject.useStream) {
axiosConfig.responseType = 'stream';
} else if (requestObject.encoding === null) {
// When downloading files, return an arrayBuffer.
axiosConfig.responseType = 'arraybuffer';
}
// If we don't set an accept header
// Axios forces "application/json, text/plan, */*"
// Which causes some nodes like NextCloud to break
// as the service returns XML unless requested otherwise.
const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : [];
if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' });
}
if (
requestObject.json !== false &&
axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
// Use default header for application/json
// If we don't specify this here, axios will add
// application/json; charset=utf-8
// and this breaks a lot of stuff
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
'content-type': 'application/json',
});
}
if (requestObject.simple === false) {
axiosConfig.validateStatus = () => true;
}
applyDefaultOutboundUserAgent(axiosConfig);
/**
* Missing properties:
* encoding (need testing)
* gzip (ignored - default already works)
* resolveWithFullResponse (implemented elsewhere)
*/
return axiosConfig;
}
@@ -12,7 +12,7 @@ import type {
import { isObjectEmpty } from 'n8n-workflow';
import { stringify } from 'qs';
import type { SsrfBridge } from '../ssrf';
import { applyDefaultOutboundUserAgent } from './user-agent';
import {
buildTargetUrl,
digestAuthAxiosConfig,
@@ -24,8 +24,8 @@ import {
setAxiosAgents,
throwIfDomainNotAllowed,
validateUrlSsrf,
} from './axios-utils';
import { applyDefaultOutboundUserAgent } from './outbound-user-agent';
} from './utils';
import type { SsrfBridge } from '../../ssrf';
export async function invokeAxios(
axiosConfig: AxiosRequestConfig,
@@ -90,8 +90,7 @@ export function convertN8nRequestToAxios(
if (n8nRequest.skipSslCertificateValidation === true) {
agentOptions.rejectUnauthorized = false;
}
const secureLookup = ssrfBridge?.createSecureLookup();
setAxiosAgents(axiosRequest, agentOptions, proxy, secureLookup);
setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled');
axiosRequest.beforeRedirect = getBeforeRedirectFn(
agentOptions,
@@ -99,7 +98,7 @@ export function convertN8nRequestToAxios(
n8nRequest.proxy,
n8nRequest.sendCredentialsOnCrossOriginRedirect ?? true,
n8nRequest.allowedDomains,
ssrfBridge,
ssrfBridge ?? 'disabled',
);
if (n8nRequest.arrayFormat !== undefined) {
@@ -191,6 +190,23 @@ export function removeEmptyBody(requestOptions: IHttpRequestOptions | IRequestOp
}
}
/**
* @deprecated Prefer the package's single entry point:
* `Container.get(OutboundHttp).requests({ ssrf }).request(options)`.
* Kept exported for callers not yet migrated to the facade.
*/
export async function httpRequest(
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
ssrfBridge?: SsrfBridge,
): Promise<IN8nHttpFullResponse>;
export async function httpRequest(
requestOptions: IHttpRequestOptions & { returnFullResponse?: false },
ssrfBridge?: SsrfBridge,
): Promise<IN8nHttpResponse>;
export async function httpRequest(
requestOptions: IHttpRequestOptions,
ssrfBridge?: SsrfBridge,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse>;
export async function httpRequest(
requestOptions: IHttpRequestOptions,
ssrfBridge?: SsrfBridge,
@@ -3,7 +3,7 @@ import { Container } from '@n8n/di';
import type { AxiosRequestConfig } from 'axios';
import { join } from 'node:path';
import { searchForHeader } from './axios-utils';
import { searchForHeader } from './utils';
const N8N_PRODUCT_URL = 'https://n8n.io/';
const LEGACY_USER_AGENT = 'n8n';
@@ -12,8 +12,9 @@ import {
type IgnoreStatusErrorConfig,
} from 'n8n-workflow';
import type { SsrfBridge } from '../ssrf';
import { createHttpProxyAgent, createHttpsProxyAgent } from './http-proxy';
import type { SsrfBridge } from '../../ssrf';
import { buildNodeAgents, isSupportedProxyUrl } from '../node-agents';
import type { ProxyOption, SsrfOption } from '../node-agents';
export function throwIfDomainNotAllowed(
configOrUrl: AxiosRequestConfig | string,
@@ -71,6 +72,26 @@ export const getHostFromRequestObject = (
}
};
/**
* Resolves a custom proxy URL (a runtime string) into a {@link ProxyOption}.
* Falls back to `'env'` when no custom proxy is configured,
* or when the configured value is not a supported proxy URL.
*/
function resolveProxyOption(customProxyUrl: string | null): ProxyOption {
if (!customProxyUrl) {
return 'env';
}
if (isSupportedProxyUrl(customProxyUrl)) {
return customProxyUrl;
}
Container.get(Logger).warn(
`Ignoring unsupported proxy URL "${customProxyUrl}", falling back to environment proxy settings`,
);
return 'env';
}
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
/** Returns an axios `beforeRedirect` callback that re-applies agents and auth headers. */
@@ -81,14 +102,14 @@ export const getBeforeRedirectFn =
proxyConfig: IHttpRequestOptions['proxy'] | string | undefined,
sendCredentialsOnCrossOriginRedirect: boolean,
allowedDomains?: string,
ssrfBridge?: SsrfBridge,
ssrf: SsrfOption = 'disabled',
) =>
(redirectedRequest: Record<string, any>) => {
throwIfDomainNotAllowed(redirectedRequest.href, allowedDomains);
// SSRF: validate redirect target synchronously for direct-IP URIs.
// Hostname-based redirect targets are caught by secureLookup on the agent.
if (ssrfBridge) {
ssrfBridge.validateRedirectSync(redirectedRequest.href);
if (ssrf !== 'disabled') {
ssrf.validateRedirectSync(redirectedRequest.href);
}
const redirectAgentOptions: AgentOptions = {
@@ -96,17 +117,12 @@ export const getBeforeRedirectFn =
servername: redirectedRequest.hostname,
};
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
// Inject secureLookup into redirect agents for non-proxy paths
const effectiveRedirectOptions =
ssrfBridge && !customProxyUrl
? { ...redirectAgentOptions, lookup: ssrfBridge.createSecureLookup() }
: redirectAgentOptions;
// Create both agents and set them
// SSRF lookup is applied to direct connections only; behind a proxy the
// proxy validates the final target.
const targetUrl = redirectedRequest.href;
const httpAgent = createHttpProxyAgent(customProxyUrl, targetUrl, effectiveRedirectOptions);
const httpsAgent = createHttpsProxyAgent(customProxyUrl, targetUrl, effectiveRedirectOptions);
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, redirectAgentOptions);
redirectedRequest.agent = redirectedRequest.href.startsWith('https://')
? httpsAgent
@@ -292,24 +308,22 @@ export function setAxiosAgents(
config: AxiosRequestConfig,
agentOptions?: AgentOptions,
proxyConfig?: IHttpRequestOptions['proxy'] | string,
secureLookup?: ReturnType<SsrfBridge['createSecureLookup']>,
ssrf: SsrfOption = 'disabled',
): void {
if (config.httpAgent || config.httpsAgent) return;
const customProxyUrl = getUrlFromProxyConfig(proxyConfig);
const targetUrl = buildTargetUrl(config.url, config.baseURL);
if (!targetUrl) return;
// Inject secureLookup only for non-proxy agents. When a proxy is used,
// the lookup option applies to resolving the proxy server hostname, not
// the target. Pre-request validateUrl covers the proxy path.
const effectiveOptions =
secureLookup && !customProxyUrl ? { ...agentOptions, lookup: secureLookup } : agentOptions;
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
config.httpAgent = createHttpProxyAgent(customProxyUrl, targetUrl, effectiveOptions);
config.httpsAgent = createHttpsProxyAgent(customProxyUrl, targetUrl, effectiveOptions);
// SSRF lookup is applied to direct connections only; behind a proxy the
// proxy validates the final target.
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, agentOptions);
config.httpAgent = httpAgent;
config.httpsAgent = httpsAgent;
}
/** Validates a URL against SSRF protection rules. Throws UserError if blocked. */
@@ -0,0 +1,42 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { EnvProxyRouter } from './env-proxy-router';
import type { NodeAgentOptions } from './node-agents';
type HttpAddRequestArgs = Parameters<HttpProxyAgent<string>['addRequest']>;
type HttpProxyClientReq = HttpAddRequestArgs[0];
type HttpProxyReqOpts = HttpAddRequestArgs[1];
/**
* `http.Agent` that delegates per-request env-proxy routing and caching to a shared {@link EnvProxyRouter}.
*
* The optional SSRF `lookup` is applied to the direct path only
* (behind a proxy it would resolve the proxy host, so the proxy validates the target).
*
* Also backs `installGlobalProxyAgent` (http-proxy.ts), keeping a single env-proxy agent implementation.
*/
export class EnvProxyHttpAgent extends http.Agent {
private readonly router: EnvProxyRouter<HttpProxyAgent<string>>;
constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) {
super({ ...agentOptions, lookup });
this.router = new EnvProxyRouter(
'http',
80,
(proxyUrl) => new HttpProxyAgent(proxyUrl, { ...agentOptions }),
);
}
addRequest(req: http.ClientRequest, options: http.RequestOptions): void {
const proxyAgent = this.router.resolve(options);
if (proxyAgent) {
return proxyAgent.addRequest(req as HttpProxyClientReq, options as HttpProxyReqOpts);
}
// No proxy for this target: serve it directly from this agent's own pool.
super.addRequest(req, options);
}
}
@@ -0,0 +1,36 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { EnvProxyRouter } from './env-proxy-router';
import type { NodeAgentOptions } from './node-agents';
type HttpsProxyReqOpts = Parameters<HttpsProxyAgent<string>['addRequest']>[1];
/**
* `https.Agent` counterpart of {@link EnvProxyHttpAgent}
*/
export class EnvProxyHttpsAgent extends https.Agent {
private readonly router: EnvProxyRouter<HttpsProxyAgent<string>>;
constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) {
super({ ...agentOptions, lookup });
this.router = new EnvProxyRouter(
'https',
443,
(proxyUrl) => new HttpsProxyAgent(proxyUrl, { ...agentOptions }),
);
}
addRequest(req: http.ClientRequest, options: https.RequestOptions): void {
const proxyAgent = this.router.resolve(options);
if (proxyAgent) {
return proxyAgent.addRequest(req, options as HttpsProxyReqOpts);
}
// No proxy for this target: serve it directly from this agent's own pool.
super.addRequest(req, options);
}
}
@@ -0,0 +1,76 @@
import { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import type http from 'node:http';
import { getProxyForUrl } from 'proxy-from-env';
const MAX_CACHED_AGENTS = 64;
/**
* Per-request env-proxy routing.
*
* Owns the single per-proxy-URL agent cache and resolves each request target
* against the environment (HTTP_PROXY / HTTPS_PROXY / NO_PROXY) for its scheme.
*
* @typeParam TProxyAgent - the proxy-agent type for this scheme (`HttpProxyAgent` or `HttpsProxyAgent`).
*/
export class EnvProxyRouter<TProxyAgent> {
private readonly proxyCache = new Map<string, TProxyAgent>();
// Defensive upper bound on cached agents. Normally unreachable: the cache is keyed by the *proxy* URL.
private cacheLimitWarned = false;
constructor(
private readonly scheme: 'http' | 'https',
private readonly defaultPort: number,
private readonly createProxyAgent: (proxyUrl: string) => TProxyAgent,
) {}
/**
* Resolves the request target to the proxy agent it should be dispatched through.
*/
resolve(options: http.RequestOptions): TProxyAgent | undefined {
const hostname = String(options.hostname ?? options.host ?? 'localhost');
const port = this.resolvePort(options.port);
const portSuffix = port === this.defaultPort ? '' : `:${port}`;
const proxyUrl = getProxyForUrl(`${this.scheme}://${hostname}${portSuffix}`);
if (!proxyUrl) {
return undefined;
}
const cached = this.proxyCache.get(proxyUrl);
if (cached) {
return cached;
}
const agent = this.createProxyAgent(proxyUrl);
this.cacheAgent(proxyUrl, agent);
return agent;
}
private cacheAgent(proxyUrl: string, agent: TProxyAgent): void {
if (this.proxyCache.size >= MAX_CACHED_AGENTS) {
if (!this.cacheLimitWarned) {
this.cacheLimitWarned = true;
Container.get(Logger).warn(
`${this.scheme} proxy agent cache reached its limit of ${MAX_CACHED_AGENTS} entries; not caching further proxy agents. This is unexpected and likely indicates a misconfiguration.`,
);
}
} else {
this.proxyCache.set(proxyUrl, agent);
}
}
private resolvePort(rawPort: http.RequestOptions['port']): number {
const parsed = typeof rawPort === 'string' ? parseInt(rawPort, 10) : rawPort;
if (Number.isInteger(parsed)) {
return parsed as number;
}
if (rawPort !== undefined && rawPort !== null) {
Container.get(Logger).warn(
`Unparseable port "${String(rawPort)}" for ${this.scheme} proxy routing, falling back to default port ${this.defaultPort}`,
);
}
return this.defaultPort;
}
}
@@ -5,92 +5,8 @@ import { HttpsProxyAgent } from 'https-proxy-agent';
import { LoggerProxy } from 'n8n-workflow';
import { getProxyForUrl } from 'proxy-from-env';
type ProxyRequestParameters = Parameters<HttpProxyAgent<string>['addRequest']>;
type ProxyClientRequest = ProxyRequestParameters[0];
type ProxyRequestOptions = ProxyRequestParameters[1];
function buildTargetUrl(hostname: string, port: number, protocol: 'http' | 'https'): string {
const defaultPort = protocol === 'https' ? 443 : 80;
const portSuffix = port === defaultPort ? '' : `:${port}`;
return `${protocol}://${hostname}${portSuffix}`;
}
function extractHostInfo(
options: http.RequestOptions,
defaultPort: number,
): { hostname: string; port: number } {
const hostname = options.hostname ?? options.host ?? 'localhost';
const port =
typeof options.port === 'string' ? parseInt(options.port, 10) : (options.port ?? defaultPort);
return { hostname: String(hostname), port: Number(port) };
}
function getOrCreateProxyAgent<T extends HttpProxyAgent<string> | HttpsProxyAgent<string>>(
cache: Map<string, T>,
proxyUrl: string,
createAgent: (url: string) => T,
): T {
let proxyAgent = cache.get(proxyUrl);
if (!proxyAgent) {
proxyAgent = createAgent(proxyUrl);
cache.set(proxyUrl, proxyAgent);
}
return proxyAgent;
}
function createFallbackAgent<T extends http.Agent | https.Agent>(agentClass: new () => T): T {
return new agentClass();
}
/**
* Node.js is working on native HTTP proxy support (as of Node.js 24)
* When it is stable we can use it and remove this implementation
*
* https://nodejs.org/api/http.html#built-in-proxy-support
*/
class HttpProxyManager extends http.Agent {
private readonly proxyAgentCache = new Map<string, HttpProxyAgent<string>>();
private readonly fallbackAgent = createFallbackAgent(http.Agent);
addRequest(req: http.ClientRequest, options: http.RequestOptions) {
const { hostname, port } = extractHostInfo(options, 80);
const targetUrl = buildTargetUrl(hostname, port, 'http');
const proxyUrl = getProxyForUrl(targetUrl);
if (proxyUrl) {
const proxyAgent = getOrCreateProxyAgent(
this.proxyAgentCache,
proxyUrl,
(url) => new HttpProxyAgent(url),
);
return proxyAgent.addRequest(req as ProxyClientRequest, options as ProxyRequestOptions);
}
return this.fallbackAgent.addRequest(req, options);
}
}
class HttpsProxyManager extends https.Agent {
private readonly proxyAgentCache = new Map<string, HttpsProxyAgent<string>>();
private readonly fallbackAgent = createFallbackAgent(https.Agent);
addRequest(req: http.ClientRequest, options: https.RequestOptions) {
const { hostname, port } = extractHostInfo(options, 443);
const targetUrl = buildTargetUrl(hostname, port, 'https');
const proxyUrl = getProxyForUrl(targetUrl);
if (proxyUrl) {
const proxyAgent = getOrCreateProxyAgent(
this.proxyAgentCache,
proxyUrl,
(url) => new HttpsProxyAgent(url),
);
return proxyAgent.addRequest(req, options);
}
return this.fallbackAgent.addRequest(req, options);
}
}
import { EnvProxyHttpAgent } from './env-proxy-http-agent';
import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
/**
* Resolves the proxy URL configured via environment variables
@@ -151,8 +67,10 @@ export function installGlobalProxyAgent(): void {
ALL_PROXY: process.env.ALL_PROXY ?? process.env.all_proxy,
});
http.globalAgent = new HttpProxyManager();
https.globalAgent = new HttpsProxyManager();
// Reuse the factory's env-proxy agents (no SSRF lookup at the global level;
// per-request SSRF enforcement is applied by the outbound HTTP client).
http.globalAgent = new EnvProxyHttpAgent();
https.globalAgent = new EnvProxyHttpsAgent();
}
}
+13 -25
View File
@@ -4,31 +4,19 @@ export {
installGlobalProxyAgent,
resolveProxyUrl,
} from './http-proxy';
export { configureGlobalAxiosDefaults } from './axios-config';
export {
createFormDataObject,
generateContentLengthHeader,
getBeforeRedirectFn,
getHostFromRequestObject,
isFormDataInstance,
resolveLegacyRequestUrl,
searchForHeader,
setAxiosAgents,
throwIfDomainNotAllowed,
tryParseUrl,
validateUrlSsrf,
} from './axios-utils';
export {
convertN8nRequestToAxios,
httpRequest,
invokeAxios,
removeEmptyBody,
} from './http-request';
export {
applyDefaultOutboundUserAgent,
buildRfcStyleUserAgent,
getDefaultN8nOutboundUserAgent,
} from './outbound-user-agent';
export { configureGlobalAxiosDefaults } from './axios/config';
export { tryParseUrl } from './axios/utils';
export { httpRequest, 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 } from './undici/transport';
export {
OutboundHttp,
type HttpRequestClient,
type HttpRequestClientOptions,
type HttpTransport,
type HttpTransportOptions,
} from './outbound-http';
export type { LegacyRequestCallbacks } from './legacy-request';
@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Logger } from '@n8n/backend-common';
import type { AxiosRequestConfig } from 'axios';
import type { IRequestOptions } from 'n8n-workflow';
import { NodeSslError } from 'n8n-workflow';
import { IncomingMessage } from 'node:http';
import { Readable } from 'node:stream';
import { buildAxiosConfigFromLegacyRequest } from './axios/legacy';
import { invokeAxios } from './axios/request';
import { resolveLegacyRequestUrl, throwIfDomainNotAllowed, validateUrlSsrf } from './axios/utils';
import { binaryToString } from './binary-string';
import { parseIncomingMessage } from './parse-incoming-message';
import type { SsrfBridge } from '../ssrf';
export interface LegacyRequestCallbacks {
/**
* Invoked once after the request successfully fetched data, before the result
* is returned. Not called when the request throws or when an error response is
* returned because `simple: false` was set.
*/
onFetched?: () => Promise<void> | void;
}
/**
* Runs the deprecated `request`-style HTTP path (`IRequestOptions`): axios
* translation, SSRF pre-flight, domain allowlist enforcement, response
* normalisation and axios error massaging.
*
* Exposed to callers as {@link HttpRequestClient.requestLegacy}; kept in its own
* module so the whole legacy path can be removed in one go once the deprecated
* request helpers are gone.
*
* @deprecated Backs the deprecated `request` helpers.
*/
export async function executeLegacyRequest(
requestObject: IRequestOptions,
ssrfBridge: SsrfBridge | undefined,
logger: Logger,
callbacks?: LegacyRequestCallbacks,
): Promise<unknown> {
let axiosConfig: AxiosRequestConfig = {
maxBodyLength: Infinity,
// -1 is the Axios sentinel for "no limit". Infinity also means no limit but
// Axios 1.15.1+ treats any value > -1 as a finite cap, wrapping stream responses
// in Readable.from() even when the limit is Infinity. That breaks the downstream
// `instanceof IncomingMessage` checks in parseIncomingMessage / prepareBinaryData.
maxContentLength: -1,
};
const url = resolveLegacyRequestUrl(requestObject);
await validateUrlSsrf(url, ssrfBridge);
axiosConfig = Object.assign(
axiosConfig,
await buildAxiosConfigFromLegacyRequest(requestObject, ssrfBridge),
);
throwIfDomainNotAllowed(axiosConfig, requestObject.allowedDomains);
try {
const response = await invokeAxios(axiosConfig, requestObject.auth);
let body = response.data;
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
parseIncomingMessage(body);
} else if (body === '') {
body = axiosConfig.responseType === 'arraybuffer' ? Buffer.alloc(0) : undefined;
}
await callbacks?.onFetched?.();
return requestObject.resolveWithFullResponse
? {
body,
headers: { ...response.headers },
statusCode: response.status,
statusMessage: response.statusText,
request: response.request,
}
: body;
} catch (error: any) {
const { config, response } = error;
// Axios hydrates the original error with more data. We extract them.
// https://github.com/axios/axios/blob/master/lib/core/enhanceError.js
// Note: `code` is ignored as it's an expected part of the errorData.
if (error.isAxiosError) {
error.config = error.request = undefined;
// Carry only the config keys that were actually set, so the error shape
// matches the previous `pick(config, [...])` behaviour (no `undefined`
// keys for properties the request never had).
const options: Record<string, unknown> = {};
for (const key of ['url', 'method', 'data', 'headers'] as const) {
if (config && key in config) {
options[key] = config[key];
}
}
error.options = options;
if (response) {
logger.debug('Request proxied to Axios failed', { status: response.status });
let responseData = response.data;
if (Buffer.isBuffer(responseData) || responseData instanceof Readable) {
responseData = await binaryToString(responseData);
}
if (requestObject.simple === false) {
if (requestObject.resolveWithFullResponse) {
return {
body: responseData,
headers: response.headers,
statusCode: response.status,
statusMessage: response.statusText,
};
} else {
return responseData;
}
}
error.message = `${response.status as number} - ${JSON.stringify(responseData)}`;
throw Object.assign(error, {
statusCode: response.status,
/**
* Axios adds `status` when serializing, causing `status` to be available only to the client.
* Hence we add it explicitly to allow the backend to use it when resolving expressions.
*/
status: response.status,
error: responseData,
response: {
headers: response.headers,
status: response.status,
statusText: response.statusText,
},
});
} else if ('rejectUnauthorized' in requestObject && error.code?.includes('CERT')) {
throw new NodeSslError(error);
}
}
throw error;
}
}
@@ -0,0 +1,34 @@
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { promisify } from 'node:util';
export interface LocalServer {
url: string;
hostWithPort: string;
captured: string[];
clear: () => void;
close: () => Promise<void>;
}
const LOOPBACK = '127.0.0.1';
export async function startServer(handler: http.RequestListener): Promise<LocalServer> {
const captured: string[] = [];
const server = http.createServer((req, res) => {
captured.push(req.url ?? '');
handler(req, res);
});
await new Promise<void>((resolve, reject) => {
server.listen(0, LOOPBACK, resolve);
server.on('error', reject);
});
const { port } = server.address() as AddressInfo;
return {
url: `http://${LOOPBACK}:${port}`,
hostWithPort: `${LOOPBACK}:${port}`,
captured,
clear: () => {
captured.length = 0;
},
close: async () => await promisify(server.close.bind(server))(),
};
}
@@ -0,0 +1,152 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { UnexpectedError } from 'n8n-workflow';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { EnvProxyHttpAgent } from './env-proxy-http-agent';
import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
import type { SsrfBridge } from '../ssrf';
/**
* An explicit proxy URL for routing all requests from this client.
* Only HTTP(S) forward proxies are supported: both the Node.js agents
* and the undici dispatcher (`ProxyAgent`) speak the HTTP CONNECT protocol, not SOCKS.
*/
export type ProxyUrl = `${'http' | 'https'}://${string}`;
/**
* Type guard for {@link ProxyUrl}.
* Only HTTP(S) forward proxies are supported.
*/
export function isSupportedProxyUrl(value: string): value is ProxyUrl {
return value.startsWith('http://') || value.startsWith('https://');
}
/**
* Controls how outgoing requests are routed through a proxy.
* - `'env'` (default): read HTTP_PROXY / HTTPS_PROXY / NO_PROXY from the environment
* - `ProxyUrl`: route all requests through the given proxy
* - `false`: bypass all proxies, connect directly to the target
*/
export type ProxyOption = 'env' | ProxyUrl | false;
/**
* Controls SSRF protection for an outbound HTTP client.
* Explicitly passing `'disabled'` makes the opt-out visible in calling code.
*/
export type SsrfOption = SsrfBridge | 'disabled';
/**
* Per-call Node.js agent options (TLS, keep-alive, `servername`, ...)
* forwarded to the underlying http/https agents.
*/
export type NodeAgentOptions = https.AgentOptions;
/**
* Builds the `{ httpAgent, httpsAgent }` pair for a given proxy + SSRF policy.
*
* Single source of truth for outbound Node.js agent construction, shared by the
* undici factory (`undici/factory.ts`), the axios transport layer
* (`axios/utils.ts`) and the global proxy agents (`http-proxy.ts`).
*
* SSRF lookup is injected only for **direct** connections. Behind a proxy the
* lookup resolves the proxy host, not the final target, so it is omitted there
* and the proxy validates the final target.
*
* The `lookup` is owned by this builder: it is derived from the SSRF policy and
* always overrides anything in `agentOptions`. Passing `agentOptions.lookup`
* therefore has no effect and is rejected to avoid a false sense of control over
* DNS resolution.
*/
export function buildNodeAgents(
proxy: ProxyOption,
ssrf: SsrfOption,
agentOptions?: NodeAgentOptions,
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
if (agentOptions?.lookup) {
throw new UnexpectedError(
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
);
}
const lookup: LookupFunction | undefined =
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
if (proxy === false) {
return applyConnectionGuard(
{
httpAgent: new http.Agent({ ...agentOptions, lookup }),
httpsAgent: new https.Agent({ ...agentOptions, lookup }),
},
ssrf,
);
}
if (proxy === 'env') {
return applyConnectionGuard(
{
httpAgent: new EnvProxyHttpAgent(lookup, agentOptions),
httpsAgent: new EnvProxyHttpsAgent(lookup, agentOptions),
},
ssrf,
);
}
// Explicit proxy URL. No direct path, so no SSRF lookup is injected.
return {
httpAgent: new HttpProxyAgent(proxy as string, { ...agentOptions }),
httpsAgent: new HttpsProxyAgent(proxy as string, { ...agentOptions }),
};
}
/** Subset of an agent's connection options we read to find the target host. */
type ConnectionOptions = { host?: string | null; hostname?: string | null };
/**
* Runtime-only `createConnection` method of Node's http(s) agents.
*/
type CreateConnection = (
options: ConnectionOptions,
onConnect?: (error: Error | null, stream?: unknown) => void,
) => unknown;
/**
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
*/
function applyConnectionGuard(
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
ssrf: SsrfOption,
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
if (ssrf !== 'disabled') {
installConnectionGuard(agents.httpAgent, ssrf);
installConnectionGuard(agents.httpsAgent, ssrf);
}
return agents;
}
/**
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
* Node invokes the custom `lookup` only to resolve hostnames, so it also requires a check at connection time.
*/
export function installConnectionGuard(
target: { createConnection: CreateConnection },
ssrf: SsrfBridge,
): void {
const createConnection = target.createConnection.bind(target);
target.createConnection = (options, onConnect) => {
const host = options.host ?? options.hostname ?? undefined;
if (typeof host === 'string') {
const result = ssrf.validateConnectionHost(host);
if (!result.ok) {
if (onConnect) {
onConnect(result.error);
return undefined;
}
throw result.error;
}
}
return createConnection(options, onConnect);
};
}
@@ -0,0 +1,167 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type {
IHttpRequestOptions,
IN8nHttpFullResponse,
IN8nHttpResponse,
IRequestOptions,
} from 'n8n-workflow';
import type http from 'node:http';
import type https from 'node:https';
import type { Dispatcher } from 'undici';
import { httpRequest } from './axios/request';
import { executeLegacyRequest, type LegacyRequestCallbacks } from './legacy-request';
import { buildNodeAgents } from './node-agents';
import type { NodeAgentOptions, ProxyOption, SsrfOption } from './node-agents';
import { SsrfProtectionService } from '../ssrf';
import { buildDispatcher, dispatchedFetch, type CustomFetch } from './undici/transport';
export interface HttpRequestClientOptions {
/**
* SSRF protection level. Defaults to the container's `SsrfProtectionService`.
* Pass `'disabled'` to explicitly opt out.
*/
ssrf?: SsrfOption;
}
export interface HttpTransportOptions {
/**
* Proxy routing for the transport. Defaults to `'env'` (HTTP(S)_PROXY / NO_PROXY).
*
* Fixed at creation: the transport hands a single dispatcher/agent to a foreign
* client that drives the calls afterwards, so the proxy cannot vary per call —
* unlike {@link HttpRequestClient}, which reads `proxy` from each request.
*/
proxy?: ProxyOption;
/**
* SSRF protection level. Defaults to the container's `SsrfProtectionService`.
* Pass `'disabled'` to explicitly opt out.
*/
ssrf?: SsrfOption;
}
/**
* Engine for outbound HTTP requests made on behalf of n8n: you hand it a request
* descriptor and it performs the call and returns the response.
*
* Carries this client's SSRF policy. Proxy routing stays **per request** (read
* from `options.proxy` / the environment), because n8n requests choose their
* proxy per call rather than per client — so there is no proxy option here.
*/
export interface HttpRequestClient {
/**
* Performs an outbound HTTP request from an `IHttpRequestOptions` descriptor,
* applying this client's SSRF policy, user-agent defaults and proxy routing.
*
* @returns the full response when `options.returnFullResponse` is `true`.
*/
request(
options: IHttpRequestOptions & { returnFullResponse: true },
): Promise<IN8nHttpFullResponse>;
/**
* @returns the parsed body when `options.returnFullResponse` is unset or `false`.
*/
request(options: IHttpRequestOptions & { returnFullResponse?: false }): Promise<IN8nHttpResponse>;
/**
* Fallback for a non-literal `returnFullResponse` flag.
*
* @returns the parsed body, or the full response when `options.returnFullResponse` is set.
*/
request(options: IHttpRequestOptions): Promise<IN8nHttpFullResponse | IN8nHttpResponse>;
/**
* Performs a request using the deprecated `request`-style options
* (`IRequestOptions`), applying the same SSRF policy as {@link request}.
*
* `callbacks.onFetched` runs once after data is successfully fetched (used by
* the execution engine to fire its `nodeFetchedData` hook).
*
* @deprecated Use {@link request} with `IHttpRequestOptions`. This exists only
* to back the deprecated `request` helpers.
*/
requestLegacy(options: IRequestOptions, callbacks?: LegacyRequestCallbacks): Promise<unknown>;
}
/**
* Transport primitives to hand to a third-party HTTP stack (an SDK you do not
* drive yourself). You do not make the request here — you configure someone
* else's client. Carries a fixed proxy + SSRF policy.
*
* The three shapes are not interchangeable styles; which one you use is dictated
* by what the consuming library accepts:
*
* - **`asCustomFetch()`**: libraries that accept a full `fetch` replacement
* (e.g. an OIDC client's `customFetch`).
* - **`getDispatcher()`**: SDKs built on undici's `fetch` that accept a
* `dispatcher` (e.g. OpenAI / Anthropic JS SDKs via `fetchOptions.dispatcher`).
* Injects only the transport while the SDK keeps its own `fetch` (streaming,
* retries, error parsing).
* - **`getNodeAgent()`**: libraries built on Node's `http`/`https` that need
* `http.Agent` / `https.Agent` instances (e.g. AWS SDK v3 via `NodeHttpHandler`).
*
* SSRF coverage is identical for `asCustomFetch()` and `getDispatcher()` (same
* underlying dispatcher, validated per hop). For `getNodeAgent()` it is enforced
* via a connect-time secure DNS lookup, injected only for direct connections.
*/
export interface HttpTransport {
asCustomFetch(): CustomFetch;
getDispatcher(): Dispatcher;
getNodeAgent(agentOptions?: NodeAgentOptions): { httpAgent: http.Agent; httpsAgent: https.Agent };
}
/**
* The single entry point for outbound http requests in `@n8n/backend-network`.
*
* Injectable via `@n8n/di`. Pick by intent, not by transport library:
*
* - {@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.
*/
@Service()
export class OutboundHttp {
constructor(
private readonly ssrfProtection: SsrfProtectionService,
private readonly logger: Logger,
) {}
/**
* A {@link HttpRequestClient} carrying the given SSRF policy.
* Proxy is resolved per request from `IHttpRequestOptions.proxy` / the environment.
*/
requests(options?: HttpRequestClientOptions): HttpRequestClient {
const ssrf = options?.ssrf ?? this.ssrfProtection;
const ssrfBridge = ssrf === 'disabled' ? undefined : ssrf;
return {
request: (async (requestOptions: IHttpRequestOptions) =>
await httpRequest(requestOptions, ssrfBridge)) as HttpRequestClient['request'],
requestLegacy: async (requestOptions, callbacks) =>
await executeLegacyRequest(requestOptions, ssrfBridge, this.logger, callbacks),
};
}
/**
* An {@link HttpTransport} carrying the given proxy + SSRF policy.
*/
transport(options?: HttpTransportOptions): HttpTransport {
const proxy = options?.proxy ?? 'env';
const ssrf = options?.ssrf ?? this.ssrfProtection;
const lazyDispatcher = lazy(() => buildDispatcher(proxy, ssrf));
const lazyNodeAgents = lazy(() => buildNodeAgents(proxy, ssrf));
return {
asCustomFetch: () => async (input, init) =>
await dispatchedFetch(lazyDispatcher(), input, init),
getDispatcher: () => lazyDispatcher(),
getNodeAgent: (agentOptions) =>
agentOptions !== undefined ? buildNodeAgents(proxy, ssrf, agentOptions) : lazyNodeAgents(),
};
}
}
function lazy<T>(factory: () => T): () => T {
let cached: { value: T } | undefined;
return () => (cached ??= { value: factory() }).value;
}
@@ -0,0 +1,103 @@
import { ensureError } from 'n8n-workflow';
import type { Dispatcher } from 'undici';
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { SsrfBridge } from '../../ssrf';
import type { ProxyOption, SsrfOption } from '../node-agents';
/**
* Drop-in replacement type for the global `fetch`.
*/
export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/**
* Builds the undici dispatcher for a given proxy + SSRF policy — the transport
* plumbing behind `OutboundHttp.transport()`. When SSRF is active the dispatcher
* is composed with {@link createSsrfInterceptor} so every dispatched request,
* including each redirect hop, is validated.
*/
export function buildDispatcher(proxy: ProxyOption, ssrf: SsrfOption): Dispatcher {
const dispatcher = buildDispatcherFromProxy(proxy);
return ssrf === 'disabled' ? dispatcher : dispatcher.compose(createSsrfInterceptor(ssrf));
}
function buildDispatcherFromProxy(proxy: ProxyOption): Dispatcher {
if (proxy === false) {
return new Agent();
}
if (proxy === 'env') {
return new EnvHttpProxyAgent();
}
return new ProxyAgent(proxy);
}
/**
* undici `compose` interceptor that runs SSRF validation against the target URL
* of every dispatched request.
*
* `fetch` re-dispatches through this dispatcher for each redirect hop, so this
* validates the initial request **and** every redirect target (both hostname
* and direct-IP targets), unlike a connect-time DNS lookup which never fires for
* IP-literal targets. Validation runs against the request target, never the
* proxy, so it is proxy-agnostic.
*/
export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.DispatcherComposeInterceptor {
return (dispatch) => (opts, handler) => {
let targetUrl: URL;
try {
// `opts.path` is the request target.
// Behind a forward proxy it can be an absolute URI, otherwise it is path-only and resolved against the origin.
// Either form yields the final target URL.
targetUrl = new URL(opts.path, opts.origin?.toString());
bridge.validateUrl(targetUrl).then(
(result) => {
if (result.ok) {
dispatch(opts, handler);
} else {
failDispatch(handler, result.error);
}
},
(error: unknown) => failDispatch(handler, ensureError(error)),
);
} catch (error: unknown) {
// Fail closed: if we cannot derive a target URL we cannot validate it
failDispatch(handler, ensureError(error));
}
return true;
};
}
/**
* Declared locally so it does not depend on undici's namespaced `DispatchHandler` / `DispatchController` types,
* whose resolution varies across undici versions in the tree)
*/
interface FailableDispatchHandler {
onResponseError?(controller: unknown, error: Error): void;
onError?(error: Error): void;
}
/**
* Signals a pre-dispatch failure to an undici dispatch handler.
* Mirrors undici's own interceptors (e.g. the DNS interceptor),
* which pass a `null` controller when erroring before a request reaches the socket.
*/
function failDispatch(handler: FailableDispatchHandler, error: Error): void {
if (handler.onResponseError) {
handler.onResponseError(null, error);
} else {
handler.onError?.(error);
}
}
/** Performs a `fetch` bound to the given undici dispatcher (the engine behind `asCustomFetch`). */
export async function dispatchedFetch(
dispatcher: Dispatcher,
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
return (await undiciFetch(
input as Parameters<typeof undiciFetch>[0],
{ ...(init ?? {}), dispatcher } as Parameters<typeof undiciFetch>[1],
)) as unknown as Response;
}
@@ -0,0 +1,19 @@
import type { LookupFunction } from 'node:net';
import { vi } from 'vitest';
import type { SsrfBridge } from '..';
export function makeLookupFn(): LookupFunction {
return vi.fn() as unknown as LookupFunction;
}
export function makeSsrfBridge(overrides?: Partial<SsrfBridge>): SsrfBridge {
return {
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),
validateIp: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateConnectionHost: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(makeLookupFn()),
...overrides,
};
}
@@ -274,6 +274,52 @@ describe('SsrfProtectionService', () => {
});
});
describe('validateConnectionHost', () => {
it('should block a direct IP-literal target', () => {
const { service } = createService();
expectBlocked(service.validateConnectionHost('169.254.169.254'));
expectBlocked(service.validateConnectionHost('10.0.0.1'));
});
it('should allow a public direct IP target', () => {
const { service } = createService();
expectAllowed(service.validateConnectionHost('93.184.216.34'));
});
it('should normalize IPv6 bracket notation before validating', () => {
const { service } = createService();
expectBlocked(service.validateConnectionHost('[::1]'));
});
it('should allow hostnames (deferred to the secure lookup)', () => {
const dnsResolver = createMockDnsResolver();
const { service } = createService({}, dnsResolver);
expectAllowed(service.validateConnectionHost('example.com'));
// No DNS resolution happens here — hostnames are validated by the lookup.
expect(dnsResolver.lookup).not.toHaveBeenCalled();
});
it('should emit a connect_time event for IP-literal targets', () => {
const { service } = createService();
const blocked = vi.fn();
const allowed = vi.fn();
service.events.on('ssrf.blocked', blocked);
service.events.on('ssrf.allowed', allowed);
service.validateConnectionHost('10.0.0.1');
service.validateConnectionHost('93.184.216.34');
expect(blocked).toHaveBeenCalledWith(
expect.objectContaining({ phase: 'connect_time', reason: 'blocked_ip' }),
);
expect(allowed).toHaveBeenCalledWith(expect.objectContaining({ phase: 'connect_time' }));
});
});
describe('validateRedirectSync', () => {
it('should block direct-IP redirect targets', () => {
const { service } = createService();
@@ -35,6 +35,7 @@ export type SsrfEventMap = {
export interface SsrfBridge {
validateIp(ip: string): SsrfCheckResult;
validateUrl(url: string | URL): Promise<SsrfCheckResult>;
validateConnectionHost(host: string): SsrfCheckResult;
validateRedirectSync(url: string): void;
createSecureLookup(): LookupFunction;
}
@@ -129,6 +130,21 @@ export class SsrfProtectionService implements SsrfBridge {
return createResultOk(undefined);
}
/**
* Connect-time validation for a host a socket is about to connect to directly.
*
* IP-literal hosts (including IPv6 bracket notation) are validated.
* Reason: Node skips the secure `lookup` when the target is already an IP.
* Hostnames are resolved and validated by {@link createSecureLookup} at resolution time.
*/
validateConnectionHost(host: string): SsrfCheckResult {
const ip = this.normalizeIpInHostname(host);
if (!isIP(ip)) {
return createResultOk(undefined);
}
return this.withEvents('connect_time', () => this.validateIp(ip));
}
/**
* Create a custom DNS lookup function that is a drop-in replacement for
* Node.js `dns.lookup`. It can be passed to `socket.connect({ lookup })`
@@ -0,0 +1,12 @@
/**
* Test-only entry point (`@n8n/backend-network/testing`).
*
* These helpers are not part of the runtime contract: they exist so existing
* test suites in other packages can exercise the axios translation internals
* 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 { generateContentLengthHeader, isFormDataInstance } from './http/axios/utils';
export { buildRfcStyleUserAgent, getDefaultN8nOutboundUserAgent } from './http/axios/user-agent';
export { startServer, type LocalServer } from './http/local-server';
+1 -1
View File
@@ -1,7 +1,7 @@
import { parseIncomingMessage } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { Request, RequestHandler } from 'express';
import { parseIncomingMessage } from 'n8n-core';
import { jsonParse, sanitizeXmlName } from 'n8n-workflow';
import { parse as parseQueryString } from 'querystring';
import getRawBody from 'raw-body';
@@ -13,7 +13,6 @@ export { StructuredToolkit, type SupplyDataToolResponse } from './utils/ai-tool-
export { constructExecutionMetaData } from './utils/construct-execution-metadata';
export { getAdditionalKeys, getNonWorkflowAdditionalKeys } from './utils/get-additional-keys';
export { normalizeItems } from './utils/normalize-items';
export { parseIncomingMessage } from '@n8n/backend-network';
export { returnJsonArray } from './utils/return-json-array';
export { resolveSourceOverwrite } from './utils/resolve-source-overwrite';
export * from './utils/binary-helper-functions';
@@ -1,4 +1,4 @@
import { generateContentLengthHeader, isFormDataInstance } from '@n8n/backend-network';
import { generateContentLengthHeader, isFormDataInstance } from '@n8n/backend-network/testing';
import type { AxiosRequestConfig } from 'axios';
import FormData from 'form-data';
import { Readable } from 'stream';
@@ -1,8 +1,8 @@
import { httpRequest } from '@n8n/backend-network';
import {
buildRfcStyleUserAgent,
getDefaultN8nOutboundUserAgent,
httpRequest,
} from '@n8n/backend-network';
} from '@n8n/backend-network/testing';
import { HttpRequestConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { INode, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow';
@@ -1,574 +1,138 @@
import type { SsrfBridge } from '@n8n/backend-network';
import FormData from 'form-data';
import type { Agent as HttpsAgent } from 'https';
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type {
IHttpRequestMethods,
INode,
IRequestOptions,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import nock from 'nock';
import type { SecureContextOptions } from 'tls';
import { mock } from 'vitest-mock-extended';
import { parseRequestObject, proxyRequestToAxios } from '../legacy-request-adapter';
import { proxyRequestToAxios } from '../legacy-request-adapter';
const TEST_CA_CERT = '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----';
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.
*/
describe('proxyRequestToAxios', () => {
const baseUrl = 'https://example.de';
const workflow = mock<Workflow>();
const hooks = mock<NonNullable<IWorkflowExecuteAdditionalData['hooks']>>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({
hooks,
ssrfBridge: undefined,
});
const workflow = mock<Workflow>({ id: 'workflow-id' });
const node = mock<INode>();
const requestLegacy = vi.fn();
const requests = vi.fn();
const outboundHttp = mock<OutboundHttp>({ requests });
beforeEach(() => {
hooks.runHook.mockClear();
vi.resetAllMocks();
requestLegacy.mockResolvedValue('response-body');
requests.mockReturnValue(mock<HttpRequestClient>({ requestLegacy }));
Container.set(OutboundHttp, outboundHttp);
});
test('should rethrow an error with `status` property', async () => {
nock(baseUrl).get('/test').reply(400);
describe('SSRF policy mapping', () => {
it('disables SSRF when the execution provides no bridge', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined });
try {
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
} catch (error) {
expect(error.status).toEqual(400);
}
});
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test');
test('should not throw if the response status is 200', async () => {
nock(baseUrl).get('/test').reply(200);
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
test('should throw if the response status is 403', async () => {
const headers = { 'content-type': 'text/plain' };
nock(baseUrl).get('/test').reply(403, 'Forbidden', headers);
try {
await proxyRequestToAxios(workflow, additionalData, node, `${baseUrl}/test`);
} catch (error) {
expect(error.statusCode).toEqual(403);
expect(error.request).toBeUndefined();
expect(error.response).toMatchObject({ headers, status: 403 });
expect(error.options).toMatchObject({
headers: { Accept: '*/*' },
method: 'get',
url: 'https://example.de/test',
});
expect(error.config).toBeUndefined();
expect(error.message).toEqual('403 - "Forbidden"');
}
expect(hooks.runHook).not.toHaveBeenCalled();
});
test('should not throw if the response status is 404, but `simple` option is set to `false`', async () => {
nock(baseUrl).get('/test').reply(404, 'Not Found');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/test`,
simple: false,
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
expect(response).toEqual('Not Found');
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
it('forwards the execution SSRF bridge when present', async () => {
const ssrfBridge = mock<SsrfBridge>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge });
test('should return full response when `resolveWithFullResponse` is set to true', async () => {
nock(baseUrl).get('/test').reply(404, 'Not Found');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/test`,
resolveWithFullResponse: true,
simple: false,
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfBridge });
});
expect(response).toMatchObject({
body: 'Not Found',
headers: {},
statusCode: 404,
statusMessage: 'Not Found',
it('disables SSRF when there is no additionalData at all', async () => {
await proxyRequestToAxios(workflow, undefined, node, 'https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
expect(hooks.runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
describe('redirects', () => {
test.each([[undefined], [true]])(
'should forward authorization header on cross-origin redirects when sendCredentialsOnCrossOriginRedirect is %s',
async (sendCredentialsOnCrossOriginRedirect) => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://otherdomain.com/test' });
nock('https://otherdomain.com')
.get('/test')
.reply(200, function () {
return this.req.headers;
});
describe('request config normalisation', () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined });
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
auth: {
username: 'testuser',
password: 'testpassword',
},
headers: {
'X-Other-Header': 'otherHeaderContent',
},
resolveWithFullResponse: true,
sendCredentialsOnCrossOriginRedirect,
});
it('wraps a string uri into a config object', async () => {
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test/path');
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBe('Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk');
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
},
);
expect(requestLegacy).toHaveBeenCalledWith(
{ uri: 'https://example.test/path' },
expect.anything(),
);
});
test('should not forward authorization header on cross-origin redirects when sendCredentialsOnCrossOriginRedirect is false', async () => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://otherdomain.com/test' });
nock('https://otherdomain.com')
.get('/test')
.reply(200, function () {
return this.req.headers;
});
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
auth: {
username: 'testuser',
password: 'testpassword',
},
headers: {
'X-Other-Header': 'otherHeaderContent',
},
resolveWithFullResponse: true,
sendCredentialsOnCrossOriginRedirect: false,
it('merges options into the config when a string uri is given', async () => {
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test/path', {
method: 'POST',
});
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBeUndefined();
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
expect(requestLegacy).toHaveBeenCalledWith(
{ uri: 'https://example.test/path', method: 'POST' },
expect.anything(),
);
});
test.each([[undefined], [true], [false]])(
'should forward authorization header on same-origin redirects when sendCredentialsOnCrossOriginRedirect is %s',
async (sendCredentialsOnCrossOriginRedirect) => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl)
.get('/test')
.reply(200, function () {
return this.req.headers;
});
it('passes an options object straight through', async () => {
const options: IRequestOptions = { uri: 'https://example.test/path', method: 'PUT' };
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
auth: {
username: 'testuser',
password: 'testpassword',
},
headers: {
'X-Other-Header': 'otherHeaderContent',
},
resolveWithFullResponse: true,
sendCredentialsOnCrossOriginRedirect,
});
await proxyRequestToAxios(workflow, additionalData, node, options);
expect(response.statusCode).toBe(200);
const forwardedHeaders = JSON.parse(response.body);
expect(forwardedHeaders.authorization).toBe('Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk');
expect(forwardedHeaders['x-other-header']).toBe('otherHeaderContent');
},
);
test('should follow redirects by default', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
});
expect(response).toMatchObject({
body: 'Redirected',
headers: {},
statusCode: 200,
});
expect(requestLegacy).toHaveBeenCalledWith(options, expect.anything());
});
test('should not follow redirects when configured', async () => {
nock(baseUrl)
.get('/redirect')
.reply(301, '', { Location: `${baseUrl}/test` });
nock(baseUrl).get('/test').reply(200, 'Redirected');
it('returns the client response', async () => {
const result = await proxyRequestToAxios(
workflow,
additionalData,
node,
'https://example.test',
);
expect(result).toBe('response-body');
});
});
describe('nodeFetchedData hook', () => {
it('fires the hook through the onFetched callback', async () => {
const runHook = vi.fn();
const hooks = mock<NonNullable<IWorkflowExecuteAdditionalData['hooks']>>({ runHook });
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined, hooks });
requestLegacy.mockImplementation(async (_opts: unknown, callbacks?: OnFetchedCallbacks) => {
await callbacks?.onFetched?.();
return 'ok';
});
await proxyRequestToAxios(workflow, additionalData, node, 'https://example.test');
expect(runHook).toHaveBeenCalledWith('nodeFetchedData', [workflow.id, node]);
});
it('does not throw when no hooks are configured', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
ssrfBridge: undefined,
hooks: undefined,
});
requestLegacy.mockImplementation(async (_opts: unknown, callbacks?: OnFetchedCallbacks) => {
await callbacks?.onFetched?.();
return 'ok';
});
await expect(
proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
resolveWithFullResponse: true,
followRedirect: false,
}),
).rejects.toThrowError(expect.objectContaining({ statusCode: 301 }));
});
});
});
describe('parseRequestObject', () => {
test('should handle basic request options', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: { key: 'value' },
});
expect(axiosOptions).toEqual(
expect.objectContaining({
url: 'https://example.com',
method: 'POST',
headers: {
accept: '*/*',
'content-type': 'application/json',
'User-Agent': 'n8n',
},
data: { key: 'value' },
maxRedirects: 0,
}),
);
});
test('should set default User-Agent when none provided', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
method: 'GET',
});
expect(axiosOptions.headers).toMatchObject({ 'User-Agent': 'n8n' });
});
test('should preserve a caller-supplied User-Agent header', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
method: 'GET',
headers: { 'User-Agent': 'MyCustomNode/1.0' },
});
expect(axiosOptions.headers).toMatchObject({ 'User-Agent': 'MyCustomNode/1.0' });
expect(axiosOptions.headers).not.toMatchObject({ 'User-Agent': 'n8n' });
});
test('should set correct headers for FormData', async () => {
const formData = new FormData();
formData.append('key', 'value');
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
formData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
accept: '*/*',
'content-length': 163,
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
expect(axiosOptions.data).toBeInstanceOf(FormData);
});
test('should handle FormData from a different module copy (duck-typing)', async () => {
// Simulate a FormData created by a different copy of the form-data package.
// instanceof FormData would return false, but duck-type check should pass.
const realFormData = new FormData();
realFormData.append('key', 'value');
// Create a wrapper that breaks instanceof but preserves the interface
const foreignFormData: Record<string, unknown> = Object.create(null);
for (const prop of Object.getOwnPropertyNames(Object.getPrototypeOf(realFormData))) {
const value = (realFormData as unknown as Record<string, unknown>)[prop];
if (typeof value === 'function') {
foreignFormData[prop] = value.bind(realFormData);
}
}
for (const prop of Object.getOwnPropertyNames(realFormData)) {
foreignFormData[prop] = (realFormData as unknown as Record<string, unknown>)[prop];
}
// Verify it's NOT an instanceof FormData
expect(foreignFormData instanceof FormData).toBe(false);
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
formData: foreignFormData as unknown as FormData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
});
test('should not use Host header for SNI', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.de/foo/bar',
headers: { Host: 'other.host.com' },
});
expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toEqual('example.de');
});
describe('should set SSL certificates', () => {
const agentOptions: SecureContextOptions = {
ca: TEST_CA_CERT,
};
const requestObject: IRequestOptions = {
method: 'GET',
uri: 'https://example.de',
agentOptions,
};
test('on regular requests', async () => {
const axiosOptions = await parseRequestObject(requestObject);
expect((axiosOptions.httpsAgent as HttpsAgent).options).toMatchObject({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
test('on redirected requests', async () => {
const axiosOptions = await parseRequestObject(requestObject);
expect(axiosOptions.beforeRedirect).toBeDefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const redirectOptions: Record<string, any> = {
agents: {},
hostname: 'example.de',
href: requestObject.uri,
};
axiosOptions.beforeRedirect!(redirectOptions, mock(), mock());
expect(redirectOptions.agent).toEqual(redirectOptions.agents.https);
expect((redirectOptions.agent as HttpsAgent).options).toMatchObject({
servername: 'example.de',
...agentOptions,
noDelay: true,
path: null,
});
});
});
describe('when followRedirect is true', () => {
test.each(['GET', 'HEAD'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
test.each(['POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should not set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followRedirect: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(0);
},
);
});
describe('when followAllRedirects is true', () => {
test.each(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as IHttpRequestMethods[])(
'should set maxRedirects on %s ',
async (method) => {
const axiosOptions = await parseRequestObject({
method,
followAllRedirects: true,
maxRedirects: 1234,
});
expect(axiosOptions.maxRedirects).toEqual(1234);
},
);
});
describe('domain allowlist enforcement', () => {
const baseUrl = 'https://example.com';
beforeEach(() => {
nock.cleanAll();
});
test('should pass allowedDomains to beforeRedirect', async () => {
const axiosOptions = await parseRequestObject({
url: `${baseUrl}/test`,
allowedDomains: 'example.com',
});
const redirectOptions = {
agents: {},
hostname: 'not-allowed.com',
href: 'https://not-allowed.com/data',
};
expect(axiosOptions.beforeRedirect).toBeDefined();
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock(), mock())).toThrow(
'Domain not allowed',
);
});
test.each([['example.com'], [undefined]])(
'should not block redirects when allowedDomains is %s',
async (allowedDomains) => {
const axiosOptions = await parseRequestObject({
url: `${baseUrl}/test`,
allowedDomains,
});
const redirectOptions = {
agents: {},
hostname: 'example.com',
href: 'https://example.com/data',
};
expect(axiosOptions.beforeRedirect).toBeDefined();
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock(), mock())).not.toThrow();
},
);
});
});
describe('SSRF protection', () => {
const baseUrl = 'https://example.com';
const workflow = mock<Workflow>();
const hooks = mock<NonNullable<IWorkflowExecuteAdditionalData['hooks']>>();
const node = mock<INode>();
const createSsrfBridge = (overrides?: Partial<SsrfBridge>): SsrfBridge => ({
validateIp: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
...overrides,
});
beforeEach(() => {
nock.cleanAll();
hooks.runHook.mockClear();
});
test('proxyRequestToAxios should resolve baseURL + relative url for validateUrl', async () => {
const ssrfBridge = createSsrfBridge();
const additionalData = mock<IWorkflowExecuteAdditionalData>({
hooks,
ssrfBridge,
});
nock(baseUrl).get('/test').reply(200, 'ok');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
baseURL: baseUrl,
url: '/test',
});
expect(response).toEqual('ok');
expect(ssrfBridge.validateUrl).toHaveBeenCalledWith(new URL(`${baseUrl}/test`));
});
describe('domain allowlist enforcement', () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({
hooks,
ssrfBridge: undefined,
});
beforeEach(() => {
nock.cleanAll();
});
describe('proxyRequestToAxios', () => {
test('should block requests to disallowed domains', async () => {
await expect(
proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/data`,
allowedDomains: 'other.com',
}),
).rejects.toThrow('Domain not allowed');
});
test.each([['example.com'], [undefined]])(
'should allow requests to allowed domains when allowedDomains is %s',
async (allowedDomains) => {
nock(baseUrl).get('/data').reply(200, 'ok');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/data`,
allowedDomains,
});
expect(response).toBe('ok');
},
);
test('should block 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');
await expect(
proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
allowedDomains: 'example.com',
followAllRedirects: true,
}),
).rejects.toThrow('Domain not allowed');
});
test.each([['example.com, allowed.com'], [undefined]])(
'should allow redirects to allowed domains when allowedDomains is %s',
async (allowedDomains) => {
nock(baseUrl).get('/redirect').reply(301, '', { Location: 'https://allowed.com/data' });
nock('https://allowed.com').get('/data').reply(200, 'ok');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: `${baseUrl}/redirect`,
allowedDomains,
followAllRedirects: true,
});
expect(response).toBe('ok');
},
);
test('should support wildcard domains in allowedDomains', async () => {
nock('https://api.example.com').get('/data').reply(200, 'ok');
const response = await proxyRequestToAxios(workflow, additionalData, node, {
url: 'https://api.example.com/data',
allowedDomains: '*.example.com',
});
expect(response).toBe('ok');
});
test('should block wildcard domains that do not match', async () => {
await expect(
proxyRequestToAxios(workflow, additionalData, node, {
url: 'https://blocked.com/data',
allowedDomains: '*.example.com',
}),
).rejects.toThrow('Domain not allowed');
});
proxyRequestToAxios(workflow, additionalData, node, 'https://example.test'),
).resolves.toBe('ok');
});
});
});
@@ -1,348 +1,13 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Logger } from '@n8n/backend-common';
import {
applyDefaultOutboundUserAgent,
binaryToString,
createFormDataObject,
generateContentLengthHeader,
getBeforeRedirectFn,
getHostFromRequestObject,
invokeAxios,
isFormDataInstance,
parseIncomingMessage,
resolveLegacyRequestUrl,
searchForHeader,
setAxiosAgents,
throwIfDomainNotAllowed,
validateUrlSsrf,
type SsrfBridge,
} from '@n8n/backend-network';
import { OutboundHttp } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type { AxiosHeaders, AxiosRequestConfig } from 'axios';
import crypto from 'crypto';
import type FormData from 'form-data';
import { IncomingMessage } from 'http';
import { type AgentOptions } from 'https';
import pick from 'lodash/pick';
import type {
GenericValue,
INode,
IRequestOptions,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import { NodeSslError } from 'n8n-workflow';
import { stringify } from 'qs';
import { Readable } from 'stream';
/**
* This function is a temporary implementation that translates all http requests
* done via the request library to axios directly.
* We are not using n8n's interface as it would an unnecessary step,
* considering the `request` helper has been be deprecated and should be removed.
* @deprecated This is only used by legacy request helpers, that are also deprecated
*/
// eslint-disable-next-line complexity
export async function parseRequestObject(requestObject: IRequestOptions, ssrfBridge?: SsrfBridge) {
const axiosConfig: AxiosRequestConfig = {};
if (requestObject.headers !== undefined) {
axiosConfig.headers = requestObject.headers as AxiosHeaders;
}
// Let's start parsing the hardest part, which is the request body.
// The process here is as following?
// - Check if we have a `content-type` header. If this was set,
// we will follow
// - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded
// - Check if the `formData` property exists. If yes, then it's multipart/form-data
// - Lastly, we should have a regular `body` that is probably a JSON.
const contentTypeHeaderKeyName =
axiosConfig.headers &&
Object.keys(axiosConfig.headers).find(
(headerName) => headerName.toLowerCase() === 'content-type',
);
const contentType =
contentTypeHeaderKeyName &&
(axiosConfig.headers?.[contentTypeHeaderKeyName] as string | undefined);
if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) {
// there are nodes incorrectly created, informing the content type header
// and also using formData. Request lib takes precedence for the formData.
// We will do the same.
// Merge body and form properties.
if (typeof requestObject.body === 'string') {
axiosConfig.data = requestObject.body;
} else {
const allData = Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
string,
string
>;
if (requestObject.useQuerystring === true) {
axiosConfig.data = stringify(allData, { arrayFormat: 'repeat' });
} else {
axiosConfig.data = stringify(allData);
}
}
} else if (contentType?.includes('multipart/form-data')) {
if (requestObject.formData !== undefined && isFormDataInstance(requestObject.formData)) {
axiosConfig.data = requestObject.formData;
} else {
const allData: Partial<FormData> = {
...(requestObject.body as object | undefined),
...(requestObject.formData as object | undefined),
};
axiosConfig.data = createFormDataObject(allData);
}
// replace the existing header with a new one that
// contains the boundary property.
delete axiosConfig.headers?.[contentTypeHeaderKeyName!];
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
// If we have only form
axiosConfig.data =
typeof requestObject.form === 'string'
? stringify(requestObject.form, { format: 'RFC3986' })
: stringify(requestObject.form).toString();
if (axiosConfig.headers !== undefined) {
const headerName = searchForHeader(axiosConfig, 'content-type');
if (headerName) {
delete axiosConfig.headers[headerName];
}
axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else {
axiosConfig.headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
}
} else if (requestObject.formData !== undefined) {
// remove any "content-type" that might exist.
if (axiosConfig.headers !== undefined) {
const headers = Object.keys(axiosConfig.headers);
headers.forEach((header) => {
if (header.toLowerCase() === 'content-type') {
delete axiosConfig.headers?.[header];
}
});
}
if (isFormDataInstance(requestObject.formData)) {
axiosConfig.data = requestObject.formData;
} else {
axiosConfig.data = createFormDataObject(requestObject.formData as Record<string, unknown>);
}
// Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined && requestObject.body) {
// merge both objects when exist.
requestObject.body = Object.assign(requestObject.body, requestObject.form);
}
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
}
}
if (requestObject.uri !== undefined) {
axiosConfig.url = requestObject.uri?.toString();
}
if (requestObject.url !== undefined) {
axiosConfig.url = requestObject.url?.toString();
}
if (requestObject.baseURL !== undefined) {
axiosConfig.baseURL = requestObject.baseURL?.toString();
}
if (requestObject.method !== undefined) {
axiosConfig.method = requestObject.method;
}
if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) {
axiosConfig.params = requestObject.qs;
}
function hasArrayFormatOptions(
arg: IRequestOptions,
): arg is Required<Pick<IRequestOptions, 'qsStringifyOptions'>> {
if (
typeof arg.qsStringifyOptions === 'object' &&
arg.qsStringifyOptions !== null &&
!Array.isArray(arg.qsStringifyOptions) &&
'arrayFormat' in arg.qsStringifyOptions
) {
return true;
}
return false;
}
if (
requestObject.useQuerystring === true ||
(hasArrayFormatOptions(requestObject) &&
requestObject.qsStringifyOptions.arrayFormat === 'repeat')
) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'repeat' });
};
} else if (requestObject.useQuerystring === false) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'indices' });
};
}
if (
hasArrayFormatOptions(requestObject) &&
requestObject.qsStringifyOptions.arrayFormat === 'brackets'
) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'brackets' });
};
}
if (requestObject.auth !== undefined) {
// Check support for sendImmediately
if (requestObject.auth.bearer !== undefined) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Authorization: `Bearer ${requestObject.auth.bearer}`,
});
} else {
const authObj = requestObject.auth;
// Request accepts both user/username and pass/password
axiosConfig.auth = {
username: (authObj.user || authObj.username) as string,
password: (authObj.password || authObj.pass) as string,
};
}
}
// Only set header if we have a body, otherwise it may fail
if (requestObject.json === true) {
// Add application/json headers - do not set charset as it breaks a lot of stuff
// only add if no other accept headers was sent.
const acceptHeaderExists =
axiosConfig.headers === undefined
? false
: Object.keys(axiosConfig.headers)
.map((headerKey) => headerKey.toLowerCase())
.includes('accept');
if (!acceptHeaderExists) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Accept: 'application/json',
});
}
}
if (requestObject.json === false || requestObject.json === undefined) {
// Prevent json parsing
axiosConfig.transformResponse = (res) => res;
}
// Axios will follow redirects by default, so we simply tell it otherwise if needed.
const { method } = requestObject;
if (
(requestObject.followRedirect !== false &&
(!method || method === 'GET' || method === 'HEAD')) ||
requestObject.followAllRedirects
) {
axiosConfig.maxRedirects = requestObject.maxRedirects;
} else {
axiosConfig.maxRedirects = 0;
}
const host = getHostFromRequestObject(requestObject);
const agentOptions: AgentOptions = { ...requestObject.agentOptions };
if (host) {
agentOptions.servername = host;
}
if (requestObject.rejectUnauthorized === false) {
agentOptions.rejectUnauthorized = false;
agentOptions.secureOptions = crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT;
}
if (requestObject.timeout !== undefined) {
axiosConfig.timeout = requestObject.timeout;
}
const secureLookup = ssrfBridge?.createSecureLookup();
setAxiosAgents(axiosConfig, agentOptions, requestObject.proxy, secureLookup);
axiosConfig.beforeRedirect = getBeforeRedirectFn(
agentOptions,
axiosConfig,
requestObject.proxy,
requestObject.sendCredentialsOnCrossOriginRedirect ?? true,
requestObject.allowedDomains,
ssrfBridge,
);
if (requestObject.useStream) {
axiosConfig.responseType = 'stream';
} else if (requestObject.encoding === null) {
// When downloading files, return an arrayBuffer.
axiosConfig.responseType = 'arraybuffer';
}
// If we don't set an accept header
// Axios forces "application/json, text/plan, */*"
// Which causes some nodes like NextCloud to break
// as the service returns XML unless requested otherwise.
const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : [];
if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' });
}
if (
requestObject.json !== false &&
axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
// Use default header for application/json
// If we don't specify this here, axios will add
// application/json; charset=utf-8
// and this breaks a lot of stuff
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
'content-type': 'application/json',
});
}
if (requestObject.simple === false) {
axiosConfig.validateStatus = () => true;
}
applyDefaultOutboundUserAgent(axiosConfig);
/**
* Missing properties:
* encoding (need testing)
* gzip (ignored - default already works)
* resolveWithFullResponse (implemented elsewhere)
*/
return axiosConfig;
}
/**
* @deprecated This is only used by legacy request helpers, that are also deprecated
@@ -354,92 +19,18 @@ export async function proxyRequestToAxios(
uriOrObject: string | IRequestOptions,
options?: IRequestOptions,
): Promise<any> {
let axiosConfig: AxiosRequestConfig = {
maxBodyLength: Infinity,
// -1 is the Axios sentinel for "no limit". Infinity also means no limit but
// Axios 1.15.1+ treats any value > -1 as a finite cap, wrapping stream responses
// in Readable.from() even when the limit is Infinity. That breaks the downstream
// `instanceof IncomingMessage` checks in parseIncomingMessage / prepareBinaryData.
maxContentLength: -1,
};
let configObject: IRequestOptions;
if (typeof uriOrObject === 'string') {
configObject = { uri: uriOrObject, ...options };
} else {
configObject = uriOrObject ?? {};
}
const configObject: IRequestOptions =
typeof uriOrObject === 'string' ? { uri: uriOrObject, ...options } : (uriOrObject ?? {});
const ssrfBridge = additionalData?.ssrfBridge;
const url = resolveLegacyRequestUrl(configObject);
await validateUrlSsrf(url, ssrfBridge);
// 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',
});
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject, ssrfBridge));
throwIfDomainNotAllowed(axiosConfig, configObject.allowedDomains);
try {
const response = await invokeAxios(axiosConfig, configObject.auth);
let body = response.data;
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
parseIncomingMessage(body);
} else if (body === '') {
body = axiosConfig.responseType === 'arraybuffer' ? Buffer.alloc(0) : undefined;
}
await additionalData?.hooks?.runHook('nodeFetchedData', [workflow?.id, node]);
return configObject.resolveWithFullResponse
? {
body,
headers: { ...response.headers },
statusCode: response.status,
statusMessage: response.statusText,
request: response.request,
}
: body;
} catch (error) {
const { config, response } = error;
// Axios hydrates the original error with more data. We extract them.
// https://github.com/axios/axios/blob/master/lib/core/enhanceError.js
// Note: `code` is ignored as it's an expected part of the errorData.
if (error.isAxiosError) {
error.config = error.request = undefined;
error.options = pick(config ?? {}, ['url', 'method', 'data', 'headers']);
if (response) {
Container.get(Logger).debug('Request proxied to Axios failed', { status: response.status });
let responseData = response.data;
if (Buffer.isBuffer(responseData) || responseData instanceof Readable) {
responseData = await binaryToString(responseData);
}
if (configObject.simple === false) {
if (configObject.resolveWithFullResponse) {
return {
body: responseData,
headers: response.headers,
statusCode: response.status,
statusMessage: response.statusText,
};
} else {
return responseData;
}
}
error.message = `${response.status as number} - ${JSON.stringify(responseData)}`;
throw Object.assign(error, {
statusCode: response.status,
/**
* Axios adds `status` when serializing, causing `status` to be available only to the client.
* Hence we add it explicitly to allow the backend to use it when resolving expressions.
*/
status: response.status,
error: responseData,
response: pick(response, ['headers', 'status', 'statusText']),
});
} else if ('rejectUnauthorized' in configObject && error.code?.includes('CERT')) {
throw new NodeSslError(error);
}
}
throw error;
}
return await client.requestLegacy(configObject, {
onFetched: async () => {
await additionalData?.hooks?.runHook('nodeFetchedData', [workflow?.id, node]);
},
});
}
@@ -1,6 +1,6 @@
import { CredentialsHelper } from '@nodes-testing/credentials-helper';
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import { convertN8nRequestToAxios } from '@n8n/backend-network';
import { convertN8nRequestToAxios } from '@n8n/backend-network/testing';
import type {
IExecuteSingleFunctions,
ILoadOptionsFunctions,
@@ -280,6 +280,7 @@ export async function testPollingTriggerNode(
ssrfBridge: {
validateIp: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),
validateConnectionHost: vi.fn().mockReturnValue({ ok: true, result: undefined }),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
} as SsrfBridge,
+1 -1
View File
@@ -548,7 +548,7 @@ export interface IHttpRequestOptions {
* If set, requests to domains not in this list will be blocked.
*/
allowedDomains?: string;
agentOptions?: Omit<AgentOptions, 'socket'>;
agentOptions?: Omit<AgentOptions, 'socket' | 'lookup'>;
}
/**
+3
View File
@@ -1186,6 +1186,9 @@ importers:
reflect-metadata:
specifier: 'catalog:'
version: 0.2.2
undici:
specifier: catalog:undici-v7
version: 7.27.2
zod:
specifier: 3.25.67
version: 3.25.67