From 68bcdfe80c68387878e4710ee8eeb491bee35c8e Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Tue, 11 Aug 2026 09:55:13 +0200 Subject: [PATCH] fix(core): Apply TLS options per hop when requests go through a proxy (#35518) --- .../src/http/__tests__/node-agents.test.ts | 16 +++ .../src/http/axios/__tests__/legacy.test.ts | 10 ++ .../src/http/axios/__tests__/utils.test.ts | 10 ++ .../backend-network/src/http/axios/legacy.ts | 7 +- .../src/http/axios/redirect.ts | 4 +- .../backend-network/src/http/axios/utils.ts | 27 +++- .../src/http/env-proxy-http-agent.ts | 9 +- .../src/http/env-proxy-https-agent.ts | 9 +- .../backend-network/src/http/node-agents.ts | 11 +- .../proxy/__tests__/proxied-agents.test.ts | 123 ++++++++++++++++++ .../src/proxy/proxied-agents.ts | 96 ++++++++++++++ .../src/proxy/proxy-resolution.ts | 8 +- .../client-oauth2/test/client-oauth2.test.ts | 10 +- 13 files changed, 309 insertions(+), 31 deletions(-) create mode 100644 packages/@n8n/backend-network/src/proxy/__tests__/proxied-agents.test.ts create mode 100644 packages/@n8n/backend-network/src/proxy/proxied-agents.ts diff --git a/packages/@n8n/backend-network/src/http/__tests__/node-agents.test.ts b/packages/@n8n/backend-network/src/http/__tests__/node-agents.test.ts index 14c003dae78..fdf3778e1cc 100644 --- a/packages/@n8n/backend-network/src/http/__tests__/node-agents.test.ts +++ b/packages/@n8n/backend-network/src/http/__tests__/node-agents.test.ts @@ -68,6 +68,22 @@ describe('buildNodeAgents', () => { expect(getAgentOptions(httpAgent).keepAlive).toBe(true); }); + + it('proxy: explicit URL → keeps the target TLS options off the proxy connection', () => { + const agents = buildNodeAgents('https://proxy.internal:3128', 'disabled', { + servername: 'target.example.com', + ca: 'TARGET_CA', + rejectUnauthorized: false, + }); + + for (const agent of [agents.httpAgent, agents.httpsAgent]) { + const { connectOpts } = agent as unknown as { connectOpts: Record }; + expect(connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 }); + expect(connectOpts).not.toHaveProperty('servername'); + expect(connectOpts).not.toHaveProperty('ca'); + expect(connectOpts).not.toHaveProperty('rejectUnauthorized'); + } + }); }); describe('rejects a caller-provided lookup (managed by the SSRF policy)', () => { diff --git a/packages/@n8n/backend-network/src/http/axios/__tests__/legacy.test.ts b/packages/@n8n/backend-network/src/http/axios/__tests__/legacy.test.ts index ec1cc612f80..73f8fee2113 100644 --- a/packages/@n8n/backend-network/src/http/axios/__tests__/legacy.test.ts +++ b/packages/@n8n/backend-network/src/http/axios/__tests__/legacy.test.ts @@ -165,6 +165,16 @@ describe('buildAxiosConfigFromLegacyRequest', () => { expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toEqual('example.de'); }); + // SNI takes a hostname, never an IP literal (RFC 6066 §3). + test.each([ + ['IPv4', 'https://185.90.154.8/foo'], + ['IPv6', 'https://[2001:db8::1]/foo'], + ])('should set no SNI when the host is an %s literal', async (_label, url) => { + const axiosOptions = await buildAxiosConfigFromLegacyRequest({ url }); + + expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toBeUndefined(); + }); + describe('should set SSL certificates', () => { const agentOptions: SecureContextOptions = { ca: TEST_CA_CERT, 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 7955193c705..94f57ce63d2 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 @@ -701,4 +701,14 @@ describe('buildAgentOptions', () => { expect(options.keepAlive).toBe(true); expect(options.servername).toBe('api.example.com'); }); + + // SNI takes a hostname, never an IP literal (RFC 6066 §3). + it.each([ + ['IPv4', 'https://185.90.154.8/v1'], + ['IPv6', 'https://[2001:db8::1]/v1'], + ])('sets no servername when the host is an %s literal', (_label, url) => { + const options = buildAgentOptions({ method: 'GET', url }); + + expect(options.servername).toBeUndefined(); + }); }); diff --git a/packages/@n8n/backend-network/src/http/axios/legacy.ts b/packages/@n8n/backend-network/src/http/axios/legacy.ts index 392efe6bc3d..4fcc08e7721 100644 --- a/packages/@n8n/backend-network/src/http/axios/legacy.ts +++ b/packages/@n8n/backend-network/src/http/axios/legacy.ts @@ -21,6 +21,7 @@ import { resolveLegacyRequestTarget, searchForHeader, setAxiosAgents, + sniFor, } from './utils'; import type { SsrfBridge } from '../../ssrf'; @@ -32,10 +33,10 @@ import type { SsrfBridge } from '../../ssrf'; * @deprecated Backs the deprecated `request` helpers. */ export function buildLegacyAgentOptions(requestObject: IRequestOptions): AgentOptions { - const host = getHostFromRequestObject(requestObject); + const servername = sniFor(getHostFromRequestObject(requestObject)); const agentOptions: AgentOptions = { ...requestObject.agentOptions }; - if (host) { - agentOptions.servername = host; + if (servername) { + agentOptions.servername = servername; } if (requestObject.rejectUnauthorized === false) { agentOptions.rejectUnauthorized = false; diff --git a/packages/@n8n/backend-network/src/http/axios/redirect.ts b/packages/@n8n/backend-network/src/http/axios/redirect.ts index 9986c1ba369..49d73344062 100644 --- a/packages/@n8n/backend-network/src/http/axios/redirect.ts +++ b/packages/@n8n/backend-network/src/http/axios/redirect.ts @@ -11,6 +11,7 @@ import { isProxyPotentiallyActive, isRedirectStatus, resolveProxyOption, + sniFor, throwIfDomainNotAllowed, tryParseUrl, validateUrlSsrf, @@ -120,12 +121,11 @@ 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 } : {}), + servername: sniFor(tryParseUrl(nextUrl)?.hostname), }); } diff --git a/packages/@n8n/backend-network/src/http/axios/utils.ts b/packages/@n8n/backend-network/src/http/axios/utils.ts index ee3cf24cbfb..32d9686d2e6 100644 --- a/packages/@n8n/backend-network/src/http/axios/utils.ts +++ b/packages/@n8n/backend-network/src/http/axios/utils.ts @@ -11,6 +11,7 @@ import { type IRequestOptions, type IgnoreStatusErrorConfig, } from 'n8n-workflow'; +import net from 'node:net'; import { hasProxyEnvironmentVariables } from '../../proxy/proxy-resolution'; import type { SsrfBridge } from '../../ssrf'; @@ -57,6 +58,24 @@ export function searchForHeader(config: AxiosRequestConfig, headerName: string) return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); } +/** + * The SNI to announce when connecting to `host`. + * + * @returns the host, or `undefined` when it carries no name to announce: SNI takes a + * hostname and never an IP literal (RFC 6066 §3), and Node ignores IP values. + */ +export function sniFor(host: string | null | undefined): string | undefined { + if (!host) { + return undefined; + } + // A URL hostname keeps the brackets of an IPv6 literal; `net.isIP` expects it bare. + const unbracketed = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + if (net.isIP(unbracketed)) { + return undefined; + } + return host; +} + /** Extracts the hostname from a request object's URL or URI. */ export const getHostFromRequestObject = ( requestObject: Partial<{ @@ -115,7 +134,7 @@ export const getBeforeRedirectFn = const redirectAgentOptions: AgentOptions = { ...agentOptions, - servername: redirectedRequest.hostname, + servername: sniFor(redirectedRequest.hostname as string | undefined), }; const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null; const proxy = resolveProxyOption(customProxyUrl); @@ -248,10 +267,10 @@ export function isFormDataInstance(data: unknown): data is FormData { * 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 servername = sniFor(getHostFromRequestObject(n8nRequest)); const agentOptions: AgentOptions = { ...n8nRequest.agentOptions }; - if (host) { - agentOptions.servername = host; + if (servername) { + agentOptions.servername = servername; } if (n8nRequest.skipSslCertificateValidation === true) { agentOptions.rejectUnauthorized = false; diff --git a/packages/@n8n/backend-network/src/http/env-proxy-http-agent.ts b/packages/@n8n/backend-network/src/http/env-proxy-http-agent.ts index 1648e6a4d83..516cd5376c5 100644 --- a/packages/@n8n/backend-network/src/http/env-proxy-http-agent.ts +++ b/packages/@n8n/backend-network/src/http/env-proxy-http-agent.ts @@ -1,9 +1,10 @@ -import { HttpProxyAgent } from 'http-proxy-agent'; +import type { HttpProxyAgent } from 'http-proxy-agent'; import http from 'node:http'; import type { LookupFunction } from 'node:net'; import { EnvProxyRouter } from './env-proxy-router'; import type { NodeAgentOptions } from './node-agents'; +import { createProxiedHttpAgent } from '../proxy/proxied-agents'; type HttpAddRequestArgs = Parameters['addRequest']>; type HttpProxyClientReq = HttpAddRequestArgs[0]; @@ -22,10 +23,8 @@ export class EnvProxyHttpAgent extends http.Agent { constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) { super({ ...agentOptions, lookup }); - this.router = new EnvProxyRouter( - 'http', - 80, - (proxyUrl) => new HttpProxyAgent(proxyUrl, { ...agentOptions }), + this.router = new EnvProxyRouter('http', 80, (proxyUrl) => + createProxiedHttpAgent(proxyUrl, agentOptions), ); } diff --git a/packages/@n8n/backend-network/src/http/env-proxy-https-agent.ts b/packages/@n8n/backend-network/src/http/env-proxy-https-agent.ts index e457ef03441..d2a17fb3f30 100644 --- a/packages/@n8n/backend-network/src/http/env-proxy-https-agent.ts +++ b/packages/@n8n/backend-network/src/http/env-proxy-https-agent.ts @@ -1,10 +1,11 @@ -import { HttpsProxyAgent } from 'https-proxy-agent'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; import type http from 'node:http'; import https from 'node:https'; import type { LookupFunction } from 'node:net'; import { EnvProxyRouter } from './env-proxy-router'; import type { NodeAgentOptions } from './node-agents'; +import { createProxiedHttpsAgent } from '../proxy/proxied-agents'; type HttpsProxyReqOpts = Parameters['addRequest']>[1]; @@ -16,10 +17,8 @@ export class EnvProxyHttpsAgent extends https.Agent { constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) { super({ ...agentOptions, lookup }); - this.router = new EnvProxyRouter( - 'https', - 443, - (proxyUrl) => new HttpsProxyAgent(proxyUrl, { ...agentOptions }), + this.router = new EnvProxyRouter('https', 443, (proxyUrl) => + createProxiedHttpsAgent(proxyUrl, agentOptions), ); } diff --git a/packages/@n8n/backend-network/src/http/node-agents.ts b/packages/@n8n/backend-network/src/http/node-agents.ts index f2daa984351..a0f48e8583e 100644 --- a/packages/@n8n/backend-network/src/http/node-agents.ts +++ b/packages/@n8n/backend-network/src/http/node-agents.ts @@ -1,5 +1,3 @@ -import { HttpProxyAgent } from 'http-proxy-agent'; -import { HttpsProxyAgent } from 'https-proxy-agent'; import { UnexpectedError } from 'n8n-workflow'; import http from 'node:http'; import https from 'node:https'; @@ -7,6 +5,7 @@ import type { LookupFunction } from 'node:net'; import { EnvProxyHttpAgent } from './env-proxy-http-agent'; import { EnvProxyHttpsAgent } from './env-proxy-https-agent'; +import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents'; import type { SsrfBridge } from '../ssrf'; /** @@ -63,6 +62,10 @@ export type NodeAgentOptions = https.AgentOptions; * always overrides anything in `agentOptions`. Passing `agentOptions.lookup` * therefore has no effect and is rejected to avoid a false sense of control over * DNS resolution. + * + * `agentOptions` describes the connection to the **target**. Behind a proxy its TLS + * options travel with the tunnelled session rather than the proxy handshake + * (see `proxy/proxied-agents.ts`). */ export function buildNodeAgents( proxy: ProxyOption, @@ -100,8 +103,8 @@ export function buildNodeAgents( // Explicit proxy URL. No direct path, so no SSRF lookup is injected. return { - httpAgent: new HttpProxyAgent(proxy as string, { ...agentOptions }), - httpsAgent: new HttpsProxyAgent(proxy as string, { ...agentOptions }), + httpAgent: createProxiedHttpAgent(proxy, agentOptions), + httpsAgent: createProxiedHttpsAgent(proxy, agentOptions), }; } diff --git a/packages/@n8n/backend-network/src/proxy/__tests__/proxied-agents.test.ts b/packages/@n8n/backend-network/src/proxy/__tests__/proxied-agents.test.ts new file mode 100644 index 00000000000..236b6379482 --- /dev/null +++ b/packages/@n8n/backend-network/src/proxy/__tests__/proxied-agents.test.ts @@ -0,0 +1,123 @@ +import { HttpsProxyAgent } from 'https-proxy-agent'; +import type http from 'node:http'; +import type https from 'node:https'; +import type net from 'node:net'; +import type { MockInstance } from 'vitest'; + +import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxied-agents'; + +const PROXY_URL = 'https://proxy.internal:3128'; + +const TARGET_TLS: https.AgentOptions = { + servername: 'target.example.com', + ca: 'TARGET_CA', + cert: 'TARGET_CERT', + key: 'TARGET_KEY', + passphrase: 'TARGET_PASSPHRASE', +}; + +type ConnectOpts = Parameters['connect']>[1]; + +const targetRequestOpts = { + host: 'target.example.com', + port: 443, + secureEndpoint: true, +} as ConnectOpts; + +describe('createProxiedHttpsAgent', () => { + it('keeps the target SNI and trust material off the connection to the proxy', () => { + const agent = createProxiedHttpsAgent(PROXY_URL, TARGET_TLS); + + expect(agent.connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 }); + expect(agent.connectOpts).not.toHaveProperty('servername'); + expect(agent.connectOpts).not.toHaveProperty('ca'); + expect(agent.connectOpts).not.toHaveProperty('cert'); + expect(agent.connectOpts).not.toHaveProperty('key'); + expect(agent.connectOpts).not.toHaveProperty('passphrase'); + }); + + it('verifies the proxy certificate even when the request opts out of verification', () => { + const agent = createProxiedHttpsAgent(PROXY_URL, { + ...TARGET_TLS, + rejectUnauthorized: false, + secureOptions: 4, + }); + + expect(agent.connectOpts).not.toHaveProperty('rejectUnauthorized'); + expect(agent.connectOpts).not.toHaveProperty('secureOptions'); + }); + + it('keeps a target TLS policy from reshaping the proxy handshake', () => { + const agent = createProxiedHttpsAgent(PROXY_URL, { + ciphers: 'TARGET_CIPHERS', + minVersion: 'TLSv1.1', + checkServerIdentity: () => undefined, + }); + + expect(agent.connectOpts).not.toHaveProperty('ciphers'); + expect(agent.connectOpts).not.toHaveProperty('minVersion'); + expect(agent.connectOpts).not.toHaveProperty('checkServerIdentity'); + }); + + it('forwards pool and socket options to the connection to the proxy', () => { + const agent = createProxiedHttpsAgent(PROXY_URL, { keepAlive: true, timeout: 5000 }); + + expect(agent.connectOpts).toMatchObject({ keepAlive: true, timeout: 5000 }); + }); + + describe('tunnelled session', () => { + let parentConnect: MockInstance['connect']>; + + beforeEach(() => { + parentConnect = vi + .spyOn(HttpsProxyAgent.prototype, 'connect') + .mockResolvedValue({} as net.Socket); + }); + + afterEach(() => parentConnect.mockRestore()); + + it('carries the target trust material', async () => { + const agent = createProxiedHttpsAgent(PROXY_URL, { + ...TARGET_TLS, + rejectUnauthorized: false, + }); + + await agent.connect({} as http.ClientRequest, targetRequestOpts); + + expect(parentConnect).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + host: 'target.example.com', + port: 443, + secureEndpoint: true, + ca: 'TARGET_CA', + cert: 'TARGET_CERT', + key: 'TARGET_KEY', + passphrase: 'TARGET_PASSPHRASE', + rejectUnauthorized: false, + }), + ); + }); + + it('leaves the SNI to be derived from the host of each hop', async () => { + const agent = createProxiedHttpsAgent(PROXY_URL, TARGET_TLS); + + await agent.connect({} as http.ClientRequest, targetRequestOpts); + + expect(parentConnect.mock.calls[0][1]).not.toHaveProperty('servername'); + }); + }); +}); + +describe('createProxiedHttpAgent', () => { + it('keeps the target SNI and trust material off the connection to the proxy', () => { + const agent = createProxiedHttpAgent(PROXY_URL, TARGET_TLS); + + expect(agent.connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 }); + expect(agent.connectOpts).not.toHaveProperty('servername'); + expect(agent.connectOpts).not.toHaveProperty('ca'); + expect(agent.connectOpts).not.toHaveProperty('cert'); + expect(agent.connectOpts).not.toHaveProperty('key'); + expect(agent.connectOpts).not.toHaveProperty('passphrase'); + }); +}); diff --git a/packages/@n8n/backend-network/src/proxy/proxied-agents.ts b/packages/@n8n/backend-network/src/proxy/proxied-agents.ts new file mode 100644 index 00000000000..7ebd62b7115 --- /dev/null +++ b/packages/@n8n/backend-network/src/proxy/proxied-agents.ts @@ -0,0 +1,96 @@ +import { HttpProxyAgent } from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import type http from 'node:http'; +import type https from 'node:https'; +import type net from 'node:net'; + +/** Per-hop connect options of a proxy agent, as `agent-base` hands them to `connect()`. */ +type ProxyConnectOpts = Parameters['connect']>[1]; + +/** + * How a socket is opened and pooled, as opposed to how the peer on the other end is + * authenticated. Every TLS option in a request describes the target, including the + * caller's willingness to accept a certificate it cannot verify, so a proxy borrows + * these and nothing else: it is a different peer, and its certificate is always + * verified against the proxy hostname. A private proxy CA is trusted through the + * process trust store (`NODE_EXTRA_CA_CERTS`), not through a per-request option. + */ +const CONNECTION_OPTION_KEYS = [ + 'keepAlive', + 'keepAliveMsecs', + 'keepAliveInitialDelay', + 'maxSockets', + 'maxTotalSockets', + 'maxFreeSockets', + 'scheduling', + 'timeout', + 'noDelay', + 'family', + 'hints', + 'localAddress', + 'localPort', + 'lookup', + 'autoSelectFamily', + 'autoSelectFamilyAttemptTimeout', +] as const satisfies ReadonlyArray; + +function pickKeys(agentOptions: https.AgentOptions, keys: readonly string[]): https.AgentOptions { + return Object.fromEntries( + Object.entries(agentOptions).filter(([key]) => keys.includes(key)), + ) as https.AgentOptions; +} + +function omitKeys(agentOptions: https.AgentOptions, keys: readonly string[]): https.AgentOptions { + return Object.fromEntries( + Object.entries(agentOptions).filter(([key]) => !keys.includes(key)), + ) as https.AgentOptions; +} + +function forProxyConnection(agentOptions: https.AgentOptions = {}): https.AgentOptions { + return pickKeys(agentOptions, CONNECTION_OPTION_KEYS); +} + +function forTunnelledConnection(agentOptions: https.AgentOptions = {}): https.AgentOptions { + return omitKeys(agentOptions, ['servername']); +} + +class TunnellingHttpsProxyAgent extends HttpsProxyAgent { + private readonly tunnelOptions: https.AgentOptions; + + constructor(proxyUrl: string, agentOptions?: https.AgentOptions) { + super(proxyUrl, forProxyConnection(agentOptions)); + this.tunnelOptions = forTunnelledConnection(agentOptions); + } + + async connect(req: http.ClientRequest, opts: ProxyConnectOpts): Promise { + return await super.connect(req, { ...opts, ...this.tunnelOptions }); + } +} + +/** + * Creates the agent routing plain-HTTP targets through `proxyUrl`. + * Such a target has no TLS session of its own, so its TLS options are dropped. + * + * @param agentOptions options describing the connection to the **target**; only its + * connection-management options reach the proxy + */ +export function createProxiedHttpAgent( + proxyUrl: string, + agentOptions?: https.AgentOptions, +): HttpProxyAgent { + return new HttpProxyAgent(proxyUrl, forProxyConnection(agentOptions)); +} + +/** + * Creates the agent tunnelling HTTPS targets through `proxyUrl`, applying the + * target's TLS options to the tunnelled session rather than to the proxy handshake. + * + * @param agentOptions options describing the connection to the **target**; only its + * connection-management options reach the proxy + */ +export function createProxiedHttpsAgent( + proxyUrl: string, + agentOptions?: https.AgentOptions, +): HttpsProxyAgent { + return new TunnellingHttpsProxyAgent(proxyUrl, agentOptions); +} diff --git a/packages/@n8n/backend-network/src/proxy/proxy-resolution.ts b/packages/@n8n/backend-network/src/proxy/proxy-resolution.ts index b0c38eaed58..5116e3146e4 100644 --- a/packages/@n8n/backend-network/src/proxy/proxy-resolution.ts +++ b/packages/@n8n/backend-network/src/proxy/proxy-resolution.ts @@ -1,9 +1,9 @@ import http from 'http'; -import { HttpProxyAgent } from 'http-proxy-agent'; import https from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; import { getProxyForUrl } from 'proxy-from-env'; +import { createProxiedHttpAgent, createProxiedHttpsAgent } from './proxied-agents'; + /** * Resolves the proxy URL configured via environment variables * (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) for a given target URL. @@ -40,7 +40,7 @@ export function createHttpProxyAgent( const proxyUrl = customProxyUrl ?? getProxyForUrl(targetUrl); if (proxyUrl) { - return new HttpProxyAgent(proxyUrl, options); + return createProxiedHttpAgent(proxyUrl, options); } return new http.Agent(options); @@ -62,7 +62,7 @@ export function createHttpsProxyAgent( const proxyUrl = customProxyUrl ?? getProxyForUrl(targetUrl); if (proxyUrl) { - return new HttpsProxyAgent(proxyUrl, options); + return createProxiedHttpsAgent(proxyUrl, options); } return new https.Agent(options); diff --git a/packages/@n8n/client-oauth2/test/client-oauth2.test.ts b/packages/@n8n/client-oauth2/test/client-oauth2.test.ts index e796d891d4f..bc0ae8f74c3 100644 --- a/packages/@n8n/client-oauth2/test/client-oauth2.test.ts +++ b/packages/@n8n/client-oauth2/test/client-oauth2.test.ts @@ -296,7 +296,7 @@ describe('ClientOAuth2', () => { expect(httpsAgent.options.rejectUnauthorized).toBe(false); }); - it('should route through an https proxy agent with relaxed TLS when HTTPS_PROXY is set', async () => { + it('should route through an https proxy agent when HTTPS_PROXY is set', async () => { process.env.HTTPS_PROXY = 'http://fake-proxy.example'; const axiosSpy = vi.spyOn(axios, 'request').mockResolvedValue({ @@ -313,7 +313,9 @@ describe('ClientOAuth2', () => { const requestConfig = axiosSpy.mock.calls[0][0]; const httpsAgent = requestConfig.httpsAgent as HttpsProxyAgent; expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent); - expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false); + // 'Ignore SSL issues' relaxes the tunnelled session to the target; the + // proxy is a different peer and keeps its certificate verified. + expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined(); // The ignore-SSL branch must keep axios's own proxy handling disabled // so routing stays with our agent, not double-proxied. expect(requestConfig.proxy).toBe(false); @@ -518,7 +520,7 @@ describe('ClientOAuth2', () => { expect(ssrfBridge.validateUrl).toHaveBeenCalledWith(new URL(config.accessTokenUri)); }); - it('should relax TLS through the proxy agent without a lookup when ignoreSSLIssues is set', async () => { + it('should route through the proxy agent without a lookup when ignoreSSLIssues is set', async () => { process.env.HTTPS_PROXY = 'http://fake-proxy.example'; const ssrfBridge = makeSsrfBridge(); const axiosSpy = proxiedTokenResponse(); @@ -527,7 +529,7 @@ describe('ClientOAuth2', () => { const httpsAgent = axiosSpy.mock.calls[0][0].httpsAgent as HttpsProxyAgent; expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent); - expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false); + expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined(); expect(httpsAgent.connectOpts.lookup).toBeUndefined(); });