feat(core): Validate redirect targets when routing requests through a proxy (no-changelog) (#32662)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Lorent Lempereur
2026-06-23 12:38:12 +02:00
committed by GitHub
parent 01978171ef
commit 3b3fe5aef0
21 changed files with 1317 additions and 263 deletions
@@ -0,0 +1,206 @@
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 { createAuthorizationInterceptor, type RequestAuthorizer } from '../undici/transport';
// The authorization gate is a dispatcher interceptor, like the SSRF one. This
// file proves it at two levels:
// (a) a direct unit test of `createAuthorizationInterceptor`, and
// (b) end-to-end tests against a real local server (no mocked `fetch`), so the
// interceptor actually runs on the initial request and every redirect hop,
// and we assert the SSRF policy runs *before* the authorizer.
// Drain the microtask queue so the interceptor's async `authorize().then(...)`
// has settled before we assert.
const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0));
// The interceptor hands the authorizer a `URL` object (not a string), so we match
// on its `href` rather than comparing against a raw string.
const authorizedUrl = (href: string) => expect.objectContaining({ href }) as unknown as URL;
// ---------------------------------------------------------------------------
// (a) createAuthorizationInterceptor — unit
// ---------------------------------------------------------------------------
function makeInterceptedDispatch(authorize: RequestAuthorizer) {
const innerDispatch = vi.fn();
const dispatch = createAuthorizationInterceptor(authorize)(
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('createAuthorizationInterceptor', () => {
it('authorizes the reconstructed target URL and dispatches when allowed', async () => {
const authorize = vi.fn<RequestAuthorizer>().mockResolvedValue(undefined);
const { innerDispatch, dispatch } = makeInterceptedDispatch(authorize);
const handler = makeHandler();
const ret = dispatch(makeOpts('/data', 'https://api.example.com'), handler);
await flush();
expect(ret).toBe(true);
expect(authorize).toHaveBeenCalledWith(authorizedUrl('https://api.example.com/data'));
expect(innerDispatch).toHaveBeenCalledTimes(1);
expect(handler.onResponseError).not.toHaveBeenCalled();
});
it('fails the dispatch and does not dispatch when the authorizer throws', async () => {
const error = new Error('domain not approved');
const authorize = vi.fn<RequestAuthorizer>().mockRejectedValue(error);
const { innerDispatch, dispatch } = makeInterceptedDispatch(authorize);
const handler = makeHandler();
dispatch(makeOpts('/secret', 'https://blocked.example.com'), 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 authorize = vi.fn<RequestAuthorizer>().mockResolvedValue(undefined);
const { innerDispatch, dispatch } = makeInterceptedDispatch(authorize);
const handler = makeHandler();
dispatch(makeOpts('not a url'), handler);
await flush();
expect(authorize).not.toHaveBeenCalled();
expect(innerDispatch).not.toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalled();
});
it('falls back to onError when onResponseError is unavailable', async () => {
const error = new Error('domain not approved');
const authorize = vi.fn<RequestAuthorizer>().mockRejectedValue(error);
const { dispatch } = makeInterceptedDispatch(authorize);
const handler = { onError: vi.fn() } as unknown as Dispatcher.DispatchHandler & {
onError: ReturnType<typeof vi.fn>;
};
dispatch(makeOpts('/secret', 'https://blocked.example.com'), 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 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 authorizer's 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('authorization end-to-end', () => {
let server: LocalServer;
beforeEach(async () => {
server = await startRedirectServer();
});
afterEach(async () => {
await server.close();
});
it('authorizes the initial request and every redirect hop before fetching it', async () => {
const authorize = vi.fn<RequestAuthorizer>().mockResolvedValue(undefined);
const fetchFn = makeTransport({ ssrf: 'disabled', proxy: false, authorize }).asCustomFetch();
const res = await fetchFn(`${server.url}/start`);
expect(res.status).toBe(200);
await expect(res.text()).resolves.toBe('reached:/internal');
expect(authorize).toHaveBeenCalledWith(authorizedUrl(`${server.url}/start`));
expect(authorize).toHaveBeenCalledWith(authorizedUrl(`${server.url}/internal`));
expect(server.captured).toEqual(['/start', '/internal']);
});
it('blocks a redirect hop the authorizer rejects, even though the initial URL is allowed', async () => {
const error = new Error('redirect target not approved');
const authorize = vi.fn<RequestAuthorizer>(async (url) => {
if (url.href.includes('/internal')) throw error;
});
const fetchFn = makeTransport({ ssrf: 'disabled', proxy: false, authorize }).asCustomFetch();
const rejection = await fetchFn(`${server.url}/start`).catch((e: unknown) => e);
expect(rejection).toBeInstanceOf(Error);
expect(rootCauseMessage(rejection)).toBe(error.message);
expect(server.captured).toContain('/start');
expect(server.captured).not.toContain('/internal');
});
it('runs the SSRF policy before the authorizer: an SSRF-blocked hop is never authorized', async () => {
const ssrfError = new Error('SSRF: blocked /internal');
const bridge: SsrfBridge = makeSsrfBridge({
validateUrl: vi.fn(async (url: string | URL) => {
const href = typeof url === 'string' ? url : url.href;
return href.includes('/internal')
? { ok: false as const, error: ssrfError }
: { ok: true as const, result: undefined };
}),
});
const authorize = vi.fn<RequestAuthorizer>().mockResolvedValue(undefined);
const fetchFn = makeTransport({ ssrf: bridge, proxy: false, authorize }).asCustomFetch();
await expect(fetchFn(`${server.url}/start`)).rejects.toThrow();
// The initial hop passed SSRF, so it was authorized; the redirect target was
// SSRF-blocked first, so the authorizer was never consulted for it.
expect(authorize).toHaveBeenCalledWith(authorizedUrl(`${server.url}/start`));
expect(authorize).not.toHaveBeenCalledWith(authorizedUrl(`${server.url}/internal`));
expect(server.captured).not.toContain('/internal');
});
});
@@ -46,6 +46,8 @@ describe('transport timeouts', () => {
it('builds a dispatcher without timeouts when none are provided', () => {
// Smoke check that the optional argument path stays valid.
expect(() => buildDispatcher(false, 'disabled')).not.toThrow();
expect(() => buildDispatcher('env', 'disabled', { bodyTimeout: 1000 })).not.toThrow();
expect(() =>
buildDispatcher('env', 'disabled', { timeouts: { bodyTimeout: 1000 } }),
).not.toThrow();
});
});
@@ -0,0 +1,96 @@
import { AiConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import nock from 'nock';
import { configureGlobalAxiosDefaults } from '../config';
import { invokeAxios } from '../invoke';
// Sets axios defaults and registers the vendor-header interceptor.
configureGlobalAxiosDefaults();
describe('invokeAxios', () => {
const baseUrl = 'https://example.de';
beforeEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should throw error for non-401 status codes', async () => {
nock(baseUrl).get('/test').reply(500, {});
await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow(
'Request failed with status code 500',
);
});
it('should throw error on 401 without digest auth challenge', async () => {
nock(baseUrl).get('/test').reply(401, {});
await expect(
invokeAxios(
{
url: `${baseUrl}/test`,
},
false,
),
).rejects.toThrow('Request failed with status code 401');
});
it('should make successful requests', async () => {
nock(baseUrl).get('/test').reply(200, { success: true });
const response = await invokeAxios({
url: `${baseUrl}/test`,
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should handle digest auth when receiving 401 with nonce', async () => {
nock(baseUrl)
.get('/test')
.matchHeader('authorization', 'Basic dXNlcjpwYXNz')
.once()
.reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' });
nock(baseUrl)
.get('/test')
.matchHeader(
'authorization',
/^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/,
)
.reply(200, { success: true });
const response = await invokeAxios(
{
url: `${baseUrl}/test`,
auth: {
username: 'user',
password: 'pass',
},
},
false,
);
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should include vendor headers in requests to OpenAi', async () => {
const { openAiDefaultHeaders } = Container.get(AiConfig);
nock('https://api.openai.com', {
reqheaders: openAiDefaultHeaders,
})
.get('/chat')
.reply(200, { success: true });
const response = await invokeAxios({
url: 'https://api.openai.com/chat',
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
});
@@ -8,100 +8,13 @@ import { mock } from 'vitest-mock-extended';
import type { SsrfBridge } from '../../../ssrf';
import { configureGlobalAxiosDefaults } from '../config';
import { convertN8nRequestToAxios, httpRequest, invokeAxios, removeEmptyBody } from '../request';
import { convertN8nRequestToAxios, httpRequest, removeEmptyBody } from '../request';
// Sets axios defaults and registers the vendor-header interceptor.
configureGlobalAxiosDefaults();
const TEST_CA_CERT = '-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----';
describe('invokeAxios', () => {
const baseUrl = 'https://example.de';
beforeEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should throw error for non-401 status codes', async () => {
nock(baseUrl).get('/test').reply(500, {});
await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow(
'Request failed with status code 500',
);
});
it('should throw error on 401 without digest auth challenge', async () => {
nock(baseUrl).get('/test').reply(401, {});
await expect(
invokeAxios(
{
url: `${baseUrl}/test`,
},
{ sendImmediately: false },
),
).rejects.toThrow('Request failed with status code 401');
});
it('should make successful requests', async () => {
nock(baseUrl).get('/test').reply(200, { success: true });
const response = await invokeAxios({
url: `${baseUrl}/test`,
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should handle digest auth when receiving 401 with nonce', async () => {
nock(baseUrl)
.get('/test')
.matchHeader('authorization', 'Basic dXNlcjpwYXNz')
.once()
.reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' });
nock(baseUrl)
.get('/test')
.matchHeader(
'authorization',
/^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/,
)
.reply(200, { success: true });
const response = await invokeAxios(
{
url: `${baseUrl}/test`,
auth: {
username: 'user',
password: 'pass',
},
},
{ sendImmediately: false },
);
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should include vendor headers in requests to OpenAi', async () => {
const { openAiDefaultHeaders } = Container.get(AiConfig);
nock('https://api.openai.com', {
reqheaders: openAiDefaultHeaders,
})
.get('/chat')
.reply(200, { success: true });
const response = await invokeAxios({
url: 'https://api.openai.com/chat',
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
});
describe('removeEmptyBody', () => {
test.each(['GET', 'HEAD', 'OPTIONS'] as IHttpRequestMethods[])(
'Should remove empty body for %s',
@@ -0,0 +1,316 @@
import type { Logger } from '@n8n/backend-common';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge } from '../../../ssrf';
import { makeSsrfBridge } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { executeLegacyRequest } from '../../legacy-request';
import { type LocalServer, startServer } from '../../local-server';
import { configureGlobalAxiosDefaults } from '../config';
import { httpRequest } from '../request';
// When a proxy may carry the request, axios' synchronous redirect following
// cannot validate a hostname target, so `httpRequest` follows redirects itself
// and runs the SSRF target check on every hop. These tests drive the real path
// against a local server: a proxy env var is set so the manual follower engages,
// while `NO_PROXY` keeps the loopback connection direct (no real proxy needed).
configureGlobalAxiosDefaults();
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] as const;
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;
}
if (req.url === '/multiple-choices') {
// A 3xx outside the classic set (300) that still carries a Location to follow.
res.writeHead(300, { Location: `${serverUrl}/internal` });
res.end();
return;
}
if (req.url === '/not-modified') {
// A 3xx without a Location: not a redirect, must surface to the caller's status policy.
res.writeHead(304);
res.end();
return;
}
if (req.url === '/bad-location') {
// A redirect to a Location the server got wrong: not a resolvable URL.
res.writeHead(302, { Location: 'http://' });
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(`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 };
}
describe('httpRequest manual redirect following with SSRF + proxy', () => {
let server: LocalServer;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(async () => {
for (const key of PROXY_ENV_KEYS) {
savedEnv[key] = process.env[key];
}
// A proxy is configured (engages the manual follower), but loopback is
// exempt so the request connects directly to the local server.
process.env.HTTP_PROXY = 'http://127.0.0.1:1';
process.env.HTTPS_PROXY = 'http://127.0.0.1:1';
process.env.NO_PROXY = '127.0.0.1,localhost';
server = await startRedirectServer();
});
afterEach(async () => {
for (const key of PROXY_ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
await server.close();
});
it('validates the redirect target and blocks it even though the initial URL is allowed', async () => {
const { bridge } = makeBridge('/internal');
await expect(
httpRequest({ method: 'GET', url: `${server.url}/start` }, bridge),
).rejects.toThrow('/internal');
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: `${server.url}/start` }),
);
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: `${server.url}/internal` }),
);
expect(server.captured).toContain('/start');
expect(server.captured).not.toContain('/internal');
});
it('follows the redirect when every hop passes validation', async () => {
const { bridge } = makeBridge('/never-matches');
const response = await httpRequest({ method: 'GET', url: `${server.url}/start` }, bridge);
expect(response).toBe('reached:/internal');
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: `${server.url}/internal` }),
);
expect(server.captured).toEqual(['/start', '/internal']);
});
it('follows a 3xx outside the classic set when it carries a Location', async () => {
const { bridge } = makeBridge('/never-matches');
const response = await httpRequest(
{ method: 'GET', url: `${server.url}/multiple-choices` },
bridge,
);
expect(response).toBe('reached:/internal');
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: `${server.url}/internal` }),
);
expect(server.captured).toEqual(['/multiple-choices', '/internal']);
});
it('surfaces a 3xx without a Location through the caller status policy instead of following it', async () => {
const { bridge } = makeBridge('/never-matches');
await expect(
httpRequest({ method: 'GET', url: `${server.url}/not-modified` }, bridge),
).rejects.toThrow('status code 304');
expect(server.captured).toEqual(['/not-modified']);
});
it('blocks the initial request before any hop when its URL is rejected', async () => {
const { bridge } = makeBridge('/start');
await expect(
httpRequest({ method: 'GET', url: `${server.url}/start` }, bridge),
).rejects.toThrow('/start');
expect(server.captured).toEqual([]);
});
it('throws a clear error when the server returns a malformed redirect location', async () => {
const { bridge } = makeBridge('/never-matches');
await expect(
httpRequest({ method: 'GET', url: `${server.url}/bad-location` }, bridge),
).rejects.toThrow('Invalid redirect location received from server');
expect(server.captured).toEqual(['/bad-location']);
});
it('returns the redirect response without following when redirects are disabled', async () => {
const { bridge } = makeBridge('/internal');
const response = await httpRequest(
{
method: 'GET',
url: `${server.url}/start`,
disableFollowRedirect: true,
returnFullResponse: true,
ignoreHttpStatusErrors: true,
},
bridge,
);
expect(response.statusCode).toBe(302);
expect(bridge.validateUrl).not.toHaveBeenCalledWith(
expect.objectContaining({ href: `${server.url}/internal` }),
);
expect(server.captured).toEqual(['/start']);
});
describe('legacy request path', () => {
it('validates the redirect target and blocks it', async () => {
const { bridge } = makeBridge('/internal');
await expect(
executeLegacyRequest({ uri: `${server.url}/start` }, bridge, mock<Logger>()),
).rejects.toThrow('/internal');
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: `${server.url}/internal` }),
);
expect(server.captured).toContain('/start');
expect(server.captured).not.toContain('/internal');
});
it('follows the redirect when every hop passes validation', async () => {
const { bridge } = makeBridge('/never-matches');
const body = await executeLegacyRequest(
{ uri: `${server.url}/start` },
bridge,
mock<Logger>(),
);
expect(body).toBe('reached:/internal');
expect(server.captured).toEqual(['/start', '/internal']);
});
});
describe('credential headers across redirects', () => {
let origin: LocalServer;
let crossOrigin: LocalServer;
let received: Record<string, string | string[] | undefined> = {};
const credentialHeaders = {
Authorization: 'Bearer secret',
'Proxy-Authorization': 'Basic proxy-secret',
Cookie: 'session=secret',
'X-Keep': 'keep-me',
};
beforeEach(async () => {
received = {};
crossOrigin = await startServer((req, res) => {
received = req.headers;
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('reached');
});
let originUrl = '';
origin = await startServer((req, res) => {
if (req.url === '/cross') {
res.writeHead(302, { Location: `${crossOrigin.url}/dest` });
res.end();
return;
}
if (req.url === '/same') {
res.writeHead(302, { Location: `${originUrl}/landing` });
res.end();
return;
}
received = req.headers;
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('reached');
});
originUrl = origin.url;
});
afterEach(async () => {
await Promise.all([origin.close(), crossOrigin.close()]);
});
it('drops Authorization, Proxy-Authorization, and Cookie on a cross-origin redirect', async () => {
const { bridge } = makeBridge('/never-matches');
await httpRequest(
{
method: 'GET',
url: `${origin.url}/cross`,
headers: { ...credentialHeaders },
sendCredentialsOnCrossOriginRedirect: false,
},
bridge,
);
expect(received.authorization).toBeUndefined();
expect(received['proxy-authorization']).toBeUndefined();
expect(received.cookie).toBeUndefined();
// Non-credential headers still carry over.
expect(received['x-keep']).toBe('keep-me');
});
it('keeps credential headers on a same-origin redirect', async () => {
const { bridge } = makeBridge('/never-matches');
await httpRequest(
{
method: 'GET',
url: `${origin.url}/same`,
headers: { ...credentialHeaders },
sendCredentialsOnCrossOriginRedirect: false,
},
bridge,
);
expect(received.authorization).toBe('Bearer secret');
expect(received['proxy-authorization']).toBe('Basic proxy-secret');
expect(received.cookie).toBe('session=secret');
});
it('keeps Proxy-Authorization cross-origin when credentials are explicitly allowed', async () => {
const { bridge } = makeBridge('/never-matches');
await httpRequest(
{
method: 'GET',
url: `${origin.url}/cross`,
headers: { ...credentialHeaders },
sendCredentialsOnCrossOriginRedirect: true,
},
bridge,
);
expect(received['proxy-authorization']).toBe('Basic proxy-secret');
});
});
});
@@ -7,14 +7,18 @@ import { mock } from 'vitest-mock-extended';
import { makeSsrfBridge } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { buildNodeAgents } from '../../node-agents';
import {
buildAgentOptions,
buildTargetUrl,
createFormDataObject,
digestAuthAxiosConfig,
generateContentLengthHeader,
getBeforeRedirectFn,
getHostFromRequestObject,
getRedirectLocation,
getUrlFromProxyConfig,
isIgnoreStatusErrorConfig,
isProxyPotentiallyActive,
isRedirectStatus,
searchForHeader,
setAxiosAgents,
tryParseUrl,
@@ -28,8 +32,8 @@ vi.mock('../../node-agents', () => ({
httpAgent: { type: 'http', ...opts },
httpsAgent: { type: 'https', ...opts },
})),
isSupportedProxyUrl: (value: string) =>
value.startsWith('http://') || value.startsWith('https://'),
isSupportedProxyUrl: (value: string | null | undefined) =>
typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://')),
}));
describe('isIgnoreStatusErrorConfig', () => {
@@ -528,3 +532,116 @@ describe('setAxiosAgents', () => {
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('socks5://proxy:1080'));
});
});
describe('isRedirectStatus', () => {
// Mirrors follow-redirects: any 3xx is in range (the `Location` check happens at the follow site).
it.each([300, 301, 302, 303, 304, 305, 307, 308, 399])(
'treats %i as in the 3xx range',
(status) => {
expect(isRedirectStatus(status)).toBe(true);
},
);
it.each([200, 204, 299, 400, 500])('treats %i as not a redirect', (status) => {
expect(isRedirectStatus(status)).toBe(false);
});
});
describe('getRedirectLocation', () => {
it('returns the Location header when present', () => {
const response = {
headers: { location: 'https://example.com/next' },
} as unknown as AxiosResponse;
expect(getRedirectLocation(response)).toBe('https://example.com/next');
});
it('returns undefined when the Location header is absent', () => {
const response = { headers: {} } as unknown as AxiosResponse;
expect(getRedirectLocation(response)).toBeUndefined();
});
it('returns undefined when the Location header is not a string', () => {
const response = { headers: { location: ['a', 'b'] } } as unknown as AxiosResponse;
expect(getRedirectLocation(response)).toBeUndefined();
});
});
describe('isProxyPotentiallyActive', () => {
const isProxyVar = (key: string) => /proxy/i.test(key);
const originalProxyEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => isProxyVar(key)),
);
const stripProxyEnv = () => {
for (const key of Object.keys(process.env)) {
if (isProxyVar(key)) delete process.env[key];
}
};
beforeEach(stripProxyEnv);
afterEach(() => {
stripProxyEnv();
Object.assign(process.env, originalProxyEnv);
});
it('is true for an explicit proxy object regardless of the environment', () => {
expect(isProxyPotentiallyActive({ host: 'proxy', port: 8080 })).toBe(true);
});
it('is true for an explicit supported proxy URL', () => {
expect(isProxyPotentiallyActive('http://proxy:8080')).toBe(true);
});
it('falls back to the environment for an unsupported proxy URL', () => {
expect(isProxyPotentiallyActive('socks5://proxy:1080')).toBe(false);
process.env.HTTPS_PROXY = 'http://env-proxy:3128';
expect(isProxyPotentiallyActive('socks5://proxy:1080')).toBe(true);
});
it('is true when a proxy environment variable is set and no explicit proxy is given', () => {
process.env.HTTP_PROXY = 'http://env-proxy:3128';
expect(isProxyPotentiallyActive()).toBe(true);
});
it('ignores empty proxy environment variables', () => {
process.env.HTTP_PROXY = '';
expect(isProxyPotentiallyActive()).toBe(false);
});
it('falls through an empty variable to a later set one', () => {
process.env.HTTP_PROXY = '';
process.env.HTTPS_PROXY = 'http://env-proxy:3128';
expect(isProxyPotentiallyActive()).toBe(true);
});
it('is false with neither an explicit proxy nor environment configuration', () => {
expect(isProxyPotentiallyActive()).toBe(false);
});
});
describe('buildAgentOptions', () => {
it('sets servername from the request host', () => {
const options = buildAgentOptions({ method: 'GET', url: 'https://api.example.com/v1' });
expect(options.servername).toBe('api.example.com');
});
it('disables certificate validation when skipSslCertificateValidation is set', () => {
const options = buildAgentOptions({
method: 'GET',
url: 'https://api.example.com',
skipSslCertificateValidation: true,
});
expect(options.rejectUnauthorized).toBe(false);
});
it('passes through provided agentOptions', () => {
const options = buildAgentOptions({
method: 'GET',
url: 'https://api.example.com',
agentOptions: { keepAlive: true },
});
expect(options.keepAlive).toBe(true);
expect(options.servername).toBe('api.example.com');
});
});
@@ -0,0 +1,28 @@
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { digestAuthAxiosConfig } from './utils';
export async function invokeAxios(axiosConfig: AxiosRequestConfig, authSendImmediately?: boolean) {
try {
return await axios(axiosConfig);
} catch (error) {
if (authSendImmediately !== false || !(error instanceof axios.AxiosError)) {
throw error;
}
// for digest-auth
const { response } = error;
const wwwAuthenticate: unknown = response?.headers['www-authenticate'];
if (
response?.status !== 401 ||
typeof wwwAuthenticate !== 'string' ||
!wwwAuthenticate.includes('nonce')
) {
throw error;
}
const { auth } = axiosConfig;
delete axiosConfig.auth;
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
return await axios(axiosConfig);
}
}
@@ -23,6 +23,26 @@ import {
} from './utils';
import type { SsrfBridge } from '../../ssrf';
/**
* Builds the per-request Node agent options for a legacy `IRequestOptions`,
* including the relaxed TLS settings the legacy path applies when `rejectUnauthorized` is `false`.
* Shared with the manual redirect follower so both derive agents the same way.
*
* @deprecated Backs the deprecated `request` helpers.
*/
export function buildLegacyAgentOptions(requestObject: IRequestOptions): AgentOptions {
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;
}
return agentOptions;
}
/**
* This function is a temporary implementation that translates all http requests
* done via the request library to axios directly.
@@ -254,15 +274,7 @@ export async function buildAxiosConfigFromLegacyRequest(
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;
}
const agentOptions: AgentOptions = buildLegacyAgentOptions(requestObject);
if (requestObject.timeout !== undefined) {
axiosConfig.timeout = requestObject.timeout;
@@ -0,0 +1,274 @@
import { AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios';
import type { AgentOptions } from 'https';
import type { IHttpRequestOptions } from 'n8n-workflow';
import { OperationalError } from 'n8n-workflow';
import { invokeAxios } from './invoke';
import {
buildTargetUrl,
getRedirectLocation,
getUrlFromProxyConfig,
isProxyPotentiallyActive,
isRedirectStatus,
resolveProxyOption,
throwIfDomainNotAllowed,
tryParseUrl,
validateUrlSsrf,
} from './utils';
import type { SsrfBridge } from '../../ssrf';
import { buildNodeAgents } from '../node-agents';
/**
* Default redirect cap, matching the limit axios applies through `follow-redirects`.
* Used when a request enables redirect following without naming an explicit limit.
*/
const MAX_REDIRECTS_DEFAULT = 21;
/**
* Inputs the manual redirect follower needs to re-derive each hop the way axios would,
* while validating the target of every hop against the SSRF policy.
*/
export interface SsrfRedirectPolicy {
ssrf: SsrfBridge;
proxyConfig?: IHttpRequestOptions['proxy'] | string;
agentOptions: AgentOptions;
allowedDomains?: string;
sendCredentialsOnCrossOriginRedirect: boolean;
authSendImmediately?: boolean;
}
/**
* Whether redirects must be followed manually instead of by axios.
*
* axios follows redirects synchronously via `follow-redirects`,
* so a redirect target can only be validated synchronously there,
* which cannot resolve a hostname.
* When a proxy may carry the request the connection-time DNS check does not see the final target either,
* so we take over redirect following to run the same async target validation as the initial request on every hop.
*/
export function shouldFollowRedirectsManually(
axiosConfig: AxiosRequestConfig,
proxyConfig: IHttpRequestOptions['proxy'] | string | undefined,
ssrfBridge?: SsrfBridge,
): ssrfBridge is SsrfBridge {
if (!ssrfBridge) {
return false;
}
const maxRedirects = axiosConfig.maxRedirects ?? MAX_REDIRECTS_DEFAULT;
if (maxRedirects === 0) {
return false;
}
return isProxyPotentiallyActive(proxyConfig);
}
/**
* @returns the absolute origin of a URL, falling back to the raw string.
*/
function safeOrigin(url: string): string {
return tryParseUrl(url)?.origin ?? url;
}
/** Whether two URLs live on different origins (scheme + host + port). */
function isCrossOrigin(fromUrl: string, toUrl: string): boolean {
return safeOrigin(fromUrl) !== safeOrigin(toUrl);
}
/**
* Whether a redirect must rewrite the request into a body-less GET.
* Mirrors `follow-redirects`: 303 downgrades any non-GET/HEAD; 301/302 downgrade a POST.
*/
function redirectDowngradesToGet(method: string, status: number): boolean {
const normalized = method.toUpperCase();
const isGetOrHead = normalized === 'GET' || normalized === 'HEAD';
return (
(status === 303 && !isGetOrHead) ||
((status === 301 || status === 302) && normalized === 'POST')
);
}
/**
* Header names that must not carry over to the next hop:
* - `Host`: always — the transport recomputes it for the new target
* - `Content-*`: only when the body is dropped on a GET downgrade, so its descriptors go too
* - `Authorization`/`Proxy-Authorization`/`Cookie`: credentials, dropped on a cross-origin hop unless explicitly allowed
*/
function headerPatternsToDropOnRedirect(
downgradeToGet: boolean,
stripCredentials: boolean,
): RegExp[] {
return [
/^host$/i,
...(downgradeToGet ? [/^content-/i] : []),
...(stripCredentials ? [/^authorization$/i, /^proxy-authorization$/i, /^cookie$/i] : []),
];
}
/** @returns a copy of `headers` without the entries whose name matches any pattern. */
function omitHeaders(
headers: AxiosRequestConfig['headers'],
patterns: RegExp[],
): AxiosRequestConfig['headers'] {
return Object.fromEntries(
Object.entries(headers ?? {}).filter(
([name]) => !patterns.some((pattern) => pattern.test(name)),
),
);
}
/** Fresh agents bound to the next target's host, re-running the SSRF policy on the new connection. */
function buildRedirectHopAgents(
nextUrl: string,
policy: SsrfRedirectPolicy,
): Pick<AxiosRequestConfig, 'httpAgent' | 'httpsAgent'> {
const host = tryParseUrl(nextUrl)?.hostname;
const customProxyUrl = policy.proxyConfig ? getUrlFromProxyConfig(policy.proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
return buildNodeAgents(proxy, policy.ssrf, {
...policy.agentOptions,
...(host ? { servername: host } : {}),
});
}
/**
* Frees an intermediate streamed response body before following the next hop.
*/
function discardResponseBody(response: AxiosResponse): void {
const data: unknown = response.data;
if (
data !== null &&
typeof data === 'object' &&
typeof (data as { destroy?: unknown }).destroy === 'function'
) {
(data as { destroy: () => void }).destroy();
}
}
/**
* Builds the next-hop axios config, mirroring how `follow-redirects` rewrites a
* request across a redirect: method/body downgrade, credential stripping on
* cross-origin hops, and fresh agents bound to the new target's host.
*/
function buildRedirectHopConfig(
prevConfig: AxiosRequestConfig,
status: number,
originalUrl: string,
nextUrl: string,
policy: SsrfRedirectPolicy,
): AxiosRequestConfig {
const downgradeToGet = redirectDowngradesToGet(prevConfig.method ?? 'GET', status);
const stripCredentials =
isCrossOrigin(originalUrl, nextUrl) && !policy.sendCredentialsOnCrossOriginRedirect;
return {
...prevConfig,
url: nextUrl,
// nextUrl is absolute and carries its own query string, so neither must carry over.
baseURL: undefined,
params: undefined,
// A GET downgrade drops the body, so its method and payload reset.
...(downgradeToGet ? { method: 'GET', data: undefined } : {}),
// Credentials must not follow a cross-origin hop unless explicitly allowed.
...(stripCredentials ? { auth: undefined } : {}),
headers: omitHeaders(
prevConfig.headers,
headerPatternsToDropOnRedirect(downgradeToGet, stripCredentials),
),
...buildRedirectHopAgents(nextUrl, policy),
};
}
/**
* Enforces the caller's status policy on a terminal response, throwing the same `AxiosError` axios would.
*/
function throwIfStatusRejected(
response: AxiosResponse,
validateStatus: (status: number) => boolean,
): void {
if (!validateStatus(response.status)) {
// Same code axios derives in `settle`: 4xx -> ERR_BAD_REQUEST, 5xx -> ERR_BAD_RESPONSE, else undefined.
const code = [AxiosError.ERR_BAD_REQUEST, AxiosError.ERR_BAD_RESPONSE][
Math.floor(response.status / 100) - 4
];
throw new AxiosError(
`Request failed with status code ${response.status}`,
code,
response.config,
response.request,
response,
);
}
}
/**
* Resolves a redirect `Location` against the current URL.
* @throws OperationalError when the server returns a malformed Location that cannot be resolved.
*/
function resolveRedirectUrl(location: string, currentUrl: string): string {
try {
return new URL(location, currentUrl).href;
} catch {
throw new OperationalError(`Invalid redirect location received from server: ${location}`);
}
}
/**
* Follows redirects manually, validating the target of every hop against the SSRF policy (DNS + IP),
* so a redirect cannot reach a target the initial pre-flight check never saw,
* including hostname targets carried by a proxy.
*/
export async function followSsrfRedirects(
initialConfig: AxiosRequestConfig,
policy: SsrfRedirectPolicy,
): Promise<AxiosResponse> {
const maxRedirects = initialConfig.maxRedirects ?? MAX_REDIRECTS_DEFAULT;
const originalUrl =
buildTargetUrl(initialConfig.url, initialConfig.baseURL) ?? initialConfig.url ?? '';
const baseValidateStatus =
initialConfig.validateStatus ?? ((status: number) => status >= 200 && status < 300);
// Each hop is a single request: disable axios' own following, let our custom
// agents do the proxying (so axios' built-in proxy does not wrap it again),
// and pass every 3xx through so we can inspect it for a `Location` to follow.
// The caller's status policy still applies to the final, non-redirect response
// (a non-3xx via axios here, a 3xx-without-Location via `throwIfStatusRejected`).
const prepareHop = (config: AxiosRequestConfig): AxiosRequestConfig => ({
...config,
maxRedirects: 0,
proxy: false,
validateStatus: (status: number) =>
isRedirectStatus(status) ? true : baseValidateStatus(status),
});
let config = prepareHop(initialConfig);
let currentUrl = originalUrl;
for (let redirectCount = 0; ; redirectCount++) {
const response = await invokeAxios(config, policy.authSendImmediately);
const location = getRedirectLocation(response);
if (!isRedirectStatus(response.status) || !location) {
if (isRedirectStatus(response.status)) {
throwIfStatusRejected(response, baseValidateStatus);
}
return response;
}
// This response is a redirect we will not return.
// Release its body (a no-op for buffered bodies, frees the socket for streamed ones)
// before we either stop or follow.
discardResponseBody(response);
if (redirectCount >= maxRedirects) {
throw new OperationalError(`Maximum number of redirects (${maxRedirects}) exceeded`);
}
const nextUrl = resolveRedirectUrl(location, currentUrl);
throwIfDomainNotAllowed(nextUrl, policy.allowedDomains);
await validateUrlSsrf(nextUrl, policy.ssrf);
config = prepareHop(
buildRedirectHopConfig(config, response.status, originalUrl, nextUrl, policy),
);
currentUrl = nextUrl;
}
}
@@ -1,8 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import type { AgentOptions } from 'https';
import type {
IHttpRequestOptions,
IN8nHttpFullResponse,
@@ -12,12 +8,13 @@ import type {
import { isObjectEmpty } from 'n8n-workflow';
import { stringify } from 'qs';
import { invokeAxios } from './invoke';
import { followSsrfRedirects, shouldFollowRedirectsManually } from './redirect';
import { applyDefaultOutboundUserAgent } from './user-agent';
import {
buildAgentOptions,
buildTargetUrl,
digestAuthAxiosConfig,
getBeforeRedirectFn,
getHostFromRequestObject,
isFormDataInstance,
isIgnoreStatusErrorConfig,
searchForHeader,
@@ -27,26 +24,6 @@ import {
} from './utils';
import type { SsrfBridge } from '../../ssrf';
export async function invokeAxios(
axiosConfig: AxiosRequestConfig,
authOptions: IRequestOptions['auth'] = {},
) {
try {
return await axios(axiosConfig);
} catch (error) {
if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error;
// for digest-auth
const { response } = error;
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
throw error;
}
const { auth } = axiosConfig;
delete axiosConfig.auth;
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
return await axios(axiosConfig);
}
}
export function convertN8nRequestToAxios(
n8nRequest: IHttpRequestOptions,
ssrfBridge?: SsrfBridge,
@@ -84,14 +61,7 @@ export function convertN8nRequestToAxios(
axiosRequest.responseType = n8nRequest.encoding;
}
const host = getHostFromRequestObject(n8nRequest);
const agentOptions: AgentOptions = { ...n8nRequest.agentOptions };
if (host) {
agentOptions.servername = host;
}
if (n8nRequest.skipSslCertificateValidation === true) {
agentOptions.rejectUnauthorized = false;
}
const agentOptions = buildAgentOptions(n8nRequest);
setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled');
axiosRequest.beforeRedirect = getBeforeRedirectFn(
@@ -228,7 +198,17 @@ export async function httpRequest(
throwIfDomainNotAllowed(axiosRequest, requestOptions.allowedDomains);
const result = await invokeAxios(axiosRequest, requestOptions.auth);
const result = shouldFollowRedirectsManually(axiosRequest, requestOptions.proxy, ssrfBridge)
? await followSsrfRedirects(axiosRequest, {
ssrf: ssrfBridge,
proxyConfig: requestOptions.proxy,
agentOptions: buildAgentOptions(requestOptions),
allowedDomains: requestOptions.allowedDomains,
sendCredentialsOnCrossOriginRedirect:
requestOptions.sendCredentialsOnCrossOriginRedirect ?? true,
authSendImmediately: requestOptions.auth?.sendImmediately,
})
: await invokeAxios(axiosRequest, requestOptions.auth?.sendImmediately);
if (requestOptions.returnFullResponse) {
return {
@@ -13,6 +13,7 @@ import {
} from 'n8n-workflow';
import type { SsrfBridge } from '../../ssrf';
import { hasProxyEnvironmentVariables } from '../http-proxy';
import { buildNodeAgents, isSupportedProxyUrl } from '../node-agents';
import type { ProxyOption, SsrfOption } from '../node-agents';
@@ -77,7 +78,7 @@ export const getHostFromRequestObject = (
* 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 {
export function resolveProxyOption(customProxyUrl: string | null): ProxyOption {
if (!customProxyUrl) {
return 'env';
}
@@ -242,6 +243,54 @@ export function isFormDataInstance(data: unknown): data is FormData {
);
}
/**
* Builds the per-request Node agent options (TLS `servername`, cert validation, ...) from an `IHttpRequestOptions`.
* Shared by the axios config builder and the manual redirect follower so both derive agents the same way.
*/
export function buildAgentOptions(n8nRequest: IHttpRequestOptions): AgentOptions {
const host = getHostFromRequestObject(n8nRequest);
const agentOptions: AgentOptions = { ...n8nRequest.agentOptions };
if (host) {
agentOptions.servername = host;
}
if (n8nRequest.skipSslCertificateValidation === true) {
agentOptions.rejectUnauthorized = false;
}
return agentOptions;
}
/**
* @returns true when a status code is in the 3xx range.
*
* Mirrors `follow-redirects`, which follows any 3xx response carrying a `Location` header
* (RFC 7231 §6.4) rather than an allow-list of specific codes. The `Location` is checked
* separately at the follow site, so a 3xx without one (e.g. `304`) is not followed and falls
* through to the caller's status policy.
*/
export function isRedirectStatus(status: number): boolean {
return status >= 300 && status < 400;
}
/**
* @returns the `Location` header from an axios response, if present and a string.
*/
export function getRedirectLocation(response: AxiosResponse): string | undefined {
const headers = response.headers as Record<string, unknown> | undefined;
const location = headers?.location;
return typeof location === 'string' ? location : undefined;
}
/**
* Whether a proxy may carry this request.
* @returns true when an explicit proxy is set, or any proxy environment variable is configured
*/
export function isProxyPotentiallyActive(
proxyConfig?: IHttpRequestOptions['proxy'] | string,
): boolean {
const configSupported = isSupportedProxyUrl(getUrlFromProxyConfig(proxyConfig));
return configSupported || hasProxyEnvironmentVariables();
}
/** Sets the `content-length` header by measuring the FormData stream length. */
export async function generateContentLengthHeader(config: AxiosRequestConfig) {
if (!isFormDataInstance(config.data)) {
@@ -267,8 +316,12 @@ export async function generateContentLengthHeader(config: AxiosRequestConfig) {
/** Converts an `IHttpRequestOptions['proxy']` (object or string) into a proxy URL string. */
export function getUrlFromProxyConfig(
proxyConfig: IHttpRequestOptions['proxy'] | string,
proxyConfig: IHttpRequestOptions['proxy'] | string | undefined | null,
): string | null {
if (!proxyConfig) {
return null;
}
if (typeof proxyConfig === 'string') {
const isValidUrl = !!tryParseUrl(proxyConfig);
return isValidUrl ? proxyConfig : null;
@@ -47,13 +47,14 @@ export function createHttpsProxyAgent(
return new https.Agent(options);
}
function hasProxyEnvironmentVariables(): boolean {
/** Whether any standard proxy environment variable is set (non-empty). */
export function hasProxyEnvironmentVariables(): boolean {
return Boolean(
process.env.HTTP_PROXY ??
process.env.http_proxy ??
process.env.HTTPS_PROXY ??
process.env.https_proxy ??
process.env.ALL_PROXY ??
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.ALL_PROXY ||
process.env.all_proxy,
);
}
@@ -11,7 +11,7 @@ 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 type { CustomFetch, RequestAuthorizer } from './undici/transport';
export {
OutboundHttp,
type HttpRequestClient,
@@ -11,12 +11,13 @@ 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 type { SsrfBridge } from '../ssrf';
import { invokeAxios } from './axios/invoke';
import { buildAxiosConfigFromLegacyRequest, buildLegacyAgentOptions } from './axios/legacy';
import { followSsrfRedirects, shouldFollowRedirectsManually } from './axios/redirect';
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 {
/**
@@ -63,7 +64,17 @@ export async function executeLegacyRequest(
throwIfDomainNotAllowed(axiosConfig, requestObject.allowedDomains);
try {
const response = await invokeAxios(axiosConfig, requestObject.auth);
const response = shouldFollowRedirectsManually(axiosConfig, requestObject.proxy, ssrfBridge)
? await followSsrfRedirects(axiosConfig, {
ssrf: ssrfBridge,
proxyConfig: requestObject.proxy,
agentOptions: buildLegacyAgentOptions(requestObject),
allowedDomains: requestObject.allowedDomains,
sendCredentialsOnCrossOriginRedirect:
requestObject.sendCredentialsOnCrossOriginRedirect ?? true,
authSendImmediately: requestObject.auth?.sendImmediately,
})
: await invokeAxios(axiosConfig, requestObject.auth?.sendImmediately);
let body = response.data;
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
parseIncomingMessage(body);
@@ -20,8 +20,12 @@ 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://');
export function isSupportedProxyUrl(value: string | null | undefined): value is ProxyUrl {
return (
value !== null &&
value !== undefined &&
(value.startsWith('http://') || value.startsWith('https://'))
);
}
/**
@@ -21,6 +21,7 @@ import { buildNodeAgents } from './node-agents';
import {
createDispatcherTransport,
type CustomFetch,
type RequestAuthorizer,
type TransportTimeoutOptions,
} from './undici/transport';
@@ -44,6 +45,13 @@ export interface HttpTransportOptions {
* otherwise hit undici's 5-minute `headersTimeout` / `bodyTimeout`.
*/
timeouts?: TransportTimeoutOptions;
/**
* Optional per-request authorization gate (see {@link RequestAuthorizer}),
* run on every dispatched request including each redirect hop. Throw to block
* a target (e.g. human-in-the-loop domain gating); the SSRF policy still runs
* first.
*/
authorize?: RequestAuthorizer;
}
/**
@@ -187,11 +195,12 @@ export class OutboundHttp {
const proxy = options?.proxy ?? 'env';
const ssrf = options?.ssrf ?? this.ssrfProtection;
const timeouts = options?.timeouts;
const authorize = options?.authorize;
// The dispatcher/fetch half is the DI-free core shared with the
// `@n8n/backend-network/transport` subpath. Only `getNodeAgent` stays here,
// because Node agent construction is not yet dependency-free.
const dispatcherTransport = createDispatcherTransport({ proxy, ssrf, timeouts });
const dispatcherTransport = createDispatcherTransport({ proxy, ssrf, timeouts, authorize });
const lazyNodeAgents = lazy(() => buildNodeAgents(proxy, ssrf));
return {
@@ -10,6 +10,20 @@ import type { ProxyOption, SsrfOption } from '../node-agents';
*/
export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/**
* Per-request authorization gate run against the target of every dispatched request
* (the initial request and every redirect hop).
*
* Resolve to allow the request throug.
* **throw to block it** (the rejection surfaces as the fetch error).
*
* The mechanism lives here. The policy (what to authorize) is the caller's.
*
* Used e.g. for human-in-the-loop domain gating where each redirect
* target must be approved before it is fetched.
*/
export type RequestAuthorizer = (url: URL) => Promise<void>;
/**
* Undici agent timeout overrides for a transport, in milliseconds.
*
@@ -35,6 +49,8 @@ export interface CreateDispatcherTransportOptions {
ssrf?: SsrfOption;
/** Undici agent timeout overrides. */
timeouts?: TransportTimeoutOptions;
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */
authorize?: RequestAuthorizer;
}
/**
@@ -48,21 +64,39 @@ export interface DispatcherTransport {
getDispatcher(): Dispatcher;
}
/** Optional knobs for {@link buildDispatcher}, beyond the required proxy + SSRF policy. */
export interface BuildDispatcherOptions {
/** Undici agent timeout overrides. */
timeouts?: TransportTimeoutOptions;
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check. */
authorize?: RequestAuthorizer;
}
/**
* 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, and a
* connect-time secure DNS lookup is installed for direct connections (see
* {@link buildDispatcherFromProxy}).
* so every dispatched request, including each redirect hop, is validated.
*
* When an {@link RequestAuthorizer} is given it is composed too, gating every dispatched target the same way.
*
* Compose order matters:
* - the SSRF interceptor runs **first**
* - a target that fails the SSRF policy is hard-rejected
*/
export function buildDispatcher(
proxy: ProxyOption,
ssrf: SsrfOption,
timeouts?: TransportTimeoutOptions,
options: BuildDispatcherOptions = {},
): Dispatcher {
const dispatcher = buildDispatcherFromProxy(proxy, ssrf, timeouts);
return ssrf === 'disabled' ? dispatcher : dispatcher.compose(createSsrfInterceptor(ssrf));
let dispatcher = buildDispatcherFromProxy(proxy, ssrf, options?.timeouts);
if (options?.authorize) {
dispatcher = dispatcher.compose(createAuthorizationInterceptor(options?.authorize));
}
if (ssrf !== 'disabled') {
dispatcher = dispatcher.compose(createSsrfInterceptor(ssrf));
}
return dispatcher;
}
function buildDispatcherFromProxy(
@@ -118,8 +152,9 @@ export function createDispatcherTransport(
const proxy = options?.proxy ?? 'env';
const ssrf = options?.ssrf ?? 'disabled';
const timeouts = options?.timeouts;
const authorize = options?.authorize;
const lazyDispatcher = lazyValue(() => buildDispatcher(proxy, ssrf, timeouts));
const lazyDispatcher = lazyValue(() => buildDispatcher(proxy, ssrf, { timeouts, authorize }));
return {
asCustomFetch: () => async (input, init) =>
@@ -170,6 +205,34 @@ export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.Dispatcher
};
}
/**
* undici `compose` interceptor that runs a {@link RequestAuthorizer} against the target URL of every dispatched request.
*
* Like {@link createSsrfInterceptor}, it benefits from `fetch` re-dispatching per redirect hop:
* - the authorizer runs on the initial request
* - **and** every redirect target before that hop is fetched.
*
* The authorizer resolves to allow the request through, or throws to block it (fail-closed).
*/
export function createAuthorizationInterceptor(
authorize: RequestAuthorizer,
): Dispatcher.DispatcherComposeInterceptor {
return (dispatch) => (opts, handler) => {
let targetUrl: URL;
try {
targetUrl = new URL(opts.path, opts.origin?.toString());
authorize(targetUrl).then(
() => dispatch(opts, handler),
(error: unknown) => failDispatch(handler, ensureError(error)),
);
} catch (error: unknown) {
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)
@@ -17,6 +17,7 @@
export {
buildDispatcher,
createSsrfInterceptor,
createAuthorizationInterceptor,
createDispatcherTransport,
dispatchedFetch,
} from './http/undici/transport';
@@ -24,6 +25,7 @@ export type {
CustomFetch,
DispatcherTransport,
CreateDispatcherTransportOptions,
RequestAuthorizer,
TransportTimeoutOptions,
} from './http/undici/transport';
export type { ProxyOption, ProxyUrl, SsrfOption } from './http/node-agents';
@@ -1792,7 +1792,8 @@ export class InstanceAiAdapterService {
const fetchCache = this.webResearchCache;
const searchCacheRef = this.searchCache;
const settingsService = this.settingsService;
const transport = this.outboundHttp.transport({ ssrf: this.ssrfProtectionService });
const { outboundHttp, ssrfProtectionService } = this;
const sharedTransport = outboundHttp.transport({ ssrf: ssrfProtectionService });
const userId = user.id;
// Lazy search method that resolves credentials on first call
@@ -1845,12 +1846,18 @@ export class InstanceAiAdapterService {
return cached;
}
// Fetch and extract — pass authorizeUrl for redirect-hop gating
const authorizeUrl = options?.authorizeUrl;
const transport = authorizeUrl
? outboundHttp.transport({
ssrf: ssrfProtectionService,
authorize: async (target: URL) => await authorizeUrl(target.href),
})
: sharedTransport;
const page = await fetchAndExtract(url, {
maxContentLength: options?.maxContentLength,
maxResponseBytes: options?.maxResponseBytes,
timeoutMs: options?.timeoutMs,
authorizeUrl: options?.authorizeUrl,
transport,
});
@@ -223,46 +223,30 @@ describe('fetchAndExtract', () => {
expect(result.contentLength).toBe(0);
});
it('follows redirects manually, authorizing each hop before fetching it', async () => {
it('lets the transport follow redirects and reports its final URL', async () => {
const html = '<html><body><p>Hi</p></body></html>';
const transportFetch = jest
.fn()
.mockResolvedValueOnce(
createMockResponse('', { status: 301, location: 'https://final.example.com/page' }),
)
.mockResolvedValueOnce(createMockResponse(html));
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(transportFetch);
const { transport, transportFetch } = mockTransport(async () =>
createMockResponse(html, { url: 'https://final.example.com/page' }),
);
const authorizeUrl = jest.fn().mockResolvedValue(undefined);
const result = await fetchAndExtract('https://example.com', { transport });
const result = await fetchAndExtract('https://example.com', {
transport,
authorizeUrl,
});
expect(authorizeUrl).toHaveBeenCalledWith('https://final.example.com/page');
expect(transportFetch).toHaveBeenCalledTimes(2);
expect(transportFetch).toHaveBeenCalledTimes(1);
expect(transportFetch).toHaveBeenCalledWith(
'https://example.com',
expect.objectContaining({ redirect: 'follow' }),
);
expect(result.finalUrl).toBe('https://final.example.com/page');
});
it('aborts before fetching a redirect target that authorizeUrl rejects', async () => {
const transportFetch = jest
.fn()
.mockResolvedValueOnce(
createMockResponse('', { status: 301, location: 'https://blocked.example.com/page' }),
);
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(transportFetch);
it('surfaces the root cause when the transport rejects a hop', async () => {
const { transport } = mockTransport(async () => {
throw new TypeError('fetch failed', { cause: new Error('Access blocked') });
});
const authorizeUrl = jest.fn().mockRejectedValue(new Error('Access blocked'));
await expect(
fetchAndExtract('https://example.com', { transport, authorizeUrl }),
).rejects.toThrow('Access blocked');
// The redirect target is never fetched — only the initial request was made.
expect(transportFetch).toHaveBeenCalledTimes(1);
await expect(fetchAndExtract('https://example.com', { transport })).rejects.toThrow(
'Access blocked',
);
});
it('does not deadlock when the response body streams in chunks after fetch resolves', async () => {
@@ -341,9 +325,13 @@ describe('fetchAndExtract', () => {
}
// Builds a real transport (the wiring the adapter service performs) so the
// SSRF interceptor runs for real against the redirecting server below.
function realTransport(ssrf: SsrfOption) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport({ ssrf });
// SSRF (and optional authorize) interceptors run for real against the
// redirecting server below.
function realTransport(ssrf: SsrfOption, authorize?: (url: URL) => Promise<void>) {
return new OutboundHttp(mock<SsrfProtectionService>(), mock<Logger>()).transport({
ssrf,
authorize,
});
}
function makeBridge(blockedPath?: string): jest.Mocked<SsrfBridge> {
@@ -409,18 +397,17 @@ describe('fetchAndExtract', () => {
it('authorizes the redirect target before it is fetched', async () => {
const ssrf = makeBridge();
const authorizeUrl = jest.fn(async (target: string) => {
if (target.includes('/internal')) throw new Error('Redirect not allowed');
const authorize = jest.fn(async (target: URL) => {
if (target.href.includes('/internal')) throw new Error('Redirect not allowed');
});
await expect(
fetchAndExtract(`${server.url}/start`, {
transport: realTransport(ssrf),
authorizeUrl,
transport: realTransport(ssrf, authorize),
}),
).rejects.toThrow('Redirect not allowed');
expect(authorizeUrl).toHaveBeenCalledWith(`${server.url}/internal`);
expect(authorize).toHaveBeenCalledWith(validatedUrl(`${server.url}/internal`));
// The redirect target is gated before any request reaches it.
expect(server.captured).toEqual(['/start']);
});
@@ -46,30 +46,34 @@ const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_TIMEOUT_MS = 120_000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MB
const DEFAULT_MAX_CONTENT_LENGTH = 30_000;
const MAX_REDIRECTS = 10;
export interface FetchAndExtractOptions {
maxContentLength?: number;
maxResponseBytes?: number;
timeoutMs?: number;
/**
* Called before following each redirect hop to validate the target URL.
* Throw to abort the fetch (e.g. for HITL domain approval).
*/
authorizeUrl?: (url: string) => Promise<void>;
/**
* SSRF-validated fetch transport.
*/
transport: HttpTransport;
}
/**
* undici's `fetch` reports a failure raised inside dispatch
* as an opaque `TypeError: fetch failed` with the real error on `.cause`.
*/
function unwrapFetchError(error: unknown): unknown {
let current = error;
const seen = new Set<unknown>();
while (current instanceof Error && current.cause instanceof Error && !seen.has(current)) {
seen.add(current);
current = current.cause;
}
return current;
}
/**
* Fetch a URL, extract its main content, and convert to markdown.
* Routes by content-type: HTML → Readability + Turndown, PDF → pdf-parse, text → passthrough.
*
* The factory transport runs SSRF validation per dispatched request, so the
* initial fetch and every redirect hop are checked — closing open-redirect
* chains to internal/private addresses.
*/
export async function fetchAndExtract(
url: string,
@@ -79,61 +83,30 @@ export async function fetchAndExtract(
const maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BYTES;
const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
const { authorizeUrl, transport } = options;
const customFetch = options.transport.asCustomFetch();
const customFetch = transport.asCustomFetch();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let currentUrl = url;
let response!: Response;
let redirectCount = 0;
while (redirectCount <= MAX_REDIRECTS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
response = await customFetch(currentUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'n8n-instance-ai/1.0 (content extraction)',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,application/pdf;q=0.7,*/*;q=0.5',
},
redirect: 'manual',
});
} finally {
clearTimeout(timeout);
}
// Follow redirects manually so each hop can be authorized before it is fetched
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) break;
// Release the redirect response's connection back to the pool.
await response.body?.cancel().catch(() => {});
redirectCount++;
if (redirectCount > MAX_REDIRECTS) {
throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
}
// Resolve relative redirect URLs against the current URL
currentUrl = new URL(location, currentUrl).href;
// Domain-access authorization for the redirect target.
// SSRF for the target is enforced by the transport on the next dispatch.
if (authorizeUrl) {
await authorizeUrl(currentUrl);
}
continue;
}
break;
let response: Response;
try {
response = await customFetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'n8n-instance-ai/1.0 (content extraction)',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,application/pdf;q=0.7,*/*;q=0.5',
},
redirect: 'follow',
});
} catch (error) {
throw unwrapFetchError(error);
} finally {
clearTimeout(timeout);
}
const finalUrl = currentUrl;
// `redirect: 'follow'` resolves to the final, non-redirect response.
const finalUrl = response.url || url;
if (!response.ok) {
// Release the connection back to the pool — we don't read the error body.