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, "line": 40,
"message": "packages/nodes-base turns '@typescript-eslint/prefer-promise-reject-errors' down to 'warn' for the whole package.", "message": "packages/nodes-base turns '@typescript-eslint/prefer-promise-reject-errors' down to 'warn' for the whole package.",
"hash": "2d61f9d00a79" "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": [ "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) # [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", "name": "n8n-monorepo",
"version": "2.40.0", "version": "2.40.1",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=24.0.0", "node": ">=24.0.0",

View File

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

View File

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

View File

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

View File

@ -47,11 +47,13 @@ const makeN8nLlmFailedAttemptHandler = vi.mocked(
const N8nLlmTracing = vi.mocked(n8nLlmTracing.N8nLlmTracing); const N8nLlmTracing = vi.mocked(n8nLlmTracing.N8nLlmTracing);
describe('supplyModel', () => { describe('supplyModel', () => {
const egressFilter = { createSecureLookup: vi.fn() };
const mockCtx = { const mockCtx = {
getNode: vi.fn(), getNode: vi.fn(),
addOutputData: vi.fn(), addOutputData: vi.fn(),
addInputData: vi.fn(), addInputData: vi.fn(),
getNextRunIndex: vi.fn(), getNextRunIndex: vi.fn(),
helpers: { getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter) },
} as unknown as ISupplyDataFunctions; } as unknown as ISupplyDataFunctions;
beforeEach(() => { beforeEach(() => {
@ -190,10 +192,14 @@ describe('supplyModel', () => {
timeout: 12345, timeout: 12345,
}); });
expect(getProxyAgent).toHaveBeenCalledWith('https://api.openai.com', { expect(getProxyAgent).toHaveBeenCalledWith(
headersTimeout: 12345, 'https://api.openai.com',
bodyTimeout: 12345, {
}); headersTimeout: 12345,
bodyTimeout: 12345,
},
egressFilter,
);
expect(ChatOpenAI).toHaveBeenCalledWith( expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({ 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 { lookup as dnsLookup } from 'node:dns';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { MockedFunction } from 'vitest'; 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('@n8n/backend-network/transport', () => ({
vi.mock('undici', () => ({ buildDispatcher: vi.fn((proxy: unknown, ssrf: unknown, options: unknown) => ({
Agent: vi.fn(function (options) { type: 'Dispatcher',
return { type: 'Agent', options }; proxy,
}), ssrf,
ProxyAgent: vi.fn(function (options) { options,
return { type: 'ProxyAgent', options }; })),
}), dispatchedFetch: vi.fn(),
fetch: vi.fn(),
})); }));
const DEFAULT_AGENT_OPTIONS = { const mockBuildDispatcher = buildDispatcher as unknown as MockedFunction<typeof buildDispatcher>;
headersTimeout: 3600000, const mockDispatchedFetch = dispatchedFetch as unknown as MockedFunction<typeof dispatchedFetch>;
bodyTimeout: 3600000,
connect: { lookup: dnsLookup }, 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', () => { describe('getProxyAgent', () => {
@ -56,286 +71,228 @@ describe('getProxyAgent', () => {
}); });
describe('default behavior (no timeout options)', () => { 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', () => { it('should return a direct dispatcher when no proxy environment variables are set and no timeout options', () => {
const agent = getProxyAgent(); const agent = getProxyAgent(undefined, undefined, passthroughFilter);
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS }); expect(agent).toEqual({
expect(ProxyAgent).not.toHaveBeenCalled(); 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', () => { 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'); const agent = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS }); expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
expect(ProxyAgent).not.toHaveBeenCalled();
}); });
it('should reuse a single Agent across calls when no proxy, timeout options nor custom lookup are given', () => { 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'); const first = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
const second = getProxyAgent('https://api.anthropic.com/v1'); const second = getProxyAgent('https://api.anthropic.com/v1', undefined, passthroughFilter);
expect(second).toBe(first); expect(second).toBe(first);
}); });
it('should build a fresh Agent when timeout options or a custom lookup are given', () => { it('should keep the shared dispatcher for the passthrough egress filter', () => {
const shared = getProxyAgent('https://api.openai.com/v1'); 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, passthroughFilter)).toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, vi.fn())).not.toBe(shared);
}); });
it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => { it('should keep enforcement for a non-passthrough policy whose lookup is the system dns lookup', () => {
const proxyUrl = 'https://proxy.example.com:8080'; const filter = makeEgressFilter();
process.env.HTTPS_PROXY = proxyUrl; vi.mocked(filter.createSecureLookup).mockReturnValue(dnsLookup);
const agent = getProxyAgent(); getProxyAgent('https://api.example.com', undefined, filter);
expect(ProxyAgent).toHaveBeenCalledWith({ expect(mockBuildDispatcher).toHaveBeenCalledWith(false, filter, expect.anything());
uri: proxyUrl,
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
expect(agent).toEqual({
type: 'ProxyAgent',
options: { uri: proxyUrl, headersTimeout: 3600000, bodyTimeout: 3600000 },
});
}); });
it('should create ProxyAgent when https_proxy is set', () => { it('should build a fresh dispatcher when timeout options or an egress filter are given', () => {
const proxyUrl = 'https://proxy.example.com:8080'; const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
process.env.https_proxy = proxyUrl;
getProxyAgent(); expect(getProxyAgent('https://api.openai.com/v1', {}, passthroughFilter)).not.toBe(shared);
expect(getProxyAgent('https://api.openai.com/v1', undefined, makeEgressFilter())).not.toBe(
expect(ProxyAgent).toHaveBeenCalledWith({ shared,
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',
}),
); );
}); });
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', () => { describe('target URL provided', () => {
it('should create ProxyAgent for HTTPS URL when HTTPS_PROXY is set', () => { it('should build an env-proxied dispatcher for HTTPS URL when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
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', () => { it('should build an env-proxied dispatcher for HTTP URL when HTTP_PROXY is set', () => {
const proxyUrl = 'http://proxy.example.com:8080'; process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
getProxyAgent('http://api.example.com'); getProxyAgent('http://api.example.com', undefined, passthroughFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', expect.anything());
});
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 }));
}); });
it('should respect NO_PROXY for localhost', () => { it('should respect NO_PROXY for localhost', () => {
const proxyUrl = 'http://proxy.example.com:8080'; process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1'; 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(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
expect(ProxyAgent).not.toHaveBeenCalled();
}); });
it('should respect NO_PROXY wildcard patterns', () => { it('should respect NO_PROXY wildcard patterns', () => {
const proxyUrl = 'http://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = '*.internal.company.com,localhost'; 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(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
expect(ProxyAgent).not.toHaveBeenCalled();
}); });
it('should use proxy for URLs not in NO_PROXY', () => { it('should use proxy for URLs not in NO_PROXY', () => {
const proxyUrl = 'http://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1'; 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', () => { it('should handle mixed case environment variables', () => {
const proxyUrl = 'http://proxy.example.com:8080'; process.env.https_proxy = 'http://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
process.env.no_proxy = 'localhost'; 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', () => { describe('timeout options', () => {
it('should pass custom timeout options to ProxyAgent when proxy is set', () => { it('should pass custom timeout options when a proxy is set', () => {
const proxyUrl = 'https://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
getProxyAgent('https://api.openai.com/v1', { getProxyAgent(
headersTimeout: 120000, 'https://api.openai.com/v1',
bodyTimeout: 180000, {
}); headersTimeout: 120000,
bodyTimeout: 180000,
},
passthroughFilter,
);
expect(ProxyAgent).toHaveBeenCalledWith({ expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', {
uri: proxyUrl, timeouts: { headersTimeout: 120000, bodyTimeout: 180000 },
headersTimeout: 120000,
bodyTimeout: 180000,
}); });
}); });
it('should create Agent with timeout options when no proxy is configured', () => { it('should build a direct dispatcher with timeout options when no proxy is configured', () => {
const agent = getProxyAgent('https://api.openai.com/v1', { const agent = getProxyAgent(
headersTimeout: 120000, 'https://api.openai.com/v1',
bodyTimeout: 180000, {
}); headersTimeout: 120000,
bodyTimeout: 180000,
},
passthroughFilter,
);
expect(Agent).toHaveBeenCalledWith({ expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
headersTimeout: 120000, timeouts: { headersTimeout: 120000, bodyTimeout: 180000 },
bodyTimeout: 180000,
connect: { lookup: dnsLookup },
});
expect(agent).toEqual({
type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 180000, connect: { lookup: dnsLookup } },
}); });
expect(agent).toEqual(expect.objectContaining({ type: 'Dispatcher', proxy: false }));
}); });
it('should use default timeouts when empty timeout options object is passed', () => { 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', () => { it('should include connectTimeout when provided', () => {
getProxyAgent('https://api.openai.com/v1', { getProxyAgent(
headersTimeout: 60000, 'https://api.openai.com/v1',
bodyTimeout: 60000, {
connectTimeout: 30000, headersTimeout: 60000,
}); bodyTimeout: 60000,
connectTimeout: 30000,
},
passthroughFilter,
);
expect(Agent).toHaveBeenCalledWith({ expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
headersTimeout: 60000, timeouts: { headersTimeout: 60000, bodyTimeout: 60000, connectTimeout: 30000 },
bodyTimeout: 60000,
connectTimeout: 30000,
connect: { lookup: dnsLookup },
}); });
}); });
it('should respect custom timeout from environment variable', () => { it('should build a fresh dispatcher when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
process.env.N8N_AI_TIMEOUT_MAX = '300000'; const shared = getProxyAgent('https://api.openai.com/v1', undefined, passthroughFilter);
// 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');
process.env.N8N_AI_TIMEOUT_MAX = '120000'; 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), // 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 // 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. // 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(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 () => { it('should honor N8N_AI_TIMEOUT_MAX when there is no proxy and the caller passes no timeout options at all', async () => {
vi.resetModules(); vi.resetModules();
process.env.N8N_AI_TIMEOUT_MAX = '120000'; 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 { 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({ expect(transport.buildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
headersTimeout: 120000, timeouts: { headersTimeout: 120000, bodyTimeout: 120000 },
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
});
expect(agent).toEqual({
type: 'Agent',
options: {
headersTimeout: 120000,
bodyTimeout: 120000,
connect: { lookup: dnsLookup },
},
}); });
}); });
}); });
describe('secure lookup', () => { describe('egress filter', () => {
it('should build an Agent with the lookup on connect when no proxy is configured', () => { it('should build the dispatcher with the filter when no proxy is configured', () => {
const lookup = vi.fn(); 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(mockBuildDispatcher).toHaveBeenCalledWith(false, egressFilter, DEFAULT_BUILD_OPTIONS);
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' })); expect(agent).toEqual(expect.objectContaining({ ssrf: egressFilter }));
expect(ProxyAgent).not.toHaveBeenCalled();
}); });
it('should not attach the lookup to a ProxyAgent when a proxy is configured', () => { it('should build the dispatcher with the filter when a proxy is configured', () => {
const proxyUrl = 'https://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl; const egressFilter = makeEgressFilter();
const lookup = vi.fn();
getProxyAgent('https://api.openai.com/v1', undefined, lookup); getProxyAgent('https://api.openai.com/v1', undefined, egressFilter);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(mockBuildDispatcher).toHaveBeenCalledWith('env', egressFilter, DEFAULT_BUILD_OPTIONS);
expect(ProxyAgent).not.toHaveBeenCalledWith(
expect.objectContaining({ connect: expect.anything() }),
);
expect(Agent).not.toHaveBeenCalled();
}); });
}); });
}); });
@ -343,7 +300,6 @@ describe('getProxyAgent', () => {
describe('proxyFetch', () => { describe('proxyFetch', () => {
// Store original environment variables // Store original environment variables
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
const mockFetch = undiciFetch as unknown as MockedFunction<typeof fetch>;
// Reset environment variables and mocks before each test // Reset environment variables and mocks before each test
beforeEach(() => { beforeEach(() => {
@ -357,7 +313,7 @@ describe('proxyFetch', () => {
delete process.env.no_proxy; delete process.env.no_proxy;
// Setup default fetch mock response // Setup default fetch mock response
mockFetch.mockResolvedValue( mockDispatchedFetch.mockResolvedValue(
new Response('{}', { new Response('{}', {
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',
@ -372,44 +328,53 @@ describe('proxyFetch', () => {
}); });
describe('with no proxy configured', () => { 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'; const url = 'https://api.openai.com/v1';
await proxyFetch({ input: url, lookup: dnsLookup }); await proxyFetch({ input: url, egressFilter: passthroughFilter });
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockDispatchedFetch).toHaveBeenCalledWith(
dispatcher: expect.objectContaining({ type: 'Agent' }), expect.objectContaining({ type: 'Dispatcher', proxy: false }),
}); url,
undefined,
);
}); });
it('should reuse the same dispatcher across plain calls', async () => { 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.openai.com/v1', egressFilter: passthroughFilter });
await proxyFetch({ input: 'https://api.anthropic.com/v1', lookup: dnsLookup }); await proxyFetch({ input: 'https://api.anthropic.com/v1', egressFilter: passthroughFilter });
const [[, first], [, second]] = mockFetch.mock.calls as unknown as Array< const [[first], [second]] = mockDispatchedFetch.mock.calls;
[unknown, { dispatcher: unknown }] expect(second).toBe(first);
>;
expect(second.dispatcher).toBe(first.dispatcher);
}); });
it('should build the dispatcher with the supplied lookup', async () => { it('should build the dispatcher with the supplied egress filter', async () => {
const lookup = vi.fn(); const egressFilter = makeEgressFilter();
await proxyFetch({ input: 'https://api.openai.com/v1', lookup }); 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'; const url = 'https://api.openai.com/v1';
await proxyFetch({ await proxyFetch({
input: url, input: url,
timeoutOptions: { headersTimeout: 60000 }, timeoutOptions: { headersTimeout: 60000 },
lookup: dnsLookup, egressFilter: passthroughFilter,
}); });
expect(Agent).toHaveBeenCalled(); expect(mockBuildDispatcher).toHaveBeenCalledWith(false, 'disabled', {
expect(mockFetch).toHaveBeenCalledWith(url, { timeouts: { headersTimeout: 60000, bodyTimeout: 3600000 },
dispatcher: expect.objectContaining({ type: 'Agent' }),
}); });
expect(mockDispatchedFetch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'Dispatcher' }),
url,
undefined,
);
}); });
it('should pass through RequestInit options', async () => { it('should pass through RequestInit options', async () => {
@ -420,41 +385,55 @@ describe('proxyFetch', () => {
body: JSON.stringify({ test: 'data' }), body: JSON.stringify({ test: 'data' }),
}; };
await proxyFetch({ input: url, init, lookup: dnsLookup }); await proxyFetch({ input: url, init, egressFilter: passthroughFilter });
expect(mockFetch).toHaveBeenCalledWith(url, { expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, init);
...init,
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
}); });
it('should handle URL objects', async () => { it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1'); 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, { expect(mockDispatchedFetch).toHaveBeenCalledWith(expect.anything(), url, undefined);
dispatcher: expect.objectContaining({ type: 'Agent' }), });
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', () => { describe('with proxy configured', () => {
it('should call fetch with ProxyAgent dispatcher when proxy is set', async () => { it('should fetch with an env-proxied dispatcher when proxy is set', async () => {
const proxyUrl = 'https://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1'; 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(mockDispatchedFetch).toHaveBeenCalledWith(
expect(mockFetch).toHaveBeenCalledWith(url, { expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }), url,
}); undefined,
);
}); });
it('should pass through RequestInit options with proxy', async () => { it('should pass through RequestInit options with proxy', async () => {
const proxyUrl = 'https://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
const init: RequestInit = { const init: RequestInit = {
@ -462,72 +441,41 @@ describe('proxyFetch', () => {
headers: { Authorization: 'Bearer token123' }, 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(mockDispatchedFetch).toHaveBeenCalledWith(
expect(mockFetch).toHaveBeenCalledWith(url, { expect.objectContaining({ type: 'Dispatcher', proxy: 'env' }),
...init, url,
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }), init,
});
});
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' }) }),
); );
}); });
it('should respect NO_PROXY environment variable', async () => { it('should respect NO_PROXY environment variable', async () => {
const proxyUrl = 'http://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1'; process.env.NO_PROXY = 'localhost,127.0.0.1';
const url = 'https://localhost:3000/api'; 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(mockDispatchedFetch).toHaveBeenCalledWith(
expect(ProxyAgent).not.toHaveBeenCalled(); expect.objectContaining({ type: 'Dispatcher', proxy: false }),
expect(mockFetch).toHaveBeenCalledWith(url, { url,
dispatcher: expect.objectContaining({ type: 'Agent' }), undefined,
}); );
}); });
it('should pass timeout options to ProxyAgent when proxy is configured', async () => { it('should pass timeout options to the dispatcher when proxy is configured', async () => {
const proxyUrl = 'https://proxy.example.com:8080'; process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1'; const url = 'https://api.openai.com/v1';
await proxyFetch({ await proxyFetch({
input: url, input: url,
timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 }, timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 },
lookup: dnsLookup, egressFilter: passthroughFilter,
}); });
expect(ProxyAgent).toHaveBeenCalledWith({ expect(mockBuildDispatcher).toHaveBeenCalledWith('env', 'disabled', {
uri: proxyUrl, timeouts: { headersTimeout: 300000, bodyTimeout: 300000 },
headersTimeout: 300000,
bodyTimeout: 300000,
}); });
}); });
}); });
@ -538,21 +486,23 @@ describe('proxyFetch', () => {
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',
}); });
mockFetch.mockResolvedValueOnce(expectedResponse); mockDispatchedFetch.mockResolvedValueOnce(expectedResponse);
const url = 'https://api.openai.com/v1'; 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); expect(result).toBe(expectedResponse);
}); });
it('should propagate fetch errors', async () => { it('should propagate fetch errors', async () => {
const error = new Error('Network error'); const error = new Error('Network error');
mockFetch.mockRejectedValueOnce(error); mockDispatchedFetch.mockRejectedValueOnce(error);
const url = 'https://api.openai.com/v1'; 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 () => { it('should return error responses without throwing', async () => {
@ -560,10 +510,10 @@ describe('proxyFetch', () => {
status: 404, status: 404,
statusText: 'Not Found', statusText: 'Not Found',
}); });
mockFetch.mockResolvedValueOnce(errorResponse); mockDispatchedFetch.mockResolvedValueOnce(errorResponse);
const url = 'https://api.openai.com/v1'; 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); expect(result).toBe(errorResponse);
}); });
@ -628,7 +578,9 @@ describe('proxyFetch with the real undici', () => {
async () => (await import('undici')).Request as unknown as typeof Request, async () => (await import('undici')).Request as unknown as typeof Request,
], ],
])('should send a Request built with %s', async (_, loadRequestClass) => { ])('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(); vi.resetModules();
const { proxyFetch: realProxyFetch } = await import('../../utils/http-proxy-agent.js'); const { proxyFetch: realProxyFetch } = await import('../../utils/http-proxy-agent.js');
const RequestClass = await loadRequestClass(); const RequestClass = await loadRequestClass();
@ -655,7 +607,7 @@ describe('proxyFetch with the real undici', () => {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: '{"model":"mistral-small"}', 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(await response.text()).toBe('ok');
expect(received).toEqual({ expect(received).toEqual({

View File

@ -56,6 +56,7 @@ export {
getNodeProxyAgent, getNodeProxyAgent,
proxyFetch, proxyFetch,
type AgentTimeoutOptions, type AgentTimeoutOptions,
type EgressFilter,
type ProxyFetchOptions, type ProxyFetchOptions,
} from './utils/http-proxy-agent'; } from './utils/http-proxy-agent';
export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search'; 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'); 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; 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. * Validate that a resolved path is within the expected base directory.
* Prevents path traversal even if components pass basic validation. * Prevents path traversal even if components pass basic validation.
@ -462,6 +471,10 @@ function getNodeFilePath(
targetVersion = `v${targetVersion.slice(1).replace('.', '')}`; 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 // Check if this is a split version structure
if (isSplitVersionStructure(nodeDir, targetVersion)) { if (isSplitVersionStructure(nodeDir, targetVersion)) {
const available = getAvailableDiscriminators(nodeDir, targetVersion); const available = getAvailableDiscriminators(nodeDir, targetVersion);
@ -491,6 +504,10 @@ function getNodeFilePath(
// Flat file structure // Flat file structure
const filePath = join(nodeDir, `${targetVersion}.ts`); const filePath = join(nodeDir, `${targetVersion}.ts`);
if (!validatePathWithinBase(filePath, nodeDir)) {
return { error: 'Error: Invalid path - path traversal detected' };
}
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
return { error: `Version '${version}' not found for node '${nodeId}'` }; return { error: `Version '${version}' not found for node '${nodeId}'` };
} }

View File

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

View File

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

View File

@ -1,31 +1,36 @@
/** /**
* Proxy/transport helpers for the AI model suppliers. * 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 * Dispatcher construction, egress validation and the dispatcher-bound fetch
* `fetchOptions: { dispatcher }` and dispatched by the global `fetch`. They * come from `@n8n/backend-network`'s DI-free subpaths, so this
* must come from undici v7: a v7 dispatcher accepts the dispatch handlers of * module only maps its callers' options — AI timeout defaults, the shared
* every supported Node's fetch, while a v6 dispatcher rejects the v7 handlers * default dispatcher — onto that shared core.
* 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).
*/ */
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 { passthroughEgressFilter } from '@n8n/backend-network/egress';
import { lookup as defaultLookup } from 'node:dns'; 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 { AgentOptions } from 'node:https';
import type { LookupFunction } from 'node:net'; import type { Dispatcher } from 'undici';
/* 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';
/** /**
* Options for configuring HTTP agent timeouts. * 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 { export interface AgentTimeoutOptions {
headersTimeout?: number; headersTimeout?: number;
@ -33,6 +38,8 @@ export interface AgentTimeoutOptions {
connectTimeout?: number; connectTimeout?: number;
} }
export type EgressFilter = TransportSsrfPolicy;
// Default timeout for AI operations (1 hour) // Default timeout for AI operations (1 hour)
// Aligned with EXECUTIONS_TIMEOUT_MAX to ensure AI requests don't exceed workflow execution limits // 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 // 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). * 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/'; const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
/** /**
* Agent shared by every caller that needs no proxy, no timeout overrides and the * Dispatcher shared by every caller that needs no proxy, no timeout overrides
* plain system DNS lookup, so those callers keep a single connection pool instead * and no effective egress filter, so those callers keep a single connection
* of building an agent (and a pool) per request. * 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. * 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 targetUrl - The target URL to check proxy configuration for (optional)
* @param timeoutOptions - Optional timeout configuration to override defaults. * @param timeoutOptions - Optional timeout configuration to override defaults.
* @param lookup - Connect-time DNS lookup, applied to every Agent this builds. * @param egressFilter - The execution context's secure egress filter, enforced on every
* Defaults to the plain system lookup. * dispatched request: pre-flight URL validation on each redirect hop and
* @returns A ProxyAgent when a proxy applies to the target, otherwise an Agent. Callers using the * a pinned DNS resolution. The passthrough filter enforces nothing.
* defaults (no timeout options, no `N8N_AI_TIMEOUT_MAX`, plain lookup) share one Agent. * @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 * @remarks
* The default undici timeouts (5 minutes) are too short for many AI operations, so an agent is * The default undici timeouts (5 minutes) are too short for many AI operations, so a dispatcher
* always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX` * 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. * is honoured even when no proxy and no explicit timeout options are configured.
*/ */
export function getProxyAgent( export function getProxyAgent(
targetUrl?: string, targetUrl: string | undefined,
timeoutOptions?: AgentTimeoutOptions, timeoutOptions: AgentTimeoutOptions | undefined,
lookup: LookupFunction = defaultLookup, egressFilter: EgressFilter,
) { ): Dispatcher {
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET); const proxyRequired = isProxyRequired(targetUrl, PROXY_FALLBACK_TARGET);
const ssrf = toSsrfOption(egressFilter);
const agentOptions = { const timeouts = {
headersTimeout: timeoutOptions?.headersTimeout ?? DEFAULT_TIMEOUT, headersTimeout: timeoutOptions?.headersTimeout ?? DEFAULT_TIMEOUT,
bodyTimeout: timeoutOptions?.bodyTimeout ?? DEFAULT_TIMEOUT, bodyTimeout: timeoutOptions?.bodyTimeout ?? DEFAULT_TIMEOUT,
...(timeoutOptions?.connectTimeout !== undefined && { ...(timeoutOptions?.connectTimeout !== undefined && {
@ -82,19 +105,15 @@ export function getProxyAgent(
}), }),
}; };
if (proxyUrl) {
return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
}
const isDefaultCase = const isDefaultCase =
!timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && lookup === defaultLookup; !proxyRequired && !timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && ssrf === 'disabled';
if (isDefaultCase) { if (isDefaultCase) {
sharedAgent ??= new Agent({ ...agentOptions, connect: { lookup } }); sharedDispatcher ??= buildDispatcher(false, 'disabled', { timeouts });
return sharedAgent; return sharedDispatcher;
} }
return new Agent({ ...agentOptions, connect: { lookup } }); return buildDispatcher(proxyRequired ? 'env' : false, ssrf, { timeouts });
} }
/** /**
@ -107,42 +126,41 @@ export interface ProxyFetchOptions {
init?: RequestInit; init?: RequestInit;
/** Optional timeout configuration to override defaults */ /** Optional timeout configuration to override defaults */
timeoutOptions?: AgentTimeoutOptions; timeoutOptions?: AgentTimeoutOptions;
/** Connect-time DNS lookup, e.g. an egress filter's secure lookup */ /** Egress filter enforced on every dispatched request, e.g. the execution context's secure egress filter */
lookup: LookupFunction; egressFilter: EgressFilter;
} }
/** /**
* Make a fetch() request with an Agent/ProxyAgent that has configured timeouts. * Make a fetch() request with a dispatcher that has configured timeouts.
* If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent. * If proxy environment variables are set, the dispatcher routes through the proxy.
*/ */
export async function proxyFetch({ export async function proxyFetch({
input, input,
init, init,
timeoutOptions, timeoutOptions,
lookup, egressFilter,
}: ProxyFetchOptions): Promise<Response> { }: ProxyFetchOptions): Promise<Response> {
// Two Request classes exist at runtime (the global one and this package's // Two Request classes exist at runtime (the global one and this package's
// undici), so detect a Request by exclusion instead of `instanceof`. // undici), so detect a Request by exclusion instead of `instanceof`.
const isRequest = typeof input !== 'string' && !(input instanceof URL); const isRequest = typeof input !== 'string' && !(input instanceof URL);
const targetUrl = isRequest ? input.url : input.toString(); 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 // `dispatchedFetch` runs the fetch of the undici the dispatcher was built
// the same undici's fetch: the global fetch on Node >= 26 rejects it. That // from: the global fetch on Node >= 26 rejects a foreign dispatcher. That
// fetch only recognizes its own Request class and stringifies any other, so // 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 // a Request (the Mistral SDK builds one with the global class) is passed
// as url + init instead. // as url + init instead.
return (await undiciFetch(targetUrl, { if (!isRequest) return await dispatchedFetch(dispatcher, input, init);
...(isRequest && {
method: input.method, return await dispatchedFetch(dispatcher, targetUrl, {
headers: [...input.headers], method: input.method,
body: input.body === null ? undefined : await input.arrayBuffer(), headers: [...input.headers],
signal: input.signal, body: input.body === null ? undefined : await input.arrayBuffer(),
redirect: input.redirect, signal: input.signal,
}), redirect: input.redirect,
...(init as Parameters<typeof undiciFetch>[1]), ...init,
dispatcher, });
})) as unknown as Response;
} }
/** /**

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/api-types", "name": "@n8n/api-types",
"version": "1.40.0", "version": "1.40.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "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 { SourceControlPullResponsePublicDto } from './source-control/source-control-pull-response-public.dto';
export { CreateCredentialDto } from './credentials/create-credential.dto'; export { CreateCredentialDto } from './credentials/create-credential.dto';
export { TestCredentialRequestDto } from './credentials/test-credential-request.dto';
export { export {
CredentialPublicDto, CredentialPublicDto,
CredentialListPublicDto, CredentialListPublicDto,

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/backend-network", "name": "@n8n/backend-network",
"version": "1.14.0", "version": "1.14.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",
@ -35,6 +35,11 @@
"import": "./src/transport.ts", "import": "./src/transport.ts",
"require": "./dist/transport.js" "require": "./dist/transport.js"
}, },
"./egress": {
"types": "./dist/egress.d.ts",
"import": "./src/egress.ts",
"require": "./dist/egress.js"
},
"./proxy": { "./proxy": {
"types": "./dist/proxy/index.d.ts", "types": "./dist/proxy/index.d.ts",
"import": "./src/proxy/index.ts", "import": "./src/proxy/index.ts",
@ -49,6 +54,9 @@
"transport": [ "transport": [
"dist/transport.d.ts" "dist/transport.d.ts"
], ],
"egress": [
"dist/egress.d.ts"
],
"proxy": [ "proxy": [
"dist/proxy/index.d.ts" "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 { fetch as undiciFetch } from 'undici';
import { mock } from 'vitest-mock-extended'; 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 { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { OutboundHttp } from '../outbound-http'; 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', () => { describe('transport asCustomFetch', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

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

View File

@ -6,13 +6,14 @@ import type {
IN8nHttpFullResponse, IN8nHttpFullResponse,
IN8nHttpResponse, IN8nHttpResponse,
IRequestOptions, IRequestOptions,
NodeEgressFilter,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type http from 'node:http'; import type http from 'node:http';
import type https from 'node:https'; import type https from 'node:https';
import type { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import type { Dispatcher } from 'undici'; import type { Dispatcher } from 'undici';
import { SsrfProtectionService } from '../ssrf'; import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
import { httpRequest } from './axios/request'; import { httpRequest } from './axios/request';
import { withClientDefaults } from './client-default-headers'; import { withClientDefaults } from './client-default-headers';
import { HttpRequestClientOptions } from './client-options'; 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: * Resolves a caller's {@link UseDefaultSsrfPolicy} to the SSRF policy to enforce:
* the container's `SsrfProtectionService` always in `'enforced'` mode, and in * 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 type { Dispatcher } from 'undici';
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from 'undici'; import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { SsrfBridge } from '../../ssrf'; import type { ProxyOption } from '../node-agents';
import type { ProxyOption, SsrfOption } from '../node-agents';
/** /**
* Drop-in replacement type for the global `fetch`. * Drop-in replacement type for the global `fetch`.
*/ */
export type CustomFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; 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 * Per-request authorization gate run against the target of every dispatched request
* (the initial request and every redirect hop). * (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 routing. Defaults to `'env'` (HTTP(S)_PROXY / NO_PROXY). */
proxy?: ProxyOption; proxy?: ProxyOption;
/** SSRF policy. Defaults to `'disabled'`. */ /** SSRF policy. Defaults to `'disabled'`. */
ssrf?: SsrfOption; ssrf?: TransportSsrfOption;
/** Undici agent timeout overrides. */ /** Undici agent timeout overrides. */
timeouts?: TransportTimeoutOptions; timeouts?: TransportTimeoutOptions;
/** When set, it runs on every dispatched request (including each redirect hop) after the SSRF check */ /** 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( export function buildDispatcher(
proxy: ProxyOption, proxy: ProxyOption,
ssrf: SsrfOption, ssrf: TransportSsrfOption,
options: BuildDispatcherOptions = {}, options: BuildDispatcherOptions = {},
): Dispatcher { ): Dispatcher {
let dispatcher = buildDispatcherFromProxy(proxy, ssrf, options?.timeouts); let dispatcher = buildDispatcherFromProxy(proxy, ssrf, options?.timeouts);
@ -100,7 +122,7 @@ export function buildDispatcher(
function buildDispatcherFromProxy( function buildDispatcherFromProxy(
proxy: ProxyOption, proxy: ProxyOption,
ssrf: SsrfOption, ssrf: TransportSsrfOption,
timeouts?: TransportTimeoutOptions, timeouts?: TransportTimeoutOptions,
): Dispatcher { ): Dispatcher {
const agentOptions = toAgentTimeoutOptions(timeouts); const agentOptions = toAgentTimeoutOptions(timeouts);
@ -127,7 +149,7 @@ function buildDispatcherFromProxy(
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own * A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
* tunnel handshake. * tunnel handshake.
*/ */
function secureProxyConnect(ssrf: SsrfOption) { function secureProxyConnect(ssrf: TransportSsrfOption) {
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } }; 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. * 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. * 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') { if (ssrf === 'disabled') {
return; 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 * 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). * `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() } }; 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 * That host is fixed for the dispatcher rather than per request, so
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built. * {@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) => { return (dispatch) => (opts, handler) => {
let targetUrl: URL; let targetUrl: URL;
try { 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( export async function dispatchedFetch(
dispatcher: Dispatcher, dispatcher: Dispatcher | undefined,
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit, init?: RequestInit,
): Promise<Response> { ): Promise<Response> {

View File

@ -1,15 +1 @@
/** export type { UseDefaultSsrfPolicy } from 'n8n-workflow';
* 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';

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'; const PROXY_URL = 'http://127.0.0.1:8888';
@ -50,3 +50,29 @@ describe('resolveProxyUrl', () => {
).toBeUndefined(); ).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, createHttpProxyAgent,
createHttpsProxyAgent, createHttpsProxyAgent,
hasProxyEnvironmentVariables, hasProxyEnvironmentVariables,
isProxyRequired,
resolveProxyUrl, resolveProxyUrl,
} from './proxy-resolution'; } from './proxy-resolution';
export { ensureHostsBypassProxy } from './no-proxy-patch'; export { ensureHostsBypassProxy } from './no-proxy-patch';

View File

@ -24,6 +24,15 @@ export function resolveProxyUrl(
return getProxyForUrl(targetUrl ?? fallbackUrl ?? '') || undefined; 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. * 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 = { export const passthroughEgressFilter: NodeEgressFilter = {
validateUrl: async () => await Promise.resolve(createResultOk(undefined)), validateUrl: async () => await Promise.resolve(createResultOk(undefined)),
validateConnectionHost: () => createResultOk(undefined),
// Same function reference every call, so callers can compare it by identity. // Same function reference every call, so callers can compare it by identity.
createSecureLookup: (): LookupFunction => lookup, createSecureLookup: (): LookupFunction => lookup,
// No egress policy configured, so every redirect target is allowed. // No egress policy configured, so every redirect target is allowed.

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/blob-storage", "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", "description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/decorators", "name": "@n8n/decorators",
"version": "1.40.0", "version": "1.40.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "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 { RequirePublicApiControllerRule } from './require-public-api-controller.js';
import { NoLegacyCipherMethodsRule } from './no-legacy-cipher-methods.js'; import { NoLegacyCipherMethodsRule } from './no-legacy-cipher-methods.js';
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.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 { NoOnLeaderTakeoverRule } from './no-on-leader-takeover.js';
import { NoMisplacedCipherPrimitivesRule } from './no-misplaced-cipher-primitives.js'; import { NoMisplacedCipherPrimitivesRule } from './no-misplaced-cipher-primitives.js';
import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js'; import { NoDeploymentKeyDeleteRule } from './no-deployment-key-delete.js';
@ -65,6 +66,7 @@ export const rules = {
'require-public-api-controller': RequirePublicApiControllerRule, 'require-public-api-controller': RequirePublicApiControllerRule,
'no-legacy-cipher-methods': NoLegacyCipherMethodsRule, 'no-legacy-cipher-methods': NoLegacyCipherMethodsRule,
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule, 'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
'require-escaped-query-values': RequireEscapedQueryValuesRule,
'no-on-leader-takeover': NoOnLeaderTakeoverRule, 'no-on-leader-takeover': NoOnLeaderTakeoverRule,
'no-misplaced-cipher-primitives': NoMisplacedCipherPrimitivesRule, 'no-misplaced-cipher-primitives': NoMisplacedCipherPrimitivesRule,
'no-deployment-key-delete': NoDeploymentKeyDeleteRule, '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", "name": "@n8n/expression-runtime",
"version": "0.31.0", "version": "0.31.1",
"description": "Secure, isolated expression evaluation runtime for n8n", "description": "Secure, isolated expression evaluation runtime for n8n",
"main": "dist/cjs/index.js", "main": "dist/cjs/index.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",

View File

@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';
import { extendedFunctions } from '../function-extensions'; import { extendedFunctions } from '../function-extensions';
const zip = extendedFunctions.zip; const zip = extendedFunctions.zip;
const toPathSegment = extendedFunctions.toPathSegment;
describe('zip', () => { describe('zip', () => {
it('should pair keys with values', () => { it('should pair keys with values', () => {
@ -21,3 +22,13 @@ describe('zip', () => {
expect(({} as Record<string, unknown>).marker).toBeUndefined(); 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; 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) { function ifEmpty<T, V>(value: V, defaultValue: T) {
if (arguments.length !== 2) { if (arguments.length !== 2) {
// DIVERGENCE from packages/workflow/src/extensions/extended-functions.ts: // DIVERGENCE from packages/workflow/src/extensions/extended-functions.ts:
@ -86,6 +99,7 @@ export const extendedFunctions = {
average, average,
numberList, numberList,
zip, zip,
toPathSegment,
$min: min, $min: min,
$max: max, $max: max,
$average: average, $average: average,

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/node-engine-compatibility", "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", "description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",

View File

@ -15,27 +15,11 @@ export type SecretKeysConfig = {
customRegex?: string[]; customRegex?: string[];
}; };
/** // Vendor prefixes stay detected even with a file suffix. Generic prefixes
* Common key prefixes used in secret keys. // 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 COMMON_KEY_PREFIXES = [ const GENERIC_KEY_PREFIXES = ['key-', 'api-', 'apikey-', 'token-', 'secret-', 'SHA:', 'Bearer '];
'key-', const COMMON_KEY_PREFIXES = [...VENDOR_KEY_PREFIXES, ...GENERIC_KEY_PREFIXES];
'sk-',
'sk_',
'pk_',
'pk-',
'ghp_',
'AKIA',
'xox',
'SG.',
'hf_',
'api-',
'apikey-',
'token-',
'secret-',
'SHA:',
'Bearer ',
];
/** /**
* File extensions to ignore when strict_mode is False. * 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('|')})$`, `^[^\\s]*(${ALLOWED_EXTENSIONS.map((ext) => ext.replace('.', '\\.')).join('|')})$`,
'i', '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.tripwireTriggered).toBe(true);
expect(result.info?.maskEntities?.SECRET).toContain('custom-secret-1234'); 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: { configuration: {
baseURL: foundryURL, baseURL: foundryURL,
fetchOptions: { fetchOptions: {
dispatcher: getProxyAgent(foundryURL, {}), dispatcher: getProxyAgent(foundryURL, {}, this.helpers.getSecureEgressFilter()),
}, },
}, },
...options, ...options,
@ -184,6 +184,7 @@ export class EmbeddingsAzureOpenAi implements INodeType {
dispatcher: getProxyAgent( dispatcher: getProxyAgent(
credentials.endpoint ?? `https://${credentials.resourceName}.openai.azure.com`, 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 = { const configuration: ClientOptions = {
baseURL: credentials.url, baseURL: credentials.url,
fetchOptions: { fetchOptions: {
dispatcher: getProxyAgent(credentials.url, {}), dispatcher: getProxyAgent(credentials.url, {}, this.helpers.getSecureEgressFilter()),
}, },
}; };

View File

@ -64,14 +64,14 @@ export class EmbeddingsOllama implements INodeType {
} }
: undefined; : undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const egressFilter = this.helpers.getSecureEgressFilter();
const embeddings = new OllamaEmbeddings({ const embeddings = new OllamaEmbeddings({
baseUrl, baseUrl,
model: modelName, model: modelName,
headers, headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }), await proxyFetch({ input, init, egressFilter }),
}); });
return { 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. * of an options object, so accept either shape.
*/ */
function lookupReachedProxyFetch(lookup: unknown): boolean { function filterReachedProxyFetch(filter: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) => return mockedProxyFetch.mock.calls.some((call) =>
call.some( call.some(
(arg) => (arg) =>
arg === lookup || arg === filter ||
(typeof arg === 'object' && (typeof arg === 'object' &&
arg !== null && 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 })); 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 secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup), createSecureLookup: vi.fn().mockReturnValue(secureLookup),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };
@ -77,8 +78,6 @@ describe('EmbeddingsOllama', () => {
await node.supplyData.call(ctx, 0); await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const embeddingsOptions = MockedOllamaEmbeddings.mock.calls[0][0] as const embeddingsOptions = MockedOllamaEmbeddings.mock.calls[0][0] as
| { fetch?: typeof fetch } | { fetch?: typeof fetch }
| undefined; | undefined;
@ -87,12 +86,13 @@ describe('EmbeddingsOllama', () => {
await embeddingsOptions?.fetch?.('http://ollama.example.com:11434/api/embed', {}); await embeddingsOptions?.fetch?.('http://ollama.example.com:11434/api/embed', {});
expect(mockedProxyFetch).toHaveBeenCalled(); expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true); expect(filterReachedProxyFetch(egressFilter)).toBe(true);
}); });
it('rejects a base URL the credential does not allow', async () => { it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()), createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };
@ -112,6 +112,7 @@ describe('EmbeddingsOllama', () => {
it('rejects a credential restricted from use in this node', async () => { it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()), createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -77,9 +77,9 @@ export class LmChatOllama implements INodeType {
} }
: undefined; : undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const egressFilter = this.helpers.getSecureEgressFilter();
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }); await proxyFetch({ input, init, egressFilter });
const model = new ChatOllama({ const model = new ChatOllama({
...options, ...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. * of an options object, so accept either shape.
*/ */
function lookupReachedProxyFetch(lookup: unknown): boolean { function filterReachedProxyFetch(filter: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) => return mockedProxyFetch.mock.calls.some((call) =>
call.some( call.some(
(arg) => (arg) =>
arg === lookup || arg === filter ||
(typeof arg === 'object' && (typeof arg === 'object' &&
arg !== null && 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 })); 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 secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup), createSecureLookup: vi.fn().mockReturnValue(secureLookup),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };
@ -78,20 +79,19 @@ describe('LmChatOllama', () => {
await node.supplyData.call(ctx, 0); await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const [modelOptions] = MockedChatOllama.mock.calls[0]; const [modelOptions] = MockedChatOllama.mock.calls[0];
expect(modelOptions?.fetch).toBeTypeOf('function'); expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/chat', {}); await modelOptions?.fetch?.('http://ollama.example.com:11434/api/chat', {});
expect(mockedProxyFetch).toHaveBeenCalled(); expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true); expect(filterReachedProxyFetch(egressFilter)).toBe(true);
}); });
it('rejects a base URL the credential does not allow', async () => { it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()), createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };
@ -111,6 +111,7 @@ describe('LmChatOllama', () => {
it('rejects a credential restricted from use in this node', async () => { it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()), createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };

View File

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

View File

@ -35,6 +35,10 @@ describe('searchModels', () => {
let mockContext: Mocked<ILoadOptionsFunctions>; let mockContext: Mocked<ILoadOptionsFunctions>;
let fetchSpy: ReturnType<typeof vi.fn>; let fetchSpy: ReturnType<typeof vi.fn>;
const secureLookup = vi.fn(); const secureLookup = vi.fn();
const egressFilter = {
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
};
beforeEach(() => { beforeEach(() => {
mockContext = { mockContext = {
@ -51,10 +55,7 @@ describe('searchModels', () => {
parameters: {}, parameters: {},
} as INode), } as INode),
helpers: { helpers: {
getSecureEgressFilter: vi.fn().mockReturnValue({ getSecureEgressFilter: vi.fn().mockReturnValue(egressFilter),
validateUrl: vi.fn(),
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
}),
}, },
} as unknown as Mocked<ILoadOptionsFunctions>; } as unknown as Mocked<ILoadOptionsFunctions>;
@ -82,7 +83,7 @@ describe('searchModels', () => {
init: expect.objectContaining({ init: expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }), headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }),
}), }),
lookup: secureLookup, egressFilter,
}); });
expect(result.results).toEqual(OFFICIAL_API_RESULTS); 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 baseURL = baseUrlOverride || (credentials.url as string) || 'https://api.openai.com/v1';
const { openAiDefaultHeaders } = Container.get(AiConfig); const { openAiDefaultHeaders } = Container.get(AiConfig);
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const egressFilter = this.helpers.getSecureEgressFilter();
const headers = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {}); const headers = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
// Shared with the agents model catalog: endpoint, auth, chat-model filtering // Shared with the agents model catalog: endpoint, auth, chat-model filtering
@ -27,7 +27,7 @@ export async function searchModels(
apiKey: credentials.apiKey as string, apiKey: credentials.apiKey as string,
baseURL, baseURL,
headers, headers,
fetch: async (input, init) => await proxyFetch({ input, init, lookup }), fetch: async (input, init) => await proxyFetch({ input, init, egressFilter }),
}); });
return { return {

View File

@ -74,7 +74,7 @@ export class LmOllama implements INodeType {
} }
: undefined; : undefined;
const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const egressFilter = this.helpers.getSecureEgressFilter();
const model = new Ollama({ const model = new Ollama({
baseUrl, baseUrl,
@ -84,7 +84,7 @@ export class LmOllama implements INodeType {
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this), onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
headers, headers,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => fetch: async (input: RequestInfo | URL, init?: RequestInit) =>
await proxyFetch({ input, init, lookup }), await proxyFetch({ input, init, egressFilter }),
}); });
return { 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. * of an options object, so accept either shape.
*/ */
function lookupReachedProxyFetch(lookup: unknown): boolean { function filterReachedProxyFetch(filter: unknown): boolean {
return mockedProxyFetch.mock.calls.some((call) => return mockedProxyFetch.mock.calls.some((call) =>
call.some( call.some(
(arg) => (arg) =>
arg === lookup || arg === filter ||
(typeof arg === 'object' && (typeof arg === 'object' &&
arg !== null && 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 })); 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 secureLookup = vi.fn();
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(secureLookup), createSecureLookup: vi.fn().mockReturnValue(secureLookup),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };
@ -78,20 +79,19 @@ describe('LmOllama', () => {
await node.supplyData.call(ctx, 0); await node.supplyData.call(ctx, 0);
expect(egressFilter.createSecureLookup).toHaveBeenCalled();
const modelOptions = MockedOllama.mock.calls[0][0] as { fetch?: typeof fetch } | undefined; const modelOptions = MockedOllama.mock.calls[0][0] as { fetch?: typeof fetch } | undefined;
expect(modelOptions?.fetch).toBeTypeOf('function'); expect(modelOptions?.fetch).toBeTypeOf('function');
await modelOptions?.fetch?.('http://ollama.example.com:11434/api/generate', {}); await modelOptions?.fetch?.('http://ollama.example.com:11434/api/generate', {});
expect(mockedProxyFetch).toHaveBeenCalled(); expect(mockedProxyFetch).toHaveBeenCalled();
expect(lookupReachedProxyFetch(secureLookup)).toBe(true); expect(filterReachedProxyFetch(egressFilter)).toBe(true);
}); });
it('rejects a base URL the credential does not allow', async () => { it('rejects a base URL the credential does not allow', async () => {
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()), createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };
@ -111,6 +111,7 @@ describe('LmOllama', () => {
it('rejects a credential restricted from use in this node', async () => { it('rejects a credential restricted from use in this node', async () => {
const egressFilter: NodeEgressFilter = { const egressFilter: NodeEgressFilter = {
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
validateConnectionHost: vi.fn().mockReturnValue(createResultOk(undefined)),
createSecureLookup: vi.fn().mockReturnValue(vi.fn()), createSecureLookup: vi.fn().mockReturnValue(vi.fn()),
validateRedirectSync: vi.fn(), validateRedirectSync: vi.fn(),
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,6 +79,26 @@ function sanitizeForSessionKey(name: string): string {
return name.replace(/[^A-Za-z0-9_-]/g, '_'); 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( export function getSessionId(
ctx: ISupplyDataFunctions | IWebhookFunctions, ctx: ISupplyDataFunctions | IWebhookFunctions,
itemIndex: number, itemIndex: number,
@ -92,43 +112,48 @@ export function getSessionId(
if (selectorType === autoSelect) { if (selectorType === autoSelect) {
// If memory node is used in webhook like node(like chat trigger node), it doesn't have access to evaluateExpression // 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 // so we try to extract sessionId from the bodyData
let rawSessionId: unknown;
if ('getBodyData' in ctx) { if ('getBodyData' in ctx) {
const bodyData = ctx.getBodyData() ?? {}; const bodyData = ctx.getBodyData() ?? {};
sessionId = bodyData.sessionId as string; rawSessionId = bodyData.sessionId;
} else { } else {
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string; rawSessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex);
// try to get sessionId from chat trigger // try to get sessionId from chat trigger
if (!sessionId || sessionId === undefined) { if (rawSessionId === '' || rawSessionId === undefined || rawSessionId === null) {
try { try {
const chatTrigger = ctx.getChatTrigger(); const chatTrigger = ctx.getChatTrigger();
if (chatTrigger) { if (chatTrigger) {
sessionId = ctx.evaluateExpression( rawSessionId = ctx.evaluateExpression(
`{{ $('${chatTrigger.name}').first().json.sessionId }}`, `{{ $('${chatTrigger.name}').first().json.sessionId }}`,
itemIndex, itemIndex,
) as string; );
} }
} catch (error) {} } catch (error) {}
} }
} }
if (sessionId === '' || sessionId === undefined) { if (rawSessionId === '' || rawSessionId === undefined || rawSessionId === null) {
throw new NodeOperationError(ctx.getNode(), 'No session ID found', { throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
description: 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", "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, itemIndex,
}); });
} }
sessionId = coerceSessionIdToString(ctx, rawSessionId, itemIndex);
} else { } else {
sessionId = ctx.getNodeParameter(customKey, itemIndex, '') as string; const rawSessionId = ctx.getNodeParameter(customKey, itemIndex, '');
if (sessionId === '' || sessionId === undefined) { if (rawSessionId === '' || rawSessionId === undefined || rawSessionId === null) {
throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', { throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', {
description: 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", "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, itemIndex,
}); });
} }
sessionId = coerceSessionIdToString(ctx, rawSessionId, itemIndex);
} }
// Scoping uses the memory node's own name, so connecting a single memory // 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'); 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', () => { it('should retrieve sessionId from chat trigger', () => {
mockCtx.getNodeParameter.mockReturnValue('fromInput'); mockCtx.getNodeParameter.mockReturnValue('fromInput');
mockCtx.evaluateExpression.mockReturnValueOnce(undefined); mockCtx.evaluateExpression.mockReturnValueOnce(undefined);

View File

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

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