mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
Compare commits
2 Commits
master
...
n8n@2.40.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b8b16267 | ||
|
|
c410f848d2 |
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
'https://api.openai.com',
|
||||||
|
{
|
||||||
headersTimeout: 12345,
|
headersTimeout: 12345,
|
||||||
bodyTimeout: 12345,
|
bodyTimeout: 12345,
|
||||||
});
|
},
|
||||||
|
egressFilter,
|
||||||
|
);
|
||||||
|
|
||||||
expect(ChatOpenAI).toHaveBeenCalledWith(
|
expect(ChatOpenAI).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
{
|
||||||
headersTimeout: 120000,
|
headersTimeout: 120000,
|
||||||
bodyTimeout: 180000,
|
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(
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
{
|
||||||
headersTimeout: 120000,
|
headersTimeout: 120000,
|
||||||
bodyTimeout: 180000,
|
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(
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
{
|
||||||
headersTimeout: 60000,
|
headersTimeout: 60000,
|
||||||
bodyTimeout: 60000,
|
bodyTimeout: 60000,
|
||||||
connectTimeout: 30000,
|
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({
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}'` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export {
|
||||||
getNodeTypeDefinition,
|
getNodeTypeDefinition,
|
||||||
getNodeTypes,
|
getNodeTypes,
|
||||||
isValidPathComponent,
|
isValidPathComponent,
|
||||||
|
isValidVersionSegment,
|
||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
parseNodeId,
|
parseNodeId,
|
||||||
toSnakeCase,
|
toSnakeCase,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
model.baseUrl,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
ctx.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAiModel = new ChatOpenAI({
|
const openAiModel = new ChatOpenAI({
|
||||||
|
|
|
||||||
|
|
@ -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 && {
|
|
||||||
|
return await dispatchedFetch(dispatcher, targetUrl, {
|
||||||
method: input.method,
|
method: input.method,
|
||||||
headers: [...input.headers],
|
headers: [...input.headers],
|
||||||
body: input.body === null ? undefined : await input.arrayBuffer(),
|
body: input.body === null ? undefined : await input.arrayBuffer(),
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
redirect: input.redirect,
|
redirect: input.redirect,
|
||||||
}),
|
...init,
|
||||||
...(init as Parameters<typeof undiciFetch>[1]),
|
});
|
||||||
dispatcher,
|
|
||||||
})) as unknown as Response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
9
packages/@n8n/backend-network/src/egress.ts
Normal file
9
packages/@n8n/backend-network/src/egress.ts
Normal 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';
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
configuration.baseURL ?? 'https://api.openai.com/v1',
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const customHeader = getCustomCredentialHeader(credentials);
|
const customHeader = getCustomCredentialHeader(credentials);
|
||||||
configuration.defaultHeaders = mergeCustomHeaders(
|
configuration.defaultHeaders = mergeCustomHeaders(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
options.baseURL ?? 'https://api.openai.com/v1',
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
defaultHeaders,
|
defaultHeaders,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -247,10 +247,14 @@ export class LmChatAlibabaCloud implements INodeType {
|
||||||
const configuration: ClientOptions = {
|
const configuration: ClientOptions = {
|
||||||
baseURL,
|
baseURL,
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
dispatcher: getProxyAgent(baseURL, {
|
dispatcher: getProxyAgent(
|
||||||
|
baseURL,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
foundryURL,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: 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(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -365,7 +365,7 @@ export class LmChatDatabricks implements INodeType {
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
},
|
},
|
||||||
egressFilter?.createSecureLookup(),
|
egressFilter,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
credentials.url,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@ async function getMcpServerConfigsWithoutAudienceTokens(
|
||||||
MICROSOFT_TOOL_OPTIONS,
|
MICROSOFT_TOOL_OPTIONS,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
lookup: secureEgressFilter.createSecureLookup(),
|
egressFilter: secureEgressFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
baseURL,
|
||||||
|
{
|
||||||
headersTimeout: timeout,
|
headersTimeout: timeout,
|
||||||
bodyTimeout: timeout,
|
bodyTimeout: timeout,
|
||||||
}),
|
},
|
||||||
|
this.helpers.getSecureEgressFilter(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
defaultHeaders,
|
defaultHeaders,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user