diff --git a/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts new file mode 100644 index 00000000000..6fcaf480541 --- /dev/null +++ b/packages/@n8n/backend-network/src/http/__tests__/outbound-http.authorization.test.ts @@ -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; + onError: ReturnType; + }; +} + +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().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().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().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().mockRejectedValue(error); + const { dispatch } = makeInterceptedDispatch(authorize); + const handler = { onError: vi.fn() } as unknown as Dispatcher.DispatchHandler & { + onError: ReturnType; + }; + + 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 { + 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[0]) { + return new OutboundHttp(mock(), mock()).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(); + 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().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(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().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'); + }); +}); diff --git a/packages/@n8n/backend-network/src/http/__tests__/transport-timeouts.test.ts b/packages/@n8n/backend-network/src/http/__tests__/transport-timeouts.test.ts index bcfa923845b..9d35692c1ea 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/transport-timeouts.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/transport-timeouts.test.ts @@ -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(); }); }); diff --git a/packages/@n8n/backend-network/src/http/axios/__tests__/invoke.test.ts b/packages/@n8n/backend-network/src/http/axios/__tests__/invoke.test.ts new file mode 100644 index 00000000000..16915e659da --- /dev/null +++ b/packages/@n8n/backend-network/src/http/axios/__tests__/invoke.test.ts @@ -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 }); + }); +}); diff --git a/packages/@n8n/backend-network/src/http/axios/__tests__/request.test.ts b/packages/@n8n/backend-network/src/http/axios/__tests__/request.test.ts index b1ae4a61894..86882cd75da 100644 --- a/packages/@n8n/backend-network/src/http/axios/__tests__/request.test.ts +++ b/packages/@n8n/backend-network/src/http/axios/__tests__/request.test.ts @@ -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', diff --git a/packages/@n8n/backend-network/src/http/axios/__tests__/ssrf-redirect.test.ts b/packages/@n8n/backend-network/src/http/axios/__tests__/ssrf-redirect.test.ts new file mode 100644 index 00000000000..fc730c9e19a --- /dev/null +++ b/packages/@n8n/backend-network/src/http/axios/__tests__/ssrf-redirect.test.ts @@ -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 { + 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 = {}; + + 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()), + ).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(), + ); + + 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 = {}; + + 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'); + }); + }); +}); diff --git a/packages/@n8n/backend-network/src/http/axios/__tests__/utils.test.ts b/packages/@n8n/backend-network/src/http/axios/__tests__/utils.test.ts index 154ef300081..533487b3803 100644 --- a/packages/@n8n/backend-network/src/http/axios/__tests__/utils.test.ts +++ b/packages/@n8n/backend-network/src/http/axios/__tests__/utils.test.ts @@ -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'); + }); +}); diff --git a/packages/@n8n/backend-network/src/http/axios/invoke.ts b/packages/@n8n/backend-network/src/http/axios/invoke.ts new file mode 100644 index 00000000000..b4d38b7aa5c --- /dev/null +++ b/packages/@n8n/backend-network/src/http/axios/invoke.ts @@ -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); + } +} diff --git a/packages/@n8n/backend-network/src/http/axios/legacy.ts b/packages/@n8n/backend-network/src/http/axios/legacy.ts index 24bad5fb7e3..c9a41d73a93 100644 --- a/packages/@n8n/backend-network/src/http/axios/legacy.ts +++ b/packages/@n8n/backend-network/src/http/axios/legacy.ts @@ -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; diff --git a/packages/@n8n/backend-network/src/http/axios/redirect.ts b/packages/@n8n/backend-network/src/http/axios/redirect.ts new file mode 100644 index 00000000000..9986c1ba369 --- /dev/null +++ b/packages/@n8n/backend-network/src/http/axios/redirect.ts @@ -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 { + 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 { + 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; + } +} diff --git a/packages/@n8n/backend-network/src/http/axios/request.ts b/packages/@n8n/backend-network/src/http/axios/request.ts index a8168e2ffe5..73f40fedabd 100644 --- a/packages/@n8n/backend-network/src/http/axios/request.ts +++ b/packages/@n8n/backend-network/src/http/axios/request.ts @@ -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 { diff --git a/packages/@n8n/backend-network/src/http/axios/utils.ts b/packages/@n8n/backend-network/src/http/axios/utils.ts index 5de4f92133e..d3a85aa20a7 100644 --- a/packages/@n8n/backend-network/src/http/axios/utils.ts +++ b/packages/@n8n/backend-network/src/http/axios/utils.ts @@ -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 | 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; diff --git a/packages/@n8n/backend-network/src/http/http-proxy.ts b/packages/@n8n/backend-network/src/http/http-proxy.ts index 004c50daffa..b92461ae185 100644 --- a/packages/@n8n/backend-network/src/http/http-proxy.ts +++ b/packages/@n8n/backend-network/src/http/http-proxy.ts @@ -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, ); } diff --git a/packages/@n8n/backend-network/src/http/index.ts b/packages/@n8n/backend-network/src/http/index.ts index 015c376d835..46232f61a37 100644 --- a/packages/@n8n/backend-network/src/http/index.ts +++ b/packages/@n8n/backend-network/src/http/index.ts @@ -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, diff --git a/packages/@n8n/backend-network/src/http/legacy-request.ts b/packages/@n8n/backend-network/src/http/legacy-request.ts index b8cf8429c1a..971f64cee8c 100644 --- a/packages/@n8n/backend-network/src/http/legacy-request.ts +++ b/packages/@n8n/backend-network/src/http/legacy-request.ts @@ -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); diff --git a/packages/@n8n/backend-network/src/http/node-agents.ts b/packages/@n8n/backend-network/src/http/node-agents.ts index 4bf263c354b..f2daa984351 100644 --- a/packages/@n8n/backend-network/src/http/node-agents.ts +++ b/packages/@n8n/backend-network/src/http/node-agents.ts @@ -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://')) + ); } /** diff --git a/packages/@n8n/backend-network/src/http/outbound-http.ts b/packages/@n8n/backend-network/src/http/outbound-http.ts index a10ac1c7eee..174afd429d0 100644 --- a/packages/@n8n/backend-network/src/http/outbound-http.ts +++ b/packages/@n8n/backend-network/src/http/outbound-http.ts @@ -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 { diff --git a/packages/@n8n/backend-network/src/http/undici/transport.ts b/packages/@n8n/backend-network/src/http/undici/transport.ts index 94e195c31c0..d083773f884 100644 --- a/packages/@n8n/backend-network/src/http/undici/transport.ts +++ b/packages/@n8n/backend-network/src/http/undici/transport.ts @@ -10,6 +10,20 @@ import type { ProxyOption, SsrfOption } from '../node-agents'; */ export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; +/** + * 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; + /** * 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) diff --git a/packages/@n8n/backend-network/src/transport.ts b/packages/@n8n/backend-network/src/transport.ts index 28b00338a11..a7d3710bfff 100644 --- a/packages/@n8n/backend-network/src/transport.ts +++ b/packages/@n8n/backend-network/src/transport.ts @@ -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'; diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index f6df5a29778..68324a196e3 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -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, }); diff --git a/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts b/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts index 74926009929..10e082a394a 100644 --- a/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts +++ b/packages/cli/src/modules/instance-ai/web-research/__tests__/fetch-and-extract.test.ts @@ -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 = '

Hi

'; - const transportFetch = jest - .fn() - .mockResolvedValueOnce( - createMockResponse('', { status: 301, location: 'https://final.example.com/page' }), - ) - .mockResolvedValueOnce(createMockResponse(html)); - const transport = mock(); - 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(); - 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(), mock()).transport({ ssrf }); + // SSRF (and optional authorize) interceptors run for real against the + // redirecting server below. + function realTransport(ssrf: SsrfOption, authorize?: (url: URL) => Promise) { + return new OutboundHttp(mock(), mock()).transport({ + ssrf, + authorize, + }); } function makeBridge(blockedPath?: string): jest.Mocked { @@ -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']); }); diff --git a/packages/cli/src/modules/instance-ai/web-research/fetch-and-extract.ts b/packages/cli/src/modules/instance-ai/web-research/fetch-and-extract.ts index f0ad64bd28c..0c6208ce549 100644 --- a/packages/cli/src/modules/instance-ai/web-research/fetch-and-extract.ts +++ b/packages/cli/src/modules/instance-ai/web-research/fetch-and-extract.ts @@ -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; /** * 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(); + 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.