mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+28
-41
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user