mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
refactor(core): Define the outbound HTTP factory contract for backend-network (no-changelog) (#32245)
This commit is contained in:
@@ -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 service’s 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-8
@@ -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
-2
@@ -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,
|
||||
+49
-39
@@ -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'));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
+22
-6
@@ -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,
|
||||
+1
-1
@@ -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';
|
||||
+38
-24
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
-1
@@ -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';
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
+96
-532
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-422
@@ -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,
|
||||
|
||||
@@ -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'>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user