fix(core): Apply TLS options per hop when requests go through a proxy (#35518)

This commit is contained in:
Lorent Lempereur
2026-08-11 09:55:13 +02:00
committed by GitHub
parent 8b22cca3a0
commit 68bcdfe80c
13 changed files with 309 additions and 31 deletions
@@ -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<string, unknown> };
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)', () => {
@@ -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,
@@ -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();
});
});
@@ -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;
@@ -11,6 +11,7 @@ import {
isProxyPotentiallyActive,
isRedirectStatus,
resolveProxyOption,
sniFor,
throwIfDomainNotAllowed,
tryParseUrl,
validateUrlSsrf,
@@ -120,12 +121,11 @@ function buildRedirectHopAgents(
nextUrl: string,
policy: SsrfRedirectPolicy,
): Pick<AxiosRequestConfig, 'httpAgent' | 'httpsAgent'> {
const host = tryParseUrl(nextUrl)?.hostname;
const customProxyUrl = policy.proxyConfig ? getUrlFromProxyConfig(policy.proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
return buildNodeAgents(proxy, policy.ssrf, {
...policy.agentOptions,
...(host ? { servername: host } : {}),
servername: sniFor(tryParseUrl(nextUrl)?.hostname),
});
}
@@ -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;
@@ -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<HttpProxyAgent<string>['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),
);
}
@@ -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<HttpsProxyAgent<string>['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),
);
}
@@ -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),
};
}
@@ -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<HttpsProxyAgent<string>['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<HttpsProxyAgent<string>['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');
});
});
@@ -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<HttpsProxyAgent<string>['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<keyof https.AgentOptions>;
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<string> {
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<net.Socket> {
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<string> {
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<string> {
return new TunnellingHttpsProxyAgent(proxyUrl, agentOptions);
}
@@ -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);
@@ -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<string>;
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<string>;
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false);
expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined();
expect(httpsAgent.connectOpts.lookup).toBeUndefined();
});