Compare commits

...

2 Commits

Author SHA1 Message Date
n8n-assistant[bot]
44b8b16267
🚀 Release 2.40.1 (#38770)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-09-16 09:32:12 +03:00
n8n-assistant[bot]
c410f848d2
chore: Bundle/2.x (backport to release-candidate/2.40.x) (#38766)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
2026-09-16 05:25:52 +00:00
294 changed files with 7554 additions and 1770 deletions

View File

@ -1527,6 +1527,18 @@
"line": 40,
"message": "packages/nodes-base turns '@typescript-eslint/prefer-promise-reject-errors' down to 'warn' for the whole package.",
"hash": "2d61f9d00a79"
},
{
"rule": "lint-config-layering",
"line": 26,
"message": "packages/nodes-base turns '@n8n/community-nodes/node-class-description-icon-missing' down to 'warn' for the whole package.",
"hash": "203610fabf04"
},
{
"rule": "lint-config-layering",
"line": 27,
"message": "packages/nodes-base turns '@n8n/community-nodes/cred-class-field-icon-missing' down to 'warn' for the whole package.",
"hash": "ac79bc55562e"
}
],
"packages/workflow/eslint.config.mjs": [

View File

@ -1,3 +1,6 @@
## [2.40.1](https://github.com/n8n-io/n8n/compare/n8n@2.40.0...n8n@2.40.1) (2026-09-16)
# [2.40.0](https://github.com/n8n-io/n8n/compare/n8n@2.39.0...n8n@2.40.0) (2026-09-15)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.40.0",
"version": "2.40.1",
"private": true,
"engines": {
"node": ">=24.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.25.0",
"version": "0.25.1",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=24.0.0"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.30.0",
"version": "0.30.1",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-utilities",
"version": "0.33.0",
"version": "0.33.1",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -47,11 +47,13 @@ const makeN8nLlmFailedAttemptHandler = vi.mocked(
const N8nLlmTracing = vi.mocked(n8nLlmTracing.N8nLlmTracing);
describe('supplyModel', () => {
const egressFilter = { createSecureLookup: vi.fn() };
const mockCtx = {
getNode: vi.fn(),
addOutputData: vi.fn(),
addInputData: vi.fn(),
getNextRunIndex: vi.fn(),
helpers: { getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter) },
} as unknown as ISupplyDataFunctions;
beforeEach(() => {
@ -190,10 +192,14 @@ describe('supplyModel', () => {
timeout: 12345,
});
expect(getProxyAgent).toHaveBeenCalledWith('https://api.openai.com', {
headersTimeout: 12345,
bodyTimeout: 12345,
});
expect(getProxyAgent).toHaveBeenCalledWith(
'https://api.openai.com',
{
headersTimeout: 12345,
bodyTimeout: 12345,
},
egressFilter,
);
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({

View File

@ -1,27 +1,42 @@
import { passthroughEgressFilter } from '@n8n/backend-network/egress';
import { buildDispatcher, dispatchedFetch } from '@n8n/backend-network/transport';
import { lookup as dnsLookup } from 'node:dns';
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import type { AddressInfo } from 'node:net';
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { MockedFunction } from 'vitest';
import { getNodeProxyAgent, getProxyAgent, proxyFetch } from 'src/utils/http-proxy-agent';
import {
getNodeProxyAgent,
getProxyAgent,
proxyFetch,
type EgressFilter,
} from 'src/utils/http-proxy-agent';
// Mock the dependencies
vi.mock('undici', () => ({
Agent: vi.fn(function (options) {
return { type: 'Agent', options };
}),
ProxyAgent: vi.fn(function (options) {
return { type: 'ProxyAgent', options };
}),
fetch: vi.fn(),
vi.mock('@n8n/backend-network/transport', () => ({
buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
type: 'Dispatcher',
proxy,
ssrf,
options,
})),
dispatchedFetch: vi.fn(),
}));
const DEFAULT_AGENT_OPTIONS = {
headersTimeout: 3600000,
bodyTimeout: 3600000,
connect: { lookup: dnsLookup },
const mockBuildDispatcher = buildDispatcher as unknown as MockedFunction<typeof buildDispatcher>;
const mockDispatchedFetch = dispatchedFetch as unknown as MockedFunction<typeof dispatchedFetch>;
const makeEgressFilter = (): EgressFilter => ({
validateUrl: vi.fn(async () => ({ ok: true as const })),
validateConnectionHost: vi.fn(() => ({ ok: true as const })),
createSecureLookup: vi.fn(() => vi.fn()),
});
// The real "no policy configured" singleton — detected by object identity.
const passthroughFilter: EgressFilter = passthroughEgressFilter;
const DEFAULT_BUILD_OPTIONS = {
timeouts: { headersTimeout: 3600000, bodyTimeout: 3600000 },
};
describe('getProxyAgent', () => {
@ -56,286 +71,228 @@ describe('getProxyAgent', () => {
});
describe('default behavior (no timeout options)', () => {
it('should return an Agent with the default lookup when no proxy environment variables are set and no timeout options', () => {
const agent = getProxyAgent();
it('should return a direct dispatcher when no proxy environment variables are set and no timeout options', () => {
const agent = getProxyAgent(undefined, undefined, passthroughFilter);
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(agent).toEqual({
type: 'Dispatcher',
proxy: false,
ssrf: 'disabled',
options: DEFAULT_BUILD_OPTIONS,
});
});
it('should return an Agent when no proxy is configured for target URL and no timeout options', () => {
const agent = getProxyAgent('https://api.openai.com/v1');
it('should return a direct dispatcher when no proxy is configured for target URL and no timeout options', () => {
const agent = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
});
it('should reuse a single Agent across calls when no proxy, timeout options nor custom lookup are given', () => {
const first = getProxyAgent('https://api.openai.com/v1');
const second = getProxyAgent('https://api.anthropic.com/v1');
it('should reuse a single dispatcher across calls when no proxy, timeout options nor egress filter are given', () => {
const first = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
const second = getProxyAgent('https://api.anthropic.com/v1', undefined, passthroughFilter);
expect(second).toBe(first);
});
it('should build a fresh Agent when timeout options or a custom lookup are given', () => {
const shared = getProxyAgent('https://api.openai.com/v1');
it('should keep the shared dispatcher for the passthrough egress filter', () => {
const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
expect(getProxyAgent('https://api.openai.com/v1', {})).not.toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, vi.fn())).not.toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter)).toBe(shared);
});
it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
it('should keep enforcement for a non-passthrough policy whose lookup is the system dns lookup', () => {
const filter = makeEgressFilter();
vi.mocked(filter.createSecureLookup).mockReturnValue(dnsLookup);
const agent = getProxyAgent();
getProxyAgent('https://api.example.com', undefined, filter);
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
expect(agent).toEqual({
type: 'ProxyAgent',
options: { uri: proxyUrl, headersTimeout: 3600000, bodyTimeout: 3600000 },
});
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, filter, expect.anything());
});
it('should create ProxyAgent when https_proxy is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
it('should build a fresh dispatcher when timeout options or an egress filter are given', () => {
const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
getProxyAgent();
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
});
it('should respect priority order of proxy environment variables', () => {
// Set multiple proxy environment variables
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.http_proxy = 'http://http-proxy-lowercase.example.com:8080';
process.env.HTTPS_PROXY = 'https://https-proxy.example.com:8080';
process.env.https_proxy = 'https://https-proxy-lowercase.example.com:8080';
getProxyAgent();
// Should use https_proxy as it has highest priority now
expect(ProxyAgent).toHaveBeenCalledWith(
expect.objectContaining({
uri: 'https://https-proxy-lowercase.example.com:8080',
}),
expect(getProxyAgent('https://api.openai.com/v1', {}, passthroughFilter)).not.toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, makeEgressFilter())).not.toBe(
shared,
);
});
it('should build an env-proxied dispatcher with default timeouts when HTTPS_PROXY is set', () => {
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
const agent = getProxyAgent(undefined, undefined, passthroughFilter);
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', DEFAULT_BUILD_OPTIONS);
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }));
});
it('should build an env-proxied dispatcher when https_proxy is set', () => {
process.env.https_proxy = 'https://proxy.example.com:8080';
getProxyAgent(undefined, undefined, passthroughFilter);
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
});
});
describe('target URL provided', () => {
it('should create ProxyAgent for HTTPS URL when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
it('should build an env-proxied dispatcher for HTTPS URL when HTTPS_PROXY is set', () => {
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
getProxyAgent('https://api.openai.com/v1');
getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
});
it('should create ProxyAgent for HTTP URL when HTTP_PROXY is set', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
it('should build an env-proxied dispatcher for HTTP URL when HTTP_PROXY is set', () => {
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
getProxyAgent('http://api.example.com');
getProxyAgent('http://api.example.com', undefined, passthroughFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
});
it('should use HTTPS_PROXY for HTTPS URLs even when HTTP_PROXY is set', () => {
const httpProxy = 'http://http-proxy.example.com:8080';
const httpsProxy = 'https://https-proxy.example.com:8443';
process.env.HTTP_PROXY = httpProxy;
process.env.HTTPS_PROXY = httpsProxy;
getProxyAgent('https://api.openai.com/v1');
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: httpsProxy }));
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
});
it('should respect NO_PROXY for localhost', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
process.env.NO_PROXY = 'localhost,127.0.0.1';
const agent = getProxyAgent('http://localhost:3000');
const agent = getProxyAgent('http://localhost:3000', undefined, passthroughFilter);
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
});
it('should respect NO_PROXY wildcard patterns', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
process.env.NO_PROXY = '*.internal.company.com,localhost';
const agent = getProxyAgent('https://api.internal.company.com');
const agent = getProxyAgent('https://api.internal.company.com', undefined, passthroughFilter);
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
});
it('should use proxy for URLs not in NO_PROXY', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
process.env.NO_PROXY = 'localhost,127.0.0.1';
getProxyAgent('https://api.openai.com/v1');
getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
});
it('should handle mixed case environment variables', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
process.env.https_proxy = 'http://proxy.example.com:8080';
process.env.no_proxy = 'localhost';
getProxyAgent('https://api.openai.com/v1');
getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
});
});
describe('timeout options', () => {
it('should pass custom timeout options to ProxyAgent when proxy is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
it('should pass custom timeout options when a proxy is set', () => {
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
getProxyAgent('https://api.openai.com/v1', {
headersTimeout: 120000,
bodyTimeout: 180000,
});
getProxyAgent(
'https://api.openai.com/v1',
{
headersTimeout: 120000,
bodyTimeout: 180000,
},
passthroughFilter,
);
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 120000,
bodyTimeout: 180000,
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', {
timeouts: { headersTimeout: 120000, bodyTimeout: 180000 },
});
});
it('should create Agent with timeout options when no proxy is configured', () => {
const agent = getProxyAgent('https://api.openai.com/v1', {
headersTimeout: 120000,
bodyTimeout: 180000,
});
it('should build a direct dispatcher with timeout options when no proxy is configured', () => {
const agent = getProxyAgent(
'https://api.openai.com/v1',
{
headersTimeout: 120000,
bodyTimeout: 180000,
},
passthroughFilter,
);
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 120000,
bodyTimeout: 180000,
connect: { lookup: dnsLookup },
});
expect(agent).toEqual({
type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 180000, connect: { lookup: dnsLookup } },
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
timeouts: { headersTimeout: 120000, bodyTimeout: 180000 },
});
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
});
it('should use default timeouts when empty timeout options object is passed', () => {
getProxyAgent('https://api.openai.com/v1', {});
getProxyAgent('https://api.openai.com/v1', {}, passthroughFilter);
expect(Agent).toHaveBeenCalledWith(DEFAULT_AGENT_OPTIONS);
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', DEFAULT_BUILD_OPTIONS);
});
it('should include connectTimeout when provided', () => {
getProxyAgent('https://api.openai.com/v1', {
headersTimeout: 60000,
bodyTimeout: 60000,
connectTimeout: 30000,
});
getProxyAgent(
'https://api.openai.com/v1',
{
headersTimeout: 60000,
bodyTimeout: 60000,
connectTimeout: 30000,
},
passthroughFilter,
);
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 60000,
bodyTimeout: 60000,
connectTimeout: 30000,
connect: { lookup: dnsLookup },
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
timeouts: { headersTimeout: 60000, bodyTimeout: 60000, connectTimeout: 30000 },
});
});
it('should respect custom timeout from environment variable', () => {
process.env.N8N_AI_TIMEOUT_MAX = '300000';
// Need to re-import to pick up env vars (or mock module)
// For this test, we just verify the default timeout parsing
// The actual behavior is tested by integration tests
// Empty options should use env var defaults
getProxyAgent('https://api.openai.com/v1', {});
// Since we can't easily re-import, we verify the mock was called with defaults
expect(Agent).toHaveBeenCalled();
});
it('should build a fresh Agent when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
const shared = getProxyAgent('https://api.openai.com/v1');
it('should build a fresh dispatcher when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
process.env.N8N_AI_TIMEOUT_MAX = '120000';
const agent = getProxyAgent('https://api.openai.com/v1');
const agent = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
// DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it),
// so the value here reflects that capture, not '120000' — the module-reset test below
// covers the env value actually being picked up end to end.
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher' }));
expect(agent).not.toBe(shared);
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should honor N8N_AI_TIMEOUT_MAX when there is no proxy and the caller passes no timeout options at all', async () => {
vi.resetModules();
process.env.N8N_AI_TIMEOUT_MAX = '120000';
const undici = await import('undici');
const transport = await import('@n8n/backend-network/transport');
const egress = await import('@n8n/backend-network/egress');
const { getProxyAgent: freshGetProxyAgent } = await import('../../utils/http-proxy-agent.js');
const agent = freshGetProxyAgent('https://api.openai.com/v1');
freshGetProxyAgent('https://api.openai.com/v1', undefined, egress.passthroughEgressFilter);
expect(undici.Agent).toHaveBeenCalledWith({
headersTimeout: 120000,
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
});
expect(agent).toEqual({
type: 'Agent',
options: {
headersTimeout: 120000,
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
},
expect(transport.buildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
timeouts: { headersTimeout: 120000, bodyTimeout: 120000 },
});
});
});
describe('secure lookup', () => {
it('should build an Agent with the lookup on connect when no proxy is configured', () => {
const lookup = vi.fn();
describe('egress filter', () => {
it('should build the dispatcher with the filter when no proxy is configured', () => {
const egressFilter = makeEgressFilter();
const agent = getProxyAgent('https://api.openai.com/v1', undefined, lookup);
const agent = getProxyAgent('https://api.openai.com/v1', undefined, egressFilter);
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
expect(ProxyAgent).not.toHaveBeenCalled();
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, egressFilter, DEFAULT_BUILD_OPTIONS);
expect(agent).toEqual(expect.objectContaining({ ssrf: egressFilter }));
});
it('should not attach the lookup to a ProxyAgent when a proxy is configured', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const lookup = vi.fn();
it('should build the dispatcher with the filter when a proxy is configured', () => {
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
const egressFilter = makeEgressFilter();
getProxyAgent('https://api.openai.com/v1', undefined, lookup);
getProxyAgent('https://api.openai.com/v1', undefined, egressFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(ProxyAgent).not.toHaveBeenCalledWith(
expect.objectContaining({ connect: expect.anything() }),
);
expect(Agent).not.toHaveBeenCalled();
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', egressFilter, DEFAULT_BUILD_OPTIONS);
});
});
});
@ -343,7 +300,6 @@ describe('getProxyAgent', () => {
describe('proxyFetch', () => {
// Store original environment variables
const originalEnv = { ...process.env };
const mockFetch = undiciFetch as unknown as MockedFunction<typeof fetch>;
// Reset environment variables and mocks before each test
beforeEach(() => {
@ -357,7 +313,7 @@ describe('proxyFetch', () => {
delete process.env.no_proxy;
// Setup default fetch mock response
mockFetch.mockResolvedValue(
mockDispatchedFetch.mockResolvedValue(
new Response('{}', {
status: 200,
statusText: 'OK',
@ -372,44 +328,53 @@ describe('proxyFetch', () => {
});
describe('with no proxy configured', () => {
it('should call fetch with an Agent dispatcher when no proxy is set and no timeout options', async () => {
it('should fetch with a direct dispatcher when no proxy is set and no timeout options', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch({ input: url, lookup: dnsLookup });
await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
url,
undefined,
);
});
it('should reuse the same dispatcher across plain calls', async () => {
await proxyFetch({ input: 'https://api.openai.com/v1', lookup: dnsLookup });
await proxyFetch({ input: 'https://api.anthropic.com/v1', lookup: dnsLookup });
await proxyFetch({ input: 'https://api.openai.com/v1', egressFilter: passthroughFilter });
await proxyFetch({ input: 'https://api.anthropic.com/v1', egressFilter: passthroughFilter });
const [[, first], [, second]] = mockFetch.mock.calls as unknown as Array<
[unknown, { dispatcher: unknown }]
>;
expect(second.dispatcher).toBe(first.dispatcher);
const [[first], [second]] = mockDispatchedFetch.mock.calls;
expect(second).toBe(first);
});
it('should build the dispatcher with the supplied lookup', async () => {
const lookup = vi.fn();
await proxyFetch({ input: 'https://api.openai.com/v1', lookup });
it('should build the dispatcher with the supplied egress filter', async () => {
const egressFilter = makeEgressFilter();
await proxyFetch({ input: 'https://api.openai.com/v1', egressFilter });
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, egressFilter, expect.anything());
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ ssrf: egressFilter }),
'https://api.openai.com/v1',
undefined,
);
});
it('should call fetch with Agent dispatcher when timeout options are provided', async () => {
it('should fetch with a dispatcher when timeout options are provided', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch({
input: url,
timeoutOptions: { headersTimeout: 60000 },
lookup: dnsLookup,
egressFilter: passthroughFilter,
});
expect(Agent).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'Agent' }),
expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
timeouts: { headersTimeout: 60000, bodyTimeout: 3600000 },
});
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'Dispatcher' }),
url,
undefined,
);
});
it('should pass through RequestInit options', async () => {
@ -420,41 +385,55 @@ describe('proxyFetch', () => {
body: JSON.stringify({ test: 'data' }),
};
await proxyFetch({ input: url, init, lookup: dnsLookup });
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, init);
});
it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1');
await proxyFetch({ input: url, lookup: dnsLookup });
await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(mockFetch).toHaveBeenCalledWith(url.href, {
dispatcher: expect.objectContaining({ type: 'Agent' }),
expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, undefined);
});
it('should handle Request objects', async () => {
const request = new Request('https://api.openai.com/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' }),
});
await proxyFetch({ input: request, egressFilter: passthroughFilter });
// A Request is decomposed into url + init: the package's undici fetch does
// not recognize a Request built by another undici.
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.anything(),
'https://api.openai.com/v1',
expect.objectContaining({
method: 'POST',
headers: [['content-type', 'application/json']],
}),
);
});
});
describe('with proxy configured', () => {
it('should call fetch with ProxyAgent dispatcher when proxy is set', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
it('should fetch with an env-proxied dispatcher when proxy is set', async () => {
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
const url = 'https://api.openai.com/v1';
await proxyFetch({ input: url, lookup: dnsLookup });
await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
url,
undefined,
);
});
it('should pass through RequestInit options with proxy', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
const url = 'https://api.openai.com/v1';
const init: RequestInit = {
@ -462,72 +441,41 @@ describe('proxyFetch', () => {
headers: { Authorization: 'Bearer token123' },
};
await proxyFetch({ input: url, init, lookup: dnsLookup });
await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
});
it('should handle URL objects with proxy', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
const url = new URL('http://api.example.com/data');
await proxyFetch({ input: url, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url.href, {
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
});
it('should handle Request objects with proxy', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const request = new Request('https://api.openai.com/v1');
await proxyFetch({ input: request, lookup: dnsLookup });
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(
request.url,
expect.objectContaining({ dispatcher: expect.objectContaining({ type: 'ProxyAgent' }) }),
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
url,
init,
);
});
it('should respect NO_PROXY environment variable', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
process.env.NO_PROXY = 'localhost,127.0.0.1';
const url = 'https://localhost:3000/api';
await proxyFetch({ input: url, lookup: dnsLookup });
await proxyFetch({ input: url, egressFilter: passthroughFilter });
// Should not create ProxyAgent for localhost
expect(ProxyAgent).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'Dispatcher', proxy: false }),
url,
undefined,
);
});
it('should pass timeout options to ProxyAgent when proxy is configured', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
it('should pass timeout options to the dispatcher when proxy is configured', async () => {
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
const url = 'https://api.openai.com/v1';
await proxyFetch({
input: url,
timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 },
lookup: dnsLookup,
egressFilter: passthroughFilter,
});
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 300000,
bodyTimeout: 300000,
expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', {
timeouts: { headersTimeout: 300000, bodyTimeout: 300000 },
});
});
});
@ -538,21 +486,23 @@ describe('proxyFetch', () => {
status: 200,
statusText: 'OK',
});
mockFetch.mockResolvedValueOnce(expectedResponse);
mockDispatchedFetch.mockResolvedValueOnce(expectedResponse);
const url = 'https://api.openai.com/v1';
const result = await proxyFetch({ input: url, lookup: dnsLookup });
const result = await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(result).toBe(expectedResponse);
});
it('should propagate fetch errors', async () => {
const error = new Error('Network error');
mockFetch.mockRejectedValueOnce(error);
mockDispatchedFetch.mockRejectedValueOnce(error);
const url = 'https://api.openai.com/v1';
await expect(proxyFetch({ input: url, lookup: dnsLookup })).rejects.toThrow('Network error');
await expect(proxyFetch({ input: url, egressFilter: passthroughFilter })).rejects.toThrow(
'Network error',
);
});
it('should return error responses without throwing', async () => {
@ -560,10 +510,10 @@ describe('proxyFetch', () => {
status: 404,
statusText: 'Not Found',
});
mockFetch.mockResolvedValueOnce(errorResponse);
mockDispatchedFetch.mockResolvedValueOnce(errorResponse);
const url = 'https://api.openai.com/v1';
const result = await proxyFetch({ input: url, lookup: dnsLookup });
const result = await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(result).toBe(errorResponse);
});
@ -628,7 +578,9 @@ describe('proxyFetch with the real undici', () => {
async () => (await import('undici')).Request as unknown as typeof Request,
],
])('should send a Request built with %s', async (_, loadRequestClass) => {
vi.doUnmock('undici');
// The dispatcher and the fetch bound to it are mocked for the rest of this
// file, so the real transport has to come back for these two.
vi.doUnmock('@n8n/backend-network/transport');
vi.resetModules();
const { proxyFetch: realProxyFetch } = await import('../../utils/http-proxy-agent.js');
const RequestClass = await loadRequestClass();
@ -655,7 +607,7 @@ describe('proxyFetch with the real undici', () => {
headers: { 'content-type': 'application/json' },
body: '{"model":"mistral-small"}',
});
const response = await realProxyFetch({ input: request, lookup: dnsLookup });
const response = await realProxyFetch({ input: request, egressFilter: passthroughFilter });
expect(await response.text()).toBe('ok');
expect(received).toEqual({

View File

@ -56,6 +56,7 @@ export {
getNodeProxyAgent,
proxyFetch,
type AgentTimeoutOptions,
type EgressFilter,
type ProxyFetchOptions,
} from './utils/http-proxy-agent';
export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search';

View File

@ -112,3 +112,53 @@ describe('getNodeTypeDefinition — latest version resolution', () => {
expect(result.content).toBe('// notion v3 get def');
});
});
describe('getNodeTypeDefinition — version segment handling', () => {
let defsDir: string;
beforeAll(() => {
defsDir = mkdtempSync(join(tmpdir(), 'ai-utils-node-versions-'));
const nodesDir = join(defsDir, 'nodes', 'n8n-nodes-base');
mkdirSync(join(nodesDir, 'set'), { recursive: true });
writeFileSync(join(nodesDir, 'set', 'v2.ts'), '// set v2 def');
writeFileSync(join(nodesDir, 'set', 'v34.ts'), '// set v34 def');
mkdirSync(join(nodesDir, 'webhook'), { recursive: true });
writeFileSync(join(nodesDir, 'webhook', 'v2.ts'), '// webhook v2 def');
writeFileSync(join(defsDir, 'index.ts'), '// definitions root index');
});
afterAll(() => {
rmSync(defsDir, { recursive: true, force: true });
});
it.each([
['2', '// set v2 def'],
['v2', '// set v2 def'],
['3.4', '// set v34 def'],
['v3.4', '// set v34 def'],
])('resolves the definition for version %s', (version, expected) => {
const result = getNodeTypeDefinition('n8n-nodes-base.set', version, [defsDir]);
expect(result.error).toBeUndefined();
expect(result.content).toBe(expected);
});
it.each([
'v./../../webhook/v2',
'v./../../../../index',
'v./../../../../../../nodes/n8n-nodes-base/webhook/v2',
'v.\\..\\..\\webhook\\v2',
'../webhook/v2',
'2\0',
'..',
'latest',
])('rejects the version value %j', (version) => {
const result = getNodeTypeDefinition('n8n-nodes-base.set', version, [defsDir]);
expect(result.error).toContain('not found for node');
expect(result.content).toBe('');
});
});

View File

@ -37,6 +37,15 @@ export function isValidPathComponent(component: string): boolean {
return true;
}
/**
* Validate a normalized version segment (`v1`, `v31`).
* Generated definitions only ever use this shape, so anything else is rejected
* before it reaches a path join.
*/
export function isValidVersionSegment(segment: string): boolean {
return /^v\d+$/.test(segment);
}
/**
* Validate that a resolved path is within the expected base directory.
* Prevents path traversal even if components pass basic validation.
@ -462,6 +471,10 @@ function getNodeFilePath(
targetVersion = `v${targetVersion.slice(1).replace('.', '')}`;
}
if (!isValidVersionSegment(targetVersion)) {
return { error: `Version '${version}' not found for node '${nodeId}'` };
}
// Check if this is a split version structure
if (isSplitVersionStructure(nodeDir, targetVersion)) {
const available = getAvailableDiscriminators(nodeDir, targetVersion);
@ -491,6 +504,10 @@ function getNodeFilePath(
// Flat file structure
const filePath = join(nodeDir, `${targetVersion}.ts`);
if (!validatePathWithinBase(filePath, nodeDir)) {
return { error: 'Error: Invalid path - path traversal detected' };
}
if (!existsSync(filePath)) {
return { error: `Version '${version}' not found for node '${nodeId}'` };
}

View File

@ -23,6 +23,7 @@ export {
getNodeTypeDefinition,
getNodeTypes,
isValidPathComponent,
isValidVersionSegment,
validatePathWithinBase,
parseNodeId,
toSnakeCase,

View File

@ -30,10 +30,14 @@ function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
const timeout = model.timeout;
clientConfiguration.fetchOptions = {
dispatcher: getProxyAgent(model.baseUrl, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
model.baseUrl,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
ctx.helpers.getSecureEgressFilter(),
),
};
const openAiModel = new ChatOpenAI({

View File

@ -1,31 +1,36 @@
/**
* Proxy/transport helpers for the AI model suppliers.
*
* These are the last AI proxy-fetch helpers not yet consolidated onto `@n8n/backend-network`.
* The dispatchers this module hands out go to AI SDK clients via
* `fetchOptions: { dispatcher }` and are dispatched by the SDK's fetch. They
* must be built by a v7 undici — which is why construction is delegated to
* `@n8n/backend-network/transport` (itself on `catalog:undici-v7`): a v7
* dispatcher accepts the dispatch handlers of every supported Node's fetch,
* while a v6 dispatcher rejects the v7 handlers of Node >= 26
* (`invalid onError method`).
*
* The dispatchers built here are handed to AI SDK clients via
* `fetchOptions: { dispatcher }` and dispatched by the global `fetch`. They
* must come from undici v7: a v7 dispatcher accepts the dispatch handlers of
* every supported Node's fetch, while a v6 dispatcher rejects the v7 handlers
* of Node >= 26 (`invalid onError method`).
*
* Proxy URL resolution and the Node `http(s).Agent` (both version-agnostic) do come from `@n8n/backend-network/proxy`,
* so this module no longer depends on `proxy-from-env` / `https-proxy-agent` directly.
*
* TODO: drop these helpers and route their calls through
* `@n8n/backend-network/transport` (CAT-3377 consolidated the backend callers
* and left these runner-side ones in place).
* Dispatcher construction, egress validation and the dispatcher-bound fetch
* come from `@n8n/backend-network`'s DI-free subpaths, so this
* module only maps its callers' options — AI timeout defaults, the shared
* default dispatcher — onto that shared core.
*/
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
import { lookup as defaultLookup } from 'node:dns';
import { passthroughEgressFilter } from '@n8n/backend-network/egress';
import {
createHttpsProxyAgent,
isProxyRequired,
resolveProxyUrl,
} from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
import {
buildDispatcher,
dispatchedFetch,
type TransportSsrfPolicy,
} from '@n8n/backend-network/transport';
import type { AgentOptions } from 'node:https';
import type { LookupFunction } from 'node:net';
/* eslint-disable n8n-local-rules/no-uncentralized-http -- raw dispatchers for AI SDK `fetchOptions`; see block comment above */
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { Dispatcher } from 'undici';
/**
* Options for configuring HTTP agent timeouts.
* These timeouts are passed to undici's Agent/ProxyAgent to override default 5-minute timeouts.
* These timeouts are passed to the underlying undici agents to override default 5-minute timeouts.
*/
export interface AgentTimeoutOptions {
headersTimeout?: number;
@ -33,6 +38,8 @@ export interface AgentTimeoutOptions {
connectTimeout?: number;
}
export type EgressFilter = TransportSsrfPolicy;
// Default timeout for AI operations (1 hour)
// Aligned with EXECUTIONS_TIMEOUT_MAX to ensure AI requests don't exceed workflow execution limits
// Configurable via N8N_AI_TIMEOUT_MAX environment variable to support custom timeout requirements
@ -40,41 +47,57 @@ const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10
/**
* Stand-in target used when the real target URL is unknown in advance (e.g. when providing a proxy agent to ChatAwsBedrock).
* Resolving against a stand-in cannot honor `NO_PROXY` for the real target, but it is better than having no proxy agent at all.
* It only decides whether an env-proxied dispatcher is built at all: that dispatcher re-resolves
* the proxy from the environment per dispatched request, so `NO_PROXY` is honored for the real target.
*/
const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
/**
* Agent shared by every caller that needs no proxy, no timeout overrides and the
* plain system DNS lookup, so those callers keep a single connection pool instead
* of building an agent (and a pool) per request.
* Dispatcher shared by every caller that needs no proxy, no timeout overrides
* and no effective egress filter, so those callers keep a single connection
* pool instead of building a dispatcher (and a pool) per request.
*/
let sharedAgent: Agent | undefined;
let sharedDispatcher: Dispatcher | undefined;
/**
* Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL.
* The passthrough filter (the "no policy configured" singleton) enforces
* nothing, so it resolves to `'disabled'` and keeps the shared-dispatcher
* fast path. Every other supplied policy is returned unchanged.
*/
function toSsrfOption(egressFilter: EgressFilter): EgressFilter | 'disabled' {
if (egressFilter === passthroughEgressFilter) {
return 'disabled';
}
return egressFilter;
}
/**
* Returns an undici dispatcher with configured timeouts based on the environment variables and target URL.
* When target URL is not provided, NO_PROXY environment variable is not respected.
*
* @param targetUrl - The target URL to check proxy configuration for (optional)
* @param timeoutOptions - Optional timeout configuration to override defaults.
* @param lookup - Connect-time DNS lookup, applied to every Agent this builds.
* Defaults to the plain system lookup.
* @returns A ProxyAgent when a proxy applies to the target, otherwise an Agent. Callers using the
* defaults (no timeout options, no `N8N_AI_TIMEOUT_MAX`, plain lookup) share one Agent.
* @param egressFilter - The execution context's secure egress filter, enforced on every
* dispatched request: pre-flight URL validation on each redirect hop and
* a pinned DNS resolution. The passthrough filter enforces nothing.
* @returns A dispatcher routing through the environment proxy when one applies to the target,
* otherwise a direct dispatcher. Callers using the defaults (no timeout options, no
* `N8N_AI_TIMEOUT_MAX`, no effective egress filter) share one dispatcher.
*
* @remarks
* The default undici timeouts (5 minutes) are too short for many AI operations, so an agent is
* always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX`
* The default undici timeouts (5 minutes) are too short for many AI operations, so a dispatcher
* is always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX`
* is honoured even when no proxy and no explicit timeout options are configured.
*/
export function getProxyAgent(
targetUrl?: string,
timeoutOptions?: AgentTimeoutOptions,
lookup: LookupFunction = defaultLookup,
) {
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
targetUrl: string | undefined,
timeoutOptions: AgentTimeoutOptions | undefined,
egressFilter: EgressFilter,
): Dispatcher {
const proxyRequired = isProxyRequired(targetUrl, PROXY_FALLBACK_TARGET);
const ssrf = toSsrfOption(egressFilter);
const agentOptions = {
const timeouts = {
headersTimeout: timeoutOptions?.headersTimeout ?? DEFAULT_TIMEOUT,
bodyTimeout: timeoutOptions?.bodyTimeout ?? DEFAULT_TIMEOUT,
...(timeoutOptions?.connectTimeout !== undefined && {
@ -82,19 +105,15 @@ export function getProxyAgent(
}),
};
if (proxyUrl) {
return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
}
const isDefaultCase =
!timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && lookup === defaultLookup;
!proxyRequired && !timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && ssrf === 'disabled';
if (isDefaultCase) {
sharedAgent ??= new Agent({ ...agentOptions, connect: { lookup } });
return sharedAgent;
sharedDispatcher ??= buildDispatcher(false, 'disabled', { timeouts });
return sharedDispatcher;
}
return new Agent({ ...agentOptions, connect: { lookup } });
return buildDispatcher(proxyRequired ? 'env' : false, ssrf, { timeouts });
}
/**
@ -107,42 +126,41 @@ export interface ProxyFetchOptions {
init?: RequestInit;
/** Optional timeout configuration to override defaults */
timeoutOptions?: AgentTimeoutOptions;
/** Connect-time DNS lookup, e.g. an egress filter's secure lookup */
lookup: LookupFunction;
/** Egress filter enforced on every dispatched request, e.g. the execution context's secure egress filter */
egressFilter: EgressFilter;
}
/**
* Make a fetch() request with an Agent/ProxyAgent that has configured timeouts.
* If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent.
* Make a fetch() request with a dispatcher that has configured timeouts.
* If proxy environment variables are set, the dispatcher routes through the proxy.
*/
export async function proxyFetch({
input,
init,
timeoutOptions,
lookup,
egressFilter,
}: ProxyFetchOptions): Promise<Response> {
// Two Request classes exist at runtime (the global one and this package's
// undici), so detect a Request by exclusion instead of `instanceof`.
const isRequest = typeof input !== 'string' && !(input instanceof URL);
const targetUrl = isRequest ? input.url : input.toString();
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, egressFilter);
// The dispatcher comes from this package's undici, so the request must use
// the same undici's fetch: the global fetch on Node >= 26 rejects it. That
// `dispatchedFetch` runs the fetch of the undici the dispatcher was built
// from: the global fetch on Node >= 26 rejects a foreign dispatcher. That
// fetch only recognizes its own Request class and stringifies any other, so
// a Request (the Mistral SDK builds one with the global class) is passed
// as url + init instead.
return (await undiciFetch(targetUrl, {
...(isRequest && {
method: input.method,
headers: [...input.headers],
body: input.body === null ? undefined : await input.arrayBuffer(),
signal: input.signal,
redirect: input.redirect,
}),
...(init as Parameters<typeof undiciFetch>[1]),
dispatcher,
})) as unknown as Response;
if (!isRequest) return await dispatchedFetch(dispatcher, input, init);
return await dispatchedFetch(dispatcher, targetUrl, {
method: input.method,
headers: [...input.headers],
body: input.body === null ? undefined : await input.arrayBuffer(),
signal: input.signal,
redirect: input.redirect,
...init,
});
}
/**

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "1.40.0",
"version": "1.40.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.40.0",
"version": "1.40.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class TestCredentialRequestDto extends Z.class({
credentials: z.object({
id: z.string(),
name: z.string().default(''),
type: z.string(),
data: z.record(z.string(), z.unknown()).optional(),
}),
}) {}

View File

@ -128,6 +128,7 @@ export { SourceControlPushConflictErrorPublicDto } from './source-control/source
export { SourceControlPullResponsePublicDto } from './source-control/source-control-pull-response-public.dto';
export { CreateCredentialDto } from './credentials/create-credential.dto';
export { TestCredentialRequestDto } from './credentials/test-credential-request.dto';
export {
CredentialPublicDto,
CredentialListPublicDto,

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.40.0",
"version": "1.40.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-network",
"version": "1.14.0",
"version": "1.14.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -35,6 +35,11 @@
"import": "./src/transport.ts",
"require": "./dist/transport.js"
},
"./egress": {
"types": "./dist/egress.d.ts",
"import": "./src/egress.ts",
"require": "./dist/egress.js"
},
"./proxy": {
"types": "./dist/proxy/index.d.ts",
"import": "./src/proxy/index.ts",
@ -49,6 +54,9 @@
"transport": [
"dist/transport.d.ts"
],
"egress": [
"dist/egress.d.ts"
],
"proxy": [
"dist/proxy/index.d.ts"
]

View File

@ -0,0 +1,9 @@
/**
* Egress entry point (`@n8n/backend-network/egress`).
*
* DI-free like `./transport`, but unlike it this subpath depends on
* `n8n-workflow` (types) and `@n8n/utils`: it exposes the passthrough filter
* singleton so DI-less consumers can detect "no egress policy configured" by
* object identity.
*/
export { passthroughEgressFilter } from './ssrf/passthrough-egress-filter';

View File

@ -4,7 +4,7 @@ import { Container } from '@n8n/di';
import { fetch as undiciFetch } from 'undici';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
import { passthroughEgressFilter, type SsrfProtectionService } from '../../ssrf';
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { OutboundHttp } from '../outbound-http';
@ -36,6 +36,50 @@ describe('DI registration', () => {
});
});
describe('egressFilter', () => {
it('returns the SSRF protection service when the instance enables protection', () => {
const service = mock<SsrfProtectionService>();
const facade = new OutboundHttp(
service,
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
);
expect(facade.egressFilter()).toBe(service);
});
it('returns the passthrough filter when the instance disables protection', () => {
const facade = new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: false }),
mock<Logger>(),
);
expect(facade.egressFilter()).toBe(passthroughEgressFilter);
});
it("returns the SSRF protection service for 'enforced' even when the instance disables protection", () => {
const service = mock<SsrfProtectionService>();
const facade = new OutboundHttp(
service,
mock<SsrfProtectionConfig>({ enabled: false }),
mock<Logger>(),
);
expect(facade.egressFilter('enforced')).toBe(service);
});
it("returns the passthrough filter for 'unsafe' even when the instance enables protection", () => {
const facade = new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<Logger>(),
);
expect(facade.egressFilter('unsafe')).toBe(passthroughEgressFilter);
});
});
describe('transport asCustomFetch', () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@ -37,8 +37,9 @@ export function isSupportedProxyUrl(value: string | null | undefined): value is
export type ProxyOption = 'env' | ProxyUrl | false;
/**
* Controls SSRF protection for an outbound HTTP client.
* Explicitly passing `'disabled'` makes the opt-out visible in calling code.
* Resolved SSRF policy for an outbound HTTP client:
* - the bridge to enforce
* - or 'disabled' when none applies.
*/
export type SsrfOption = SsrfBridge | 'disabled';

View File

@ -6,13 +6,14 @@ import type {
IN8nHttpFullResponse,
IN8nHttpResponse,
IRequestOptions,
NodeEgressFilter,
} from 'n8n-workflow';
import type http from 'node:http';
import type https from 'node:https';
import type { Readable } from 'node:stream';
import type { Dispatcher } from 'undici';
import { SsrfProtectionService } from '../ssrf';
import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
import { httpRequest } from './axios/request';
import { withClientDefaults } from './client-default-headers';
import { HttpRequestClientOptions } from './client-options';
@ -241,6 +242,17 @@ export class OutboundHttp {
};
}
/**
* The egress filter enforcing the given {@link UseDefaultSsrfPolicy}, for
* callers that hand a filter to a third-party SDK: the container's
* `SsrfProtectionService` when the policy resolves to guarded, a passthrough
* filter otherwise.
*/
egressFilter(useDefaultSsrfPolicy: UseDefaultSsrfPolicy = 'safe'): NodeEgressFilter {
const ssrf = this.resolveSsrf(useDefaultSsrfPolicy);
return ssrf === 'disabled' ? passthroughEgressFilter : ssrf;
}
/**
* Resolves a caller's {@link UseDefaultSsrfPolicy} to the SSRF policy to enforce:
* the container's `SsrfProtectionService` always in `'enforced'` mode, and in

View File

@ -1,14 +1,36 @@
import type { LookupFunction } from 'node:net';
import type { Dispatcher } from 'undici';
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { SsrfBridge } from '../../ssrf';
import type { ProxyOption, SsrfOption } from '../node-agents';
import type { ProxyOption } from '../node-agents';
/**
* Drop-in replacement type for the global `fetch`.
*/
export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/**
* Structural SSRF policy for the dispatcher path: the subset of `SsrfBridge`
* this transport core consumes. Kept structural (and dependency-free) so
* DI-less callers can satisfy it with their own policy object.
*/
export interface TransportSsrfPolicy {
/** Pre-flight validation of a dispatched target (the initial request and every redirect hop). */
validateUrl(url: string | URL): Promise<{ ok: true } | { ok: false; error: Error }>;
/** Validation of a connection host no DNS lookup will see (e.g. an IP-literal proxy host). */
validateConnectionHost(host: string): { ok: true } | { ok: false; error: Error };
/** DNS lookup drop-in that pins the validated address to the socket at connect time. */
createSecureLookup(): LookupFunction;
}
/**
* Resolved SSRF policy for the dispatcher path: the policy to enforce, or
* `'disabled'` when none applies (instance protection off, no egress filter in
* the calling context, or an `'unsafe'` opt-out already resolved upstream).
* Intent is expressed at the client layer (`UseDefaultSsrfPolicy`), not here.
*/
export type TransportSsrfOption = TransportSsrfPolicy | 'disabled';
/**
* Per-request authorization gate run against the target of every dispatched request
* (the initial request and every redirect hop).
@ -45,7 +67,7 @@ export interface CreateDispatcherTransportOptions {
/** Proxy routing. Defaults to `'env'` (HTTP(S)_PROXY / NO_PROXY). */
proxy?: ProxyOption;
/** SSRF policy. Defaults to `'disabled'`. */
ssrf?: SsrfOption;
ssrf?: TransportSsrfOption;
/** Undici agent timeout overrides. */
timeouts?: TransportTimeoutOptions;
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */
@ -85,7 +107,7 @@ export interface BuildDispatcherOptions {
*/
export function buildDispatcher(
proxy: ProxyOption,
ssrf: SsrfOption,
ssrf: TransportSsrfOption,
options: BuildDispatcherOptions = {},
): Dispatcher {
let dispatcher = buildDispatcherFromProxy(proxy, ssrf, options?.timeouts);
@ -100,7 +122,7 @@ export function buildDispatcher(
function buildDispatcherFromProxy(
proxy: ProxyOption,
ssrf: SsrfOption,
ssrf: TransportSsrfOption,
timeouts?: TransportTimeoutOptions,
): Dispatcher {
const agentOptions = toAgentTimeoutOptions(timeouts);
@ -127,7 +149,7 @@ function buildDispatcherFromProxy(
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
* tunnel handshake.
*/
function secureProxyConnect(ssrf: SsrfOption) {
function secureProxyConnect(ssrf: TransportSsrfOption) {
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } };
}
@ -135,7 +157,7 @@ function secureProxyConnect(ssrf: SsrfOption) {
* Catches an IP-literal proxy host, which no lookup sees.
* Applies to an explicit proxy URI, the one form that can come from a request.
*/
function assertProxyHostAllowed(ssrf: SsrfOption, proxyUri: string): void {
function assertProxyHostAllowed(ssrf: TransportSsrfOption, proxyUri: string): void {
if (ssrf === 'disabled') {
return;
}
@ -162,7 +184,7 @@ function proxyHostname(uri: string): string | undefined {
* It pins the validated IP to the socket, so a hostname that passed the interceptor's pre-flight
* `validateUrl` cannot be rebound to a private IP before undici resolves it again at connect time (DNS-rebinding / TOCTOU).
*/
function secureConnect(ssrf: SsrfOption) {
function secureConnect(ssrf: TransportSsrfOption) {
return ssrf === 'disabled' ? {} : { connect: { lookup: ssrf.createSecureLookup() } };
}
@ -222,7 +244,9 @@ function lazyValue<T>(factory: () => T): () => T {
* That host is fixed for the dispatcher rather than per request, so
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built.
*/
export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.DispatcherComposeInterceptor {
export function createSsrfInterceptor(
bridge: Pick<TransportSsrfPolicy, 'validateUrl'>,
): Dispatcher.DispatcherComposeInterceptor {
return (dispatch) => (opts, handler) => {
let targetUrl: URL;
try {
@ -309,9 +333,12 @@ function failDispatch(handler: FailableDispatchHandler, error: Error): void {
}
}
/** Performs a `fetch` bound to the given undici dispatcher (the engine behind `asCustomFetch`). */
/**
* Performs a `fetch` bound to the given undici dispatcher (the engine behind `asCustomFetch`).
* Without a dispatcher it falls through to this undici's default dispatcher.
*/
export async function dispatchedFetch(
dispatcher: Dispatcher,
dispatcher: Dispatcher | undefined,
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {

View File

@ -1,15 +1 @@
/**
* Controls whether an outbound HTTP client is subject to the instance's
* outbound network policy (SSRF protection).
*
* - `'safe'` (default): the client enforces the instance policy. Whether the
* guard actually runs is decided inside `OutboundHttp` from
* `SsrfProtectionConfig.enabled` — callers never read that flag themselves.
* - `'enforced'`: the guard runs unconditionally, regardless of
* `SsrfProtectionConfig.enabled`. Reserve this for destinations that must
* stay guarded even on instances that leave protection off.
* - `'unsafe'`: the client bypasses the policy unconditionally. Reserve this
* for fixed, n8n-owned or operator-configured destinations, and state the
* reason in a comment at the call site.
*/
export type UseDefaultSsrfPolicy = 'safe' | 'enforced' | 'unsafe';
export type { UseDefaultSsrfPolicy } from 'n8n-workflow';

View File

@ -1,4 +1,4 @@
import { resolveProxyUrl } from '../proxy-resolution';
import { isProxyRequired, resolveProxyUrl } from '../proxy-resolution';
const PROXY_URL = 'http://127.0.0.1:8888';
@ -50,3 +50,29 @@ describe('resolveProxyUrl', () => {
).toBeUndefined();
});
});
describe('isProxyRequired', () => {
beforeEach(() => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.ALL_PROXY;
});
test('returns true when a proxy applies to the target', () => {
process.env.HTTP_PROXY = PROXY_URL;
expect(isProxyRequired('http://api.example.com:8080/test')).toBe(true);
});
test('returns false when no proxy is configured', () => {
expect(isProxyRequired('http://api.example.com:8080/test')).toBe(false);
});
test('returns false when the target is excluded by NO_PROXY', () => {
process.env.HTTP_PROXY = PROXY_URL;
process.env.NO_PROXY = 'api.example.com';
expect(isProxyRequired('http://api.example.com:8080/test')).toBe(false);
});
});

View File

@ -14,6 +14,7 @@ export {
createHttpProxyAgent,
createHttpsProxyAgent,
hasProxyEnvironmentVariables,
isProxyRequired,
resolveProxyUrl,
} from './proxy-resolution';
export { ensureHostsBypassProxy } from './no-proxy-patch';

View File

@ -24,6 +24,15 @@ export function resolveProxyUrl(
return getProxyForUrl(targetUrl ?? fallbackUrl ?? '') || undefined;
}
/**
* Whether an environment proxy applies to the given target URL, i.e. whether
* {@link resolveProxyUrl} resolves to a proxy for it. For callers that only
* need the yes/no (e.g. to pick a proxy mode) rather than the proxy URL itself.
*/
export function isProxyRequired(targetUrl: string | undefined, fallbackUrl?: string): boolean {
return resolveProxyUrl(targetUrl, fallbackUrl) !== undefined;
}
/**
* DI-free proxy resolution and Node proxy-agent factory.
*

View File

@ -11,6 +11,7 @@ import type { LookupFunction } from 'node:net';
*/
export const passthroughEgressFilter: NodeEgressFilter = {
validateUrl: async () => await Promise.resolve(createResultOk(undefined)),
validateConnectionHost: () => createResultOk(undefined),
// Same function reference every call, so callers can compare it by identity.
createSecureLookup: (): LookupFunction => lookup,
// No egress policy configured, so every redirect target is allowed.

View File

@ -26,6 +26,7 @@ export type {
DispatcherTransport,
CreateDispatcherTransportOptions,
RequestAuthorizer,
TransportSsrfPolicy,
TransportTimeoutOptions,
} from './http/undici/transport';
export type { ProxyOption, ProxyUrl, SsrfOption } from './http/node-agents';

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "1.40.0",
"version": "1.40.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/blob-storage",
"version": "1.8.0",
"version": "1.8.1",
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat-hub",
"version": "1.33.0",
"version": "1.33.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.22.0",
"version": "1.22.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.48.0",
"version": "0.48.1",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.40.0",
"version": "1.40.1",
"scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.40.0",
"version": "1.40.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -29,6 +29,7 @@ import { NoRepositoryInPublicApiHandlerRule } from './no-repository-in-public-ap
import { RequirePublicApiControllerRule } from './require-public-api-controller.js';
import { NoLegacyCipherMethodsRule } from './no-legacy-cipher-methods.js';
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js';
import { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js';
import { NoMisplacedCipherPrimitivesRule } from './no-misplaced-cipher-primitives.js';
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
@ -65,6 +66,7 @@ export const rules = {
'require-public-api-controller': RequirePublicApiControllerRule,
'no-legacy-cipher-methods': NoLegacyCipherMethodsRule,
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
'require-escaped-query-values': RequireEscapedQueryValuesRule,
'no-on-leader-takeover': NoOnLeaderTakeoverRule,
'no-misplaced-cipher-primitives': NoMisplacedCipherPrimitivesRule,
'no-deployment-key-delete': NoDeploymentKeyDeleteRule,

View File

@ -0,0 +1,217 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireEscapedQueryValuesRule } from './require-escaped-query-values.js';
const ruleTester = new RuleTester();
const IMPORT =
"import { escapeBackslashQuotedValue, escapeODataSearchValue, escapeODataValue } from '@utils/query-escaping';\n";
/** Prefixes the escaper import the rule requires before trusting a call. */
const withImport = (code: string) => IMPORT + code;
ruleTester.run('require-escaped-query-values', RequireEscapedQueryValuesRule, {
valid: [
{ code: withImport("qs.$filter = `displayName eq '${escapeODataValue(filter)}'`;") },
{
code: withImport(
"const qs = { q: `name contains '${escapeBackslashQuotedValue(filter)}'` };",
),
},
{ code: withImport("query.push(`name contains '${escapeBackslashQuotedValue(filter)}'`);") },
{ code: withImport("qs.$filter += ` AND contains(subject, '${escapeODataValue(value)}')`;") },
{ code: withImport("const query = `email LIKE '${escapeBackslashQuotedValue(email)}'`;") },
// Escaped one hop back, through a `const`.
{
code: withImport(`const filterValue = escapeODataValue(encodeURI(filter));
qs.$filter = \`contains(displayName, '\${filterValue}')\`;`),
},
// Escaped in both arms of a conditional.
{
code: withImport("const q = `name contains '${filter ? escapeODataValue(filter) : ''}'`;"),
},
// A module constant is fixed at build time.
{
code: `import { DRIVE } from './interfaces';
query.push(\`mimeType = '\${DRIVE.FOLDER}'\`);`,
},
{
code: `import { FOLDER_MIME } from './interfaces';
query.push(\`mimeType = '\${FOLDER_MIME}'\`);`,
},
// Escaped through the constructs isNeutralised looks past.
{ code: withImport("qs.$filter = `eq '${escapeODataValue(a) || escapeODataValue(b)}'`;") },
{ code: withImport("qs.$filter = `eq '${escapeODataValue(x) as string}'`;") },
{ code: withImport("qs.$filter = `eq '${escapeODataValue(x)!}'`;") },
// A literal with no quote of its own cannot end the literal it sits in.
{ code: 'qs.$filter = `eq \'${"ab"}\'`;' },
// An alias of a sanctioned helper is still that helper.
{
code: `import { escapeODataValue as esc } from '@utils/query-escaping';
qs.$filter = \`eq '\${esc(filter)}'\`;`,
},
// A quote of the other kind inside a literal is data, not a delimiter.
{ code: withImport("qs.q = `name = \"it's\" and owner = '${escapeODataValue(ownerId)}'`;") },
// `satisfies` is transparent, like `as` and `!`.
{ code: withImport("qs.$filter = `eq '${escapeODataValue(x)}'` satisfies string;") },
{ code: "qs.q = `owner = ${ownerId} and name = 'x'`;" },
// Double-quoted literals count too, e.g. an OData `$search` phrase.
{ code: withImport('qs.$search = `"displayName:${escapeODataSearchValue(filter)}"`;') },
// Outside a quoted literal the value cannot close one, so the language's
// own syntax applies and escaping is not what protects it.
{ code: "qs.$filter = `contains(${nameProperty}, 'literal')`;" },
{ code: 'qs.sysparm_query = `table_name=${tableName}`;' },
// Not a query sink.
{ code: "throw new Error(`The resource '${resource}' is unknown`);" },
{ code: "const message = `Column '${key}' does not exist`;" },
{ code: "items.push(`field '${name}' is missing`);" },
{ code: "const body = { subject: `Re: '${subject}'` };" },
// A quote already closed by the time the value is interpolated.
{ code: "const q = `name = 'literal' and owner = ${ownerId}`;" },
// Escaped quotes in the template do not open a literal.
{ code: "const q = `label = \\'x\\' and owner = ${ownerId}`;" },
],
invalid: [
{
code: "qs.$filter = `displayName eq '${filter}'`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "Object.assign(body, { query: `email LIKE '${email}' ` });",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "query.push(`name contains '${filter}'`);",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "const q = filter ? `name contains '${filter}'` : undefined;",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "qs['$filter'] = `fields/Title eq '${filter}'`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
// Both interpolations sit inside a literal, so both are reported.
{
code: "qs.$filter = `startsWith(displayName, '${filter}') OR startsWith(mail, '${filter}')`;",
errors: [{ messageId: 'escapeQueryValue' }, { messageId: 'escapeQueryValue' }],
},
{
code: "const query = `name contains '${prefix}${suffix}'`;",
errors: [{ messageId: 'escapeQueryValue' }, { messageId: 'escapeQueryValue' }],
},
// A `let` may be reassigned, so following it back proves nothing.
{
code: withImport(`let filterValue = escapeODataValue(filter);
qs.$filter = \`displayName eq '\${filterValue}'\`;`),
errors: [{ messageId: 'escapeQueryValue' }],
},
// Only one arm of the conditional is escaped.
{
code: withImport(
"const q = `name contains '${filter ? filter : escapeODataValue(fallback)}'`;",
),
errors: [{ messageId: 'escapeQueryValue' }],
},
// encodeURI leaves single quotes untouched, so it is not an escape.
{
code: "qs.$filter = `contains(subject, '${encodeURI(filter)}')`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "qs.$filter = `contains(subject, '${encodeURIComponent(filter)}')`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: 'qs.$search = `"displayName:${filter}"`;',
errors: [{ messageId: 'escapeQueryValue' }],
},
// The sink is still found when reached through a choosing expression or a cast.
{
code: "const q = fallback || `name contains '${filter}'`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "qs.$filter = `eq '${filter}'` as string;",
errors: [{ messageId: 'escapeQueryValue' }],
},
// Only one arm of the logical expression is escaped.
{
code: withImport("qs.$filter = `eq '${a || escapeODataValue(b)}'`;"),
errors: [{ messageId: 'escapeQueryValue' }],
},
// The single quote inside the double-quoted literal must not be mistaken for
// a delimiter — the interpolation below it really is inside a literal.
{
code: "qs.q = `name = \"it's\" and owner = '${ownerId}'`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
// An escaper for a different dialect does not make this value safe.
{
code: "query.push(`name contains '${escapeSqlIdentifier(filter)}'`);",
errors: [{ messageId: 'escapeQueryValue' }],
},
// The sink is still found through a `satisfies` wrapper.
{
code: "qs.$filter = `eq '${filter}'` satisfies string;",
errors: [{ messageId: 'escapeQueryValue' }],
},
// A literal carrying the active quote would end the literal it sits in.
{
code: "qs.$filter = `eq '${\"a'b\"}'`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
// Same through a const, which is the shape that actually occurs.
{
code: `const DEFAULT_NAME = "it's";
qs.$filter = \`eq '\${DEFAULT_NAME}'\`;`,
errors: [{ messageId: 'escapeQueryValue' }],
},
// The local spelling does not matter — this is `noop` under an approved name.
{
code: `import { noop as escapeODataValue } from './helpers';
qs.$filter = \`eq '\${escapeODataValue(filter)}'\`;`,
errors: [{ messageId: 'escapeQueryValue' }],
},
// An approved name reached through a member access proves nothing.
{
code: `const evil = { escapeODataValue: (v: string) => v };
qs.$filter = \`eq '\${evil.escapeODataValue(filter)}'\`;`,
errors: [{ messageId: 'escapeQueryValue' }],
},
{
code: "qs.$filter = `eq '${this.escapeODataValue(filter)}'`;",
errors: [{ messageId: 'escapeQueryValue' }],
},
// A local function with an approved name is not the approved escaper.
{
code: `const escapeODataValue = (v: string) => v;
qs.$filter = \`eq '\${escapeODataValue(filter)}'\`;`,
errors: [{ messageId: 'escapeQueryValue' }],
},
// A property of a bound parameter is not a module constant.
{
code: `const options = this.getNodeParameter('options', i);
query.push(\`mimeType = '\${options.fileType}'\`);`,
errors: [{ messageId: 'escapeQueryValue' }],
},
// `String.replace` with a string pattern replaces only the first match.
{
code: `query.push(\`name contains '\${filter.replace("'", "\\\\'")}'\`);`,
errors: [{ messageId: 'escapeQueryValue' }],
},
],
});

View File

@ -0,0 +1,285 @@
import { ASTUtils, ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
import type { RuleContext } from '@typescript-eslint/utils/ts-eslint';
type MessageIds = 'escapeQueryValue';
type Options = [];
/** A template literal reaching one of these is treated as a query being assembled. */
const QUERY_SINKS = new Set([
'$filter',
'$search',
'filter',
'jql',
'q',
'query',
'sql',
'sysparm_query',
'where',
]);
/** Names of arrays whose entries are joined into a query. */
const QUERY_PART_SINKS = new Set(['conditions', 'filterString', 'filters', 'query']);
/**
* The sanctioned escapers, by name. A prefix pattern such as `/^escape/` would
* accept any similarly named function, including one for a different query
* language or one that escapes identifiers rather than values — so a new dialect
* helper has to be added here deliberately.
*
* `encodeURI`/`encodeURIComponent` are absent on purpose: neither encodes a
* quote, so neither keeps a value inside its literal.
*/
const ESCAPERS = new Set([
'escapeBackslashQuotedValue',
'escapeCognitoFilterValue',
'escapeSgqlLikeValue',
'escapeFilterValue',
'escapeODataSearchValue',
'escapeODataValue',
'escapeSoqlString',
'escapeSqlString',
]);
// Deliberately absent: `escapeSqlIdentifier`, which escapes a backtick. That
// makes a value safe as an identifier, not inside a quoted string literal.
/** How far to follow `const` bindings back to their initialiser. */
const MAX_BINDING_DEPTH = 5;
type OpenQuote = "'" | '"';
type OpenLiteral = OpenQuote | null;
/**
* Counting each quote character independently would be wrong: query languages
* use both (OData `$filter` quotes with `'`, `$search` with `"`), and a `'`
* inside a `"…"` literal is data, not a delimiter.
*
* Reads the cooked text, because that is what the query language receives — a
* `\'` written in the template source is a plain quote by then, and a backslash
* the query language itself should treat as an escape is written `\\'`.
*/
function scanLiteral(text: string, open: OpenLiteral): OpenLiteral {
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (open !== null && char === '\\') {
i++; // an escaped character cannot close the literal
} else if (open === null) {
if (char === "'" || char === '"') open = char;
} else if (char === open) {
open = null;
}
}
return open;
}
function isNeutralised(
node: TSESTree.Node,
open: OpenQuote,
getScope: (
node: TSESTree.Node,
) => ReturnType<RuleContext<MessageIds, Options>['sourceCode']['getScope']>,
depth = 0,
): boolean {
// Report rather than pass when the chain gets too long to follow: an unread
// value is not a safe one.
if (depth >= MAX_BINDING_DEPTH) return false;
switch (node.type) {
// A literal contributes no runtime value, but it can still carry the quote
// that would end the literal it sits in.
case 'Literal':
return !String(node.value).includes(open);
case 'CallExpression': {
// Only a bare call to an imported helper counts. A member access such as
// `helpers.escapeODataValue(x)` carries an approved name but proves nothing
// about what it calls, so it is not accepted.
if (node.callee.type !== 'Identifier') return false;
const variable = ASTUtils.findVariable(getScope(node.callee), node.callee.name);
const binding = variable?.defs.find((definition) => definition.type === 'ImportBinding');
if (binding === undefined) return false;
// Match the name the helper is exported under rather than its local
// spelling, so an alias still counts and a same-named local does not.
const specifier = binding.node;
if (specifier.type !== 'ImportSpecifier' || specifier.imported.type !== 'Identifier') {
return false;
}
return ESCAPERS.has(specifier.imported.name);
}
// Assumed fixed at build time. Proving it would need cross-file analysis, so
// an import initialised from a runtime call is accepted here — same
// assumption as no-dynamic-regexp.
case 'MemberExpression':
return !node.computed && isNeutralised(node.object, open, getScope, depth + 1);
case 'Identifier': {
const variable = ASTUtils.findVariable(getScope(node), node.name);
if (!variable || variable.defs.length !== 1) return false;
const [definition] = variable.defs;
if (definition.type === 'ImportBinding') return true;
if (definition.type !== 'Variable' || definition.parent.kind !== 'const') return false;
const init = definition.node.type === 'VariableDeclarator' ? definition.node.init : null;
return init !== null && isNeutralised(init, open, getScope, depth + 1);
}
case 'ConditionalExpression':
return (
isNeutralised(node.consequent, open, getScope, depth + 1) &&
isNeutralised(node.alternate, open, getScope, depth + 1)
);
case 'LogicalExpression':
return (
isNeutralised(node.left, open, getScope, depth + 1) &&
isNeutralised(node.right, open, getScope, depth + 1)
);
// TypeScript-only wrappers that do not change the runtime value.
case 'TSAsExpression':
case 'TSNonNullExpression':
case 'TSSatisfiesExpression':
return isNeutralised(node.expression, open, getScope, depth + 1);
default:
return false;
}
}
function propertyName(node: TSESTree.MemberExpression): string | undefined {
if (!node.computed && node.property.type === 'Identifier') return node.property.name;
if (
node.computed &&
node.property.type === 'Literal' &&
typeof node.property.value === 'string'
) {
return node.property.value;
}
return undefined;
}
/**
* Climbs past expressions that only choose between values, so a query reached
* through one — `q: filter ? \`...\` : undefined` — is still recognised.
*/
function effectiveParent(node: TSESTree.Node): TSESTree.Node | undefined {
let parent: TSESTree.Node | undefined = node.parent;
while (
parent?.type === 'ConditionalExpression' ||
parent?.type === 'LogicalExpression' ||
parent?.type === 'TSAsExpression' ||
parent?.type === 'TSNonNullExpression' ||
parent?.type === 'TSSatisfiesExpression'
) {
parent = parent.parent;
}
return parent;
}
function isQuerySink(node: TSESTree.TemplateLiteral): boolean {
const parent = effectiveParent(node);
if (!parent) return false;
switch (parent.type) {
// `{ query: `...` }`
case 'Property':
return (
(parent.key.type === 'Identifier' && QUERY_SINKS.has(parent.key.name)) ||
(parent.key.type === 'Literal' &&
typeof parent.key.value === 'string' &&
QUERY_SINKS.has(parent.key.value))
);
// `qs.$filter = `...`` / `qs.$filter += `...``
case 'AssignmentExpression': {
if (parent.left.type !== 'MemberExpression') return false;
const name = propertyName(parent.left);
return name !== undefined && QUERY_SINKS.has(name);
}
// `const q = `...``
case 'VariableDeclarator':
return (
parent.id.type === 'Identifier' &&
(QUERY_SINKS.has(parent.id.name) || QUERY_PART_SINKS.has(parent.id.name))
);
// `query.push(`...`)`
case 'CallExpression':
return (
parent.callee.type === 'MemberExpression' &&
propertyName(parent.callee) === 'push' &&
parent.callee.object.type === 'Identifier' &&
QUERY_PART_SINKS.has(parent.callee.object.name)
);
default:
return false;
}
}
export const RequireEscapedQueryValuesRule = ESLintUtils.RuleCreator.withoutDocs<
Options,
MessageIds
>({
meta: {
type: 'problem',
docs: {
// Coverage boundary, so this is not mistaken for a complete gate: the rule
// only inspects a template literal that lands directly in a recognised
// sink, and only proves safety from the interpolated expression itself. It
// does not see a template passed as a call argument (a query inside a URL
// path), returned from a helper, built by string concatenation, assembled
// as `.map()` fragments joined later, or assigned to a `let`. Nor does it
// know about a guard on an earlier statement, such as
// `assertNoQueryDelimiters`. Those need a test, not this rule.
//
// It also checks only that a value went through an approved escaper, not
// that the escaper matches the sink's dialect. A sink name does not carry
// one: `filterString` is used by an OData `$filter` builder, which doubles
// a quote, and by the Cognito `ListUsers` filter, which backslash-escapes
// it; `q` by both the Google Drive query language and SOQL. Picking the
// dialect stays a review judgement.
description:
"Require values interpolated into a quoted literal of a third-party query language to go through that language's escape helper.",
},
schema: [],
messages: {
escapeQueryValue:
'A value interpolated into a quoted query literal must go through the escape helper for that query language (@utils/query-escaping). Disable this rule on the line if the value provably cannot contain a quote.',
},
},
defaultOptions: [],
create(context) {
const sourceCode = context.sourceCode;
return {
TemplateLiteral(node) {
if (!isQuerySink(node)) return;
let open: OpenLiteral = null;
node.quasis.forEach((quasi, index) => {
open = scanLiteral(quasi.value.cooked ?? quasi.value.raw, open);
const expression = node.expressions[index];
// Only an interpolation inside a quoted literal can close it.
if (!expression || open === null) return;
if (isNeutralised(expression, open, (n) => sourceCode.getScope(n))) return;
context.report({ node: expression, messageId: 'escapeQueryValue' });
});
},
};
},
});

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/expression-runtime",
"version": "0.31.0",
"version": "0.31.1",
"description": "Secure, isolated expression evaluation runtime for n8n",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",

View File

@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';
import { extendedFunctions } from '../function-extensions';
const zip = extendedFunctions.zip;
const toPathSegment = extendedFunctions.toPathSegment;
describe('zip', () => {
it('should pair keys with values', () => {
@ -21,3 +22,13 @@ describe('zip', () => {
expect(({} as Record<string, unknown>).marker).toBeUndefined();
});
});
describe('toPathSegment', () => {
it('encodes a value as one path segment', () => {
expect(toPathSegment('a/b c')).toBe('a%2Fb%20c');
});
it.each(['', '.', '..', null, undefined])('rejects %s', (input) => {
expect(() => toPathSegment(input)).toThrow('Invalid identifier');
});
});

View File

@ -43,6 +43,19 @@ const not = (value: unknown): boolean => {
return !value;
};
function toPathSegment(input: unknown): string {
if (input === null || input === undefined) {
throw new ExpressionExtensionError('Invalid identifier: a value is required');
}
const value = String(input);
if (value === '' || value === '.' || value === '..') {
throw new ExpressionExtensionError(`Invalid identifier: "${value}" is not allowed`);
}
return encodeURIComponent(value);
}
function ifEmpty<T, V>(value: V, defaultValue: T) {
if (arguments.length !== 2) {
// DIVERGENCE from packages/workflow/src/extensions/extended-functions.ts:
@ -86,6 +99,7 @@ export const extendedFunctions = {
average,
numberList,
zip,
toPathSegment,
$min: min,
$max: max,
$average: average,

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.25.0",
"version": "1.25.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-apps",
"version": "0.17.0",
"version": "0.17.1",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.49.0",
"version": "0.49.1",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-engine-compatibility",
"version": "0.8.0",
"version": "0.8.1",
"description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -15,27 +15,11 @@ export type SecretKeysConfig = {
customRegex?: string[];
};
/**
* Common key prefixes used in secret keys.
*/
const COMMON_KEY_PREFIXES = [
'key-',
'sk-',
'sk_',
'pk_',
'pk-',
'ghp_',
'AKIA',
'xox',
'SG.',
'hf_',
'api-',
'apikey-',
'token-',
'secret-',
'SHA:',
'Bearer ',
];
// Vendor prefixes stay detected even with a file suffix. Generic prefixes
// remain allowlisted so filenames like api-client.ts are not flagged.
const VENDOR_KEY_PREFIXES = ['sk-', 'sk_', 'pk_', 'pk-', 'ghp_', 'AKIA', 'xox', 'SG.', 'hf_'];
const GENERIC_KEY_PREFIXES = ['key-', 'api-', 'apikey-', 'token-', 'secret-', 'SHA:', 'Bearer '];
const COMMON_KEY_PREFIXES = [...VENDOR_KEY_PREFIXES, ...GENERIC_KEY_PREFIXES];
/**
* File extensions to ignore when strict_mode is False.
@ -182,7 +166,13 @@ function containsAllowedPattern(text: string): boolean {
`^[^\\s]*(${ALLOWED_EXTENSIONS.map((ext) => ext.replace('.', '\\.')).join('|')})$`,
'i',
);
return extPattern.test(text);
if (!extPattern.test(text)) {
return false;
}
if (VENDOR_KEY_PREFIXES.some((prefix) => text.startsWith(prefix))) {
return false;
}
return true;
}
/**

View File

@ -29,4 +29,62 @@ describe('secretKeys guardrail', () => {
expect(result.tripwireTriggered).toBe(true);
expect(result.info?.maskEntities?.SECRET).toContain('custom-secret-1234');
});
it('detects vendor-prefixed keys that end with a file suffix in balanced mode', () => {
const config: SecretKeysConfig = {
threshold: 'balanced',
customRegex: [],
};
const secrets = [
'sk-proj-AbCdEfGh1234567890xyzQRSTUVWXYZ.txt',
'AKIAABCDEFGHIJKLMNOP.json',
'ghp_1234567890AbCdEfGhIjKlMnOpQrStUvWx.log',
];
for (const secret of secrets) {
const result = secretKeysCheck(secret, config);
expect(result.tripwireTriggered).toBe(true);
expect(result.info?.maskEntities?.SECRET).toContain(secret);
}
});
it('does not treat generic filenames as secrets in balanced mode', () => {
const config: SecretKeysConfig = {
threshold: 'balanced',
customRegex: [],
};
for (const filename of ['api-client.ts', 'config.json', 'key-value.json']) {
const result = secretKeysCheck(filename, config);
expect(result.tripwireTriggered).toBe(false);
}
});
it('detects vendor-prefixed keys without a file suffix in balanced mode', () => {
const config: SecretKeysConfig = {
threshold: 'balanced',
customRegex: [],
};
const secret = 'sk-proj-AbCdEfGh1234567890xyzQRSTUVWXYZ';
const result = secretKeysCheck(secret, config);
expect(result.tripwireTriggered).toBe(true);
expect(result.info?.maskEntities?.SECRET).toContain(secret);
});
it('detects vendor-prefixed keys that end with a file suffix in strict mode', () => {
const config: SecretKeysConfig = {
threshold: 'strict',
customRegex: [],
};
const secret = 'sk-proj-AbCdEfGh1234567890xyzQRSTUVWXYZ.txt';
const result = secretKeysCheck(secret, config);
expect(result.tripwireTriggered).toBe(true);
expect(result.info?.maskEntities?.SECRET).toContain(secret);
});
});

View File

@ -157,7 +157,7 @@ export class EmbeddingsAzureOpenAi implements INodeType {
configuration: {
baseURL: foundryURL,
fetchOptions: {
dispatcher: getProxyAgent(foundryURL, {}),
dispatcher: getProxyAgent(foundryURL, {}, this.helpers.getSecureEgressFilter()),
},
},
...options,
@ -184,6 +184,7 @@ export class EmbeddingsAzureOpenAi implements INodeType {
dispatcher: getProxyAgent(
credentials.endpoint ?? `https://${credentials.resourceName}.openai.azure.com`,
{},
this.helpers.getSecureEgressFilter(),
),
},
},

View File

@ -152,7 +152,7 @@ export class EmbeddingsNvidia implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {}),
dispatcher: getProxyAgent(credentials.url, {}, this.helpers.getSecureEgressFilter()),
},
};

View File

@ -64,14 +64,14 @@ export class EmbeddingsOllama implements INodeType {
}
: undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const egressFilter = this.helpers.getSecureEgressFilter();
const embeddings = new OllamaEmbeddings({
baseUrl,
model: modelName,
headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }),
await proxyFetch({ input, init, egressFilter }),
});
return {

View File

@ -30,17 +30,17 @@ const nodeDef: INode = {
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* The filter may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
function filterReachedProxyFetch(filter: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
arg === filter ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
(arg as { egressFilter?: unknown }).egressFilter === filter),
),
);
}
@ -66,10 +66,11 @@ describe('EmbeddingsOllama', () => {
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
it('routes requests through the egress filter', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
validateRedirectSync: vi.fn(),
};
@ -77,8 +78,6 @@ describe('EmbeddingsOllama', () => {
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const embeddingsOptions = MockedOllamaEmbeddings.mock.calls[0][0] as
| { fetch?: typeof fetch }
| undefined;
@ -87,12 +86,13 @@ describe('EmbeddingsOllama', () => {
await embeddingsOptions?.fetch?.('http://ollama.example.com:11434/api/embed', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
expect(filterReachedProxyFetch(egressFilter)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
};
@ -112,6 +112,7 @@ describe('EmbeddingsOllama', () => {
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
};

View File

@ -260,7 +260,11 @@ export class EmbeddingsOpenAi implements INodeType {
}
configuration.fetchOptions = {
dispatcher: getProxyAgent(configuration.baseURL ?? 'https://api.openai.com/v1', {}),
dispatcher: getProxyAgent(
configuration.baseURL ?? 'https://api.openai.com/v1',
{},
this.helpers.getSecureEgressFilter(),
),
};
configuration.defaultHeaders = mergeCustomHeaders(

View File

@ -679,7 +679,7 @@ export class LmChatAnthropic implements INodeType {
// undici v7 and the SDK's bundled fetch types disagree structurally
// (FormData iterators), so the dispatcher cannot carry its own type here.
fetchOptions: {
dispatcher: getProxyAgent(baseURL),
dispatcher: getProxyAgent(baseURL, undefined, this.helpers.getSecureEgressFilter()),
} as NonNullable<ChatAnthropicInput['clientOptions']>['fetchOptions'],
};

View File

@ -45,15 +45,16 @@ describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn();
const egressFilter = {
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
beforeEach(() => {
mockContext = {
getCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter),
},
} as unknown as Mocked<ILoadOptionsFunctions>;
@ -84,7 +85,7 @@ describe('searchModels', () => {
'anthropic-version': '2023-06-01',
}),
}),
lookup: secureLookup,
egressFilter,
});
expect(result.results).toHaveLength(5);
});

View File

@ -9,7 +9,7 @@ export async function searchModels(
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('anthropicApi');
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const egressFilter = this.helpers.getSecureEgressFilter();
const baseURL = (credentials.url as string) ?? 'https://api.anthropic.com';
// Shared with the agents model catalog: endpoint, auth, and newest-first
@ -20,7 +20,7 @@ export async function searchModels(
apiKey: (credentials.apiKey as string) ?? '',
baseURL,
headers: mergeCustomHeaders(credentials, {}),
fetch: async (input, init) => await proxyFetch({ input, init, lookup }),
fetch: async (input, init) => await proxyFetch({ input, init, egressFilter }),
});
return {

View File

@ -104,7 +104,11 @@ export class LmChatLemonade implements INodeType {
}
configuration.fetchOptions = {
dispatcher: getProxyAgent(configuration.baseURL ?? '', {}),
dispatcher: getProxyAgent(
configuration.baseURL ?? '',
{},
this.helpers.getSecureEgressFilter(),
),
};
const model = new ChatOpenAI({

View File

@ -77,9 +77,9 @@ export class LmChatOllama implements INodeType {
}
: undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const egressFilter = this.helpers.getSecureEgressFilter();
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup });
await proxyFetch({ input, init, egressFilter });
const model = new ChatOllama({
...options,

View File

@ -30,17 +30,17 @@ const nodeDef: INode = {
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* The filter may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
function filterReachedProxyFetch(filter: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
arg === filter ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
(arg as { egressFilter?: unknown }).egressFilter === filter),
),
);
}
@ -67,10 +67,11 @@ describe('LmChatOllama', () => {
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
it('routes requests through the egress filter', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
validateRedirectSync: vi.fn(),
};
@ -78,20 +79,19 @@ describe('LmChatOllama', () => {
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const [modelOptions] = MockedChatOllama.mock.calls[0];
expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/chat', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
expect(filterReachedProxyFetch(egressFilter)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
};
@ -111,6 +111,7 @@ describe('LmChatOllama', () => {
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
};

View File

@ -779,10 +779,14 @@ export class LmChatOpenAi implements INodeType {
const timeout = options.timeout;
configuration.fetchOptions = {
dispatcher: getProxyAgent(configuration.baseURL ?? 'https://api.openai.com/v1', {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
configuration.baseURL ?? 'https://api.openai.com/v1',
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
};
const customHeader = getCustomCredentialHeader(credentials);
configuration.defaultHeaders = mergeCustomHeaders(

View File

@ -35,6 +35,10 @@ describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn();
const egressFilter = {
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
beforeEach(() => {
mockContext = {
@ -51,10 +55,7 @@ describe('searchModels', () => {
parameters: {},
} as INode),
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter),
},
} as unknown as Mocked<ILoadOptionsFunctions>;
@ -82,7 +83,7 @@ describe('searchModels', () => {
init: expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }),
}),
lookup: secureLookup,
egressFilter,
});
expect(result.results).toEqual(OFFICIAL_API_RESULTS);
});

View File

@ -18,7 +18,7 @@ export async function searchModels(
}
const baseURL = baseUrlOverride || (credentials.url as string) || 'https://api.openai.com/v1';
const { openAiDefaultHeaders } = Container.get(AiConfig);
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const egressFilter = this.helpers.getSecureEgressFilter();
const headers = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
// Shared with the agents model catalog: endpoint, auth, chat-model filtering
@ -27,7 +27,7 @@ export async function searchModels(
apiKey: credentials.apiKey as string,
baseURL,
headers,
fetch: async (input, init) => await proxyFetch({ input, init, lookup }),
fetch: async (input, init) => await proxyFetch({ input, init, egressFilter }),
});
return {

View File

@ -74,7 +74,7 @@ export class LmOllama implements INodeType {
}
: undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const egressFilter = this.helpers.getSecureEgressFilter();
const model = new Ollama({
baseUrl,
@ -84,7 +84,7 @@ export class LmOllama implements INodeType {
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }),
await proxyFetch({ input, init, egressFilter }),
});
return {

View File

@ -30,17 +30,17 @@ const nodeDef: INode = {
};
/**
* The lookup may travel to `proxyFetch` as a positional argument or as a field
* The filter may travel to `proxyFetch` as a positional argument or as a field
* of an options object, so accept either shape.
*/
function lookupReachedProxyFetch(lookup: unknown): boolean {
function filterReachedProxyFetch(filter: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) =>
call.some(
(arg) =>
arg === lookup ||
arg === filter ||
(typeof arg === 'object' &&
arg !== null &&
(arg as { lookup?: unknown }).lookup === lookup),
(arg as { egressFilter?: unknown }).egressFilter === filter),
),
);
}
@ -67,10 +67,11 @@ describe('LmOllama', () => {
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
});
it('routes requests through the egress filter lookup', async () => {
it('routes requests through the egress filter', async () => {
const secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
validateRedirectSync: vi.fn(),
};
@ -78,20 +79,19 @@ describe('LmOllama', () => {
await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const modelOptions = MockedOllama.mock.calls[0][0] as { fetch?: typeof fetch } | undefined;
expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/generate', {});
expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true);
expect(filterReachedProxyFetch(egressFilter)).toBe(true);
});
it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
};
@ -111,6 +111,7 @@ describe('LmOllama', () => {
it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
};

View File

@ -265,10 +265,14 @@ export class LmOpenAi implements INodeType {
const timeout = options.timeout;
const configuration: ClientOptions = {
fetchOptions: {
dispatcher: getProxyAgent(options.baseURL ?? 'https://api.openai.com/v1', {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
options.baseURL ?? 'https://api.openai.com/v1',
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
defaultHeaders,
};

View File

@ -247,10 +247,14 @@ export class LmChatAlibabaCloud implements INodeType {
const configuration: ClientOptions = {
baseURL,
fetchOptions: {
dispatcher: getProxyAgent(baseURL, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
baseURL,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -172,6 +172,7 @@ describe('LmChatAlibabaCloud', () => {
headersTimeout: undefined,
bodyTimeout: undefined,
}),
ctx.helpers.getSecureEgressFilter(),
);
});
@ -191,6 +192,7 @@ describe('LmChatAlibabaCloud', () => {
headersTimeout: 120000,
bodyTimeout: 120000,
}),
ctx.helpers.getSecureEgressFilter(),
);
});

View File

@ -107,10 +107,14 @@ export class LmChatAzureOpenAi implements INodeType {
const configuration: ClientOptions = {
baseURL: foundryURL,
fetchOptions: {
dispatcher: getProxyAgent(foundryURL, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
foundryURL,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};
if (modelConfig.azureADTokenProvider) {
@ -161,6 +165,7 @@ export class LmChatAzureOpenAi implements INodeType {
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
},

View File

@ -74,7 +74,11 @@ describe('LmChatAzureOpenAi', () => {
await new LmChatAzureOpenAi().supplyData.call(ctx, 0);
expect(vi.mocked(getProxyAgent)).toHaveBeenCalledWith(expectedUrl, expect.any(Object));
expect(vi.mocked(getProxyAgent)).toHaveBeenCalledWith(
expectedUrl,
expect.any(Object),
expect.any(Object),
);
},
);
});

View File

@ -365,7 +365,7 @@ export class LmChatDatabricks implements INodeType {
headersTimeout: timeout,
bodyTimeout: timeout,
},
egressFilter?.createSecureLookup(),
egressFilter,
),
},
};

View File

@ -190,11 +190,10 @@ describe('LmChatDatabricks', () => {
it('should thread the egress filter into the token provider and proxy agent', async () => {
const ctx = setupMockContext();
const secureLookup = vi.fn();
const egressFilter = {
validateUrl: vi.fn(),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
createSecureLookup: vi.fn(),
};
ctx.helpers.getSecureEgressFilter = vi.fn().mockReturnValue(egressFilter);
@ -208,7 +207,7 @@ describe('LmChatDatabricks', () => {
expect(mockedGetProxyAgent).toHaveBeenCalledWith(
'https://my.databricks.com/ai-gateway/openai/v1',
expect.any(Object),
secureLookup,
egressFilter,
);
// Every redirect hop is checked before the bearer is sent to it

View File

@ -113,6 +113,7 @@ describe('getDatabricksTokenProvider', () => {
validateUrl: vi.fn(),
validateRedirectSync: vi.fn(),
createSecureLookup: vi.fn(),
validateConnectionHost: vi.fn(),
};
const { getToken } = getDatabricksTokenProvider(mockCtx, mockCredential, egressFilter);

View File

@ -235,10 +235,14 @@ export class LmChatDeepSeek implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -174,10 +174,14 @@ export class LmChatMinimax implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -195,6 +195,7 @@ describe('LmChatMinimax', () => {
headersTimeout: undefined,
bodyTimeout: undefined,
}),
ctx.helpers.getSecureEgressFilter(),
);
});
@ -214,6 +215,7 @@ describe('LmChatMinimax', () => {
headersTimeout: 120000,
bodyTimeout: 120000,
}),
ctx.helpers.getSecureEgressFilter(),
);
});
});

View File

@ -194,9 +194,9 @@ export class LmChatMistralCloud implements INodeType {
randomSeed: undefined,
}) as Partial<ChatMistralAIInput>;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup();
const egressFilter = this.helpers.getSecureEgressFilter();
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, timeoutOptions: {}, lookup });
await proxyFetch({ input, init, timeoutOptions: {}, egressFilter });
const httpClient = new HTTPClient({ fetcher: fetchWithTimeout });
const model = new ChatMistralAI({

View File

@ -290,10 +290,14 @@ export class LmChatMoonshot implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -171,6 +171,7 @@ describe('LmChatMoonshot', () => {
headersTimeout: undefined,
bodyTimeout: undefined,
}),
ctx.helpers.getSecureEgressFilter(),
);
});
@ -190,6 +191,7 @@ describe('LmChatMoonshot', () => {
headersTimeout: 120000,
bodyTimeout: 120000,
}),
ctx.helpers.getSecureEgressFilter(),
);
});
});

View File

@ -249,10 +249,14 @@ export class LmChatNvidia implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -396,10 +396,14 @@ export class LmChatOpenRouter implements INodeType {
baseURL: credentials.url,
fetch: createOpenRouterFetch(globalThis.fetch),
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -233,10 +233,14 @@ export class LmChatVercelAiGateway implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -275,10 +275,14 @@ export class LmChatXAiGrok implements INodeType {
const configuration: ClientOptions = {
baseURL: credentials.url,
fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
credentials.url,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
};

View File

@ -426,11 +426,13 @@ describe('LmChatAnthropic', () => {
let mockLoadContext: ILoadOptionsFunctions;
let mockGetCredentials: Mock;
let fetchSpy: Mock;
let egressFilter: { createSecureLookup: Mock };
const secureLookup = vi.fn();
beforeEach(() => {
mockGetCredentials = vi.fn();
fetchSpy = vi.fn();
egressFilter = { createSecureLookup: vi.fn().mockReturnValue(secureLookup) };
vi.mocked(proxyFetch).mockImplementation(
fetchSpy as unknown as typeof import('@n8n/ai-utilities')['proxyFetch'],
);
@ -438,9 +440,7 @@ describe('LmChatAnthropic', () => {
mockLoadContext = {
getCredentials: mockGetCredentials,
helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter),
},
} as unknown as ILoadOptionsFunctions;
});
@ -495,7 +495,7 @@ describe('LmChatAnthropic', () => {
'anthropic-version': '2023-06-01',
}),
}),
lookup: secureLookup,
egressFilter,
}),
);

View File

@ -37,6 +37,7 @@ const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;
const createTestEgressFilter = (): NodeEgressFilter => ({
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(),
});

View File

@ -38,6 +38,7 @@ const MockedClient = Client as MockedClass<typeof Client>;
const createTestEgressFilter = (): NodeEgressFilter => ({
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn(),
validateRedirectSync: vi.fn(),
});
@ -921,6 +922,7 @@ describe('utils', () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultError(new Error('Egress blocked'))),
validateRedirectSync: vi.fn(),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
@ -955,6 +957,7 @@ describe('utils', () => {
const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateRedirectSync: vi.fn(),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};

View File

@ -48,6 +48,7 @@ const baseConnectionConfig: McpConnectionConfig = {
const createTestEgressFilter = (): NodeEgressFilter => ({
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn(),
validateRedirectSync: vi.fn(),
});

View File

@ -302,9 +302,9 @@ function createAuthFetch(
onUnauthorized?: OnUnauthorizedHandler,
allowedDomains?: string,
): typeof fetch {
const secureLookup = secureEgressFilter.createSecureLookup();
return createRefreshingAuthFetch({
baseFetch: async (input, init) => await proxyFetch({ input, init, lookup: secureLookup }),
baseFetch: async (input, init) =>
await proxyFetch({ input, init, egressFilter: secureEgressFilter }),
initialHeaders,
...(onUnauthorized
? {

View File

@ -8,7 +8,7 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { getSessionId } from '@utils/helpers';
import { coerceSessionIdToString, getSessionId } from '@utils/helpers';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import {
@ -167,7 +167,11 @@ export class MemoryBufferWindow implements INodeType {
if (nodeVersion >= 1.2) {
sessionId = getSessionId(this, itemIndex);
} else {
sessionId = this.getNodeParameter('sessionKey', itemIndex) as string;
sessionId = coerceSessionIdToString(
this,
this.getNodeParameter('sessionKey', itemIndex),
itemIndex,
);
}
const memory = await memoryInstance.getMemory(`${workflowId}__${sessionId}`, {

View File

@ -7,7 +7,7 @@ import {
type SupplyData,
} from 'n8n-workflow';
import { getSessionId } from '@utils/helpers';
import { coerceSessionIdToString, getSessionId } from '@utils/helpers';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import {
@ -111,7 +111,11 @@ export class MemoryMotorhead implements INodeType {
if (nodeVersion >= 1.2) {
sessionId = getSessionId(this, itemIndex);
} else {
sessionId = this.getNodeParameter('sessionId', itemIndex) as string;
sessionId = coerceSessionIdToString(
this,
this.getNodeParameter('sessionId', itemIndex),
itemIndex,
);
}
const memory = new MotorheadMemory({

View File

@ -12,7 +12,7 @@ import {
import type { RedisClientOptions } from 'redis';
import { createClient } from 'redis';
import { getSessionId } from '@utils/helpers';
import { coerceSessionIdToString, getSessionId } from '@utils/helpers';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import {
@ -122,7 +122,11 @@ export class MemoryRedisChat implements INodeType {
if (nodeVersion >= 1.2) {
sessionId = getSessionId(this, itemIndex);
} else {
sessionId = this.getNodeParameter('sessionKey', itemIndex) as string;
sessionId = coerceSessionIdToString(
this,
this.getNodeParameter('sessionKey', itemIndex),
itemIndex,
);
}
const redisOptions: RedisClientOptions = {

View File

@ -9,7 +9,7 @@ import type {
SupplyData,
} from 'n8n-workflow';
import { getSessionId } from '@utils/helpers';
import { coerceSessionIdToString, getSessionId } from '@utils/helpers';
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
import {
@ -111,7 +111,11 @@ export class MemoryXata implements INodeType {
if (nodeVersion >= 1.2) {
sessionId = getSessionId(this, itemIndex);
} else {
sessionId = this.getNodeParameter('sessionId', itemIndex) as string;
sessionId = coerceSessionIdToString(
this,
this.getNodeParameter('sessionId', itemIndex),
itemIndex,
);
}
const xataClient = new BaseClient({

View File

@ -13,7 +13,7 @@ import {
NodeOperationError,
} from 'n8n-workflow';
import { getSessionId } from '@utils/helpers';
import { coerceSessionIdToString, getSessionId } from '@utils/helpers';
import {
expressionSessionKeyProperty,
@ -137,7 +137,11 @@ export class MemoryZep implements INodeType {
if (nodeVersion >= 1.2) {
sessionId = getSessionId(this, itemIndex);
} else {
sessionId = this.getNodeParameter('sessionId', itemIndex) as string;
sessionId = coerceSessionIdToString(
this,
this.getNodeParameter('sessionId', itemIndex),
itemIndex,
);
}
let memory: BaseChatMemory;

View File

@ -239,7 +239,7 @@ async function getMcpServerConfigsWithoutAudienceTokens(
MICROSOFT_TOOL_OPTIONS,
),
},
lookup: secureEgressFilter.createSecureLookup(),
egressFilter: secureEgressFilter,
});
if (!response.ok) {

View File

@ -196,10 +196,14 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
timeout: timeout ?? 10000,
baseURL,
fetchOptions: {
dispatcher: getProxyAgent(baseURL, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
dispatcher: getProxyAgent(
baseURL,
{
headersTimeout: timeout,
bodyTimeout: timeout,
},
this.helpers.getSecureEgressFilter(),
),
},
defaultHeaders,
});

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "2.40.0",
"version": "2.40.1",
"description": "",
"main": "index.js",
"exports": {

View File

@ -79,6 +79,26 @@ function sanitizeForSessionKey(name: string): string {
return name.replace(/[^A-Za-z0-9_-]/g, '_');
}
// The session key can arrive straight from an untrusted request body (e.g. a
// public chat trigger). It is used to address stored data in the memory
// backend, so it must be a primitive value: an object or array would be
// interpreted as query structure by backends like MongoDB. Coerce numbers and
// booleans to strings and reject anything non-primitive.
export function coerceSessionIdToString(
ctx: ISupplyDataFunctions | IWebhookFunctions,
sessionId: unknown,
itemIndex: number,
): string {
if (typeof sessionId === 'string') return sessionId;
if (typeof sessionId === 'number' || typeof sessionId === 'boolean') {
return String(sessionId);
}
throw new NodeOperationError(ctx.getNode(), 'Invalid session ID', {
description: 'The session ID must be a text value',
itemIndex,
});
}
export function getSessionId(
ctx: ISupplyDataFunctions | IWebhookFunctions,
itemIndex: number,
@ -92,43 +112,48 @@ export function getSessionId(
if (selectorType === autoSelect) {
// If memory node is used in webhook like node(like chat trigger node), it doesn't have access to evaluateExpression
// so we try to extract sessionId from the bodyData
let rawSessionId: unknown;
if ('getBodyData' in ctx) {
const bodyData = ctx.getBodyData() ?? {};
sessionId = bodyData.sessionId as string;
rawSessionId = bodyData.sessionId;
} else {
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
rawSessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex);
// try to get sessionId from chat trigger
if (!sessionId || sessionId === undefined) {
if (rawSessionId === '' || rawSessionId === undefined || rawSessionId === null) {
try {
const chatTrigger = ctx.getChatTrigger();
if (chatTrigger) {
sessionId = ctx.evaluateExpression(
rawSessionId = ctx.evaluateExpression(
`{{ $('${chatTrigger.name}').first().json.sessionId }}`,
itemIndex,
) as string;
);
}
} catch (error) {}
}
}
if (sessionId === '' || sessionId === undefined) {
if (rawSessionId === '' || rawSessionId === undefined || rawSessionId === null) {
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
description:
"Expected to find the session ID in an input field called 'sessionId' (this is what the chat trigger node outputs). To use something else, change the 'Session ID' parameter",
itemIndex,
});
}
sessionId = coerceSessionIdToString(ctx, rawSessionId, itemIndex);
} else {
sessionId = ctx.getNodeParameter(customKey, itemIndex, '') as string;
if (sessionId === '' || sessionId === undefined) {
const rawSessionId = ctx.getNodeParameter(customKey, itemIndex, '');
if (rawSessionId === '' || rawSessionId === undefined || rawSessionId === null) {
throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', {
description:
"Provide a key to use as session ID in the 'Key' parameter or use the 'Connected Chat Trigger Node' option to use the session ID from your Chat Trigger",
itemIndex,
});
}
sessionId = coerceSessionIdToString(ctx, rawSessionId, itemIndex);
}
// Scoping uses the memory node's own name, so connecting a single memory

View File

@ -572,6 +572,55 @@ describe('getSessionId', () => {
expect(sessionId).toBe('12345');
});
it('should reject a non-primitive sessionId from bodyData', () => {
mockCtx.getBodyData = vi.fn();
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.getBodyData.mockReturnValue({ sessionId: { $ne: null } });
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
});
it('should reject an array sessionId from bodyData', () => {
mockCtx.getBodyData = vi.fn();
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.getBodyData.mockReturnValue({ sessionId: [{ $regex: '^a' }] });
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
});
it('should coerce a numeric sessionId from bodyData to a string', () => {
mockCtx.getBodyData = vi.fn();
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.getBodyData.mockReturnValue({ sessionId: 12345 });
const sessionId = getSessionId(mockCtx, 0);
expect(sessionId).toBe('12345');
});
it('should coerce a boolean sessionId from bodyData to a string', () => {
mockCtx.getBodyData = vi.fn();
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.getBodyData.mockReturnValue({ sessionId: false });
const sessionId = getSessionId(mockCtx, 0);
expect(sessionId).toBe('false');
});
it('should throw "No session ID found" for a null sessionId from bodyData', () => {
mockCtx.getBodyData = vi.fn();
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.getBodyData.mockReturnValue({ sessionId: null });
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
});
it('should reject a non-primitive sessionId resolved from an expression', () => {
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.evaluateExpression.mockReturnValue({ $ne: null });
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
});
it('should retrieve sessionId from chat trigger', () => {
mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.evaluateExpression.mockReturnValueOnce(undefined);

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/scheduler",
"version": "1.9.0",
"version": "1.9.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

Some files were not shown because too many files have changed in this diff Show More