Compare commits

...

7 Commits

Author SHA1 Message Date
n8n-assistant[bot]
bc9090e8c6
🚀 Release 2.36.2 (#36570)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
Co-authored-by: Matsuuu <matias.huhta@n8n.io>
2026-08-19 10:31:08 +03:00
n8n-assistant[bot]
900c44c56e
build: Re-pin mailsplit license election to 5.4.15 (no-changelog) (backport to release-candidate/2.36.x) (#36568)
Co-authored-by: Declan Carroll <declan@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:22:52 +03:00
n8n-assistant[bot]
4c60d38f2a
chore: Bundle/2.x (backport to release-candidate/2.36.x) (#36560)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
Co-authored-by: Sam Wooler <swooler592@gmail.com>
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Lorent Lempereur <looorent@users.noreply.github.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
Co-authored-by: phyllis-noester <102315132+phyllis-noester@users.noreply.github.com>
Co-authored-by: n8n-assistant[bot] <n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: Robin Braumann <50590409+bjorger@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 08:33:33 +03:00
n8n-assistant[bot]
3da926085d
build: Bump dependency pins to patched minor and patch versions (no-changelog) (backport to release-candidate/2.36.x) (#36536)
Co-authored-by: Declan Carroll <declan@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 18:19:02 +00:00
n8n-assistant[bot]
705014a02d
fix(editor): Keep agent preview user messages right-aligned (backport to release-candidate/2.36.x) (#36533)
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
2026-08-18 16:38:11 +00:00
n8n-assistant[bot]
19eb96602d
fix(core): Resolve end-user credentials when loading node parameters (backport to release-candidate/2.36.x) (#36518)
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:00:57 +00:00
n8n-assistant[bot]
2c09a81269
fix(API): Return a request schema for decorator routes in /discover (backport to release-candidate/2.36.x) (#36496)
Co-authored-by: Uddish Verma <uddish.verma@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:55:53 +00:00
145 changed files with 5797 additions and 1105 deletions

View File

@ -1,3 +1,13 @@
## [2.36.2](https://github.com/n8n-io/n8n/compare/n8n@2.36.0...n8n@2.36.2) (2026-08-19)
### Bug Fixes
* **API:** Return a request schema for decorator routes in /discover ([#36496](https://github.com/n8n-io/n8n/issues/36496)) ([2c09a81](https://github.com/n8n-io/n8n/commit/2c09a81269e5411b0f81b301d5e140514126e941))
* **core:** Resolve end-user credentials when loading node parameters ([#36518](https://github.com/n8n-io/n8n/issues/36518)) ([19eb966](https://github.com/n8n-io/n8n/commit/19eb96602d8fe6f89287cdc36522db8f60a8a4ec))
* **editor:** Keep agent preview user messages right-aligned ([#36533](https://github.com/n8n-io/n8n/issues/36533)) ([705014a](https://github.com/n8n-io/n8n/commit/705014a02de7815961723a552271520418cf023b))
# [2.36.0](https://github.com/n8n-io/n8n/compare/n8n@2.35.0...n8n@2.36.0) (2026-08-18)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.36.0",
"version": "2.36.2",
"private": true,
"engines": {
"node": ">=24.0.0",
@ -156,9 +156,9 @@
"@rudderstack/rudder-sdk-node@<=3.0.0": "3.0.0",
"diff": "8.0.3",
"tar": "catalog:",
"ajv@6": "6.14.0",
"ajv@7": "8.18.0",
"ajv@8": "8.18.0",
"ajv@6": "6.15.0",
"ajv@7": "8.20.0",
"ajv@8": "8.20.0",
"bn.js@4": "5.2.3",
"bn.js@5": "5.2.3",
"lodash": "4.18.1",
@ -181,7 +181,7 @@
"axios": "catalog:",
"fast-xml-builder": "1.2.1",
"fast-xml-parser": "5.7.2",
"postcss@<=8.5.9": "8.5.10",
"postcss@<=8.5.22": "8.5.23",
"@anthropic-ai/sdk@<=0.91.1": "0.91.1",
"uuid@<=13.0.1": "13.0.1",
"fast-uri": "3.1.5",
@ -189,7 +189,7 @@
"protobufjs": "7.6.5",
"shell-quote": "1.9.0",
"adm-zip": "0.6.0",
"ip-address@10": "10.1.1",
"ip-address@10": "10.3.1",
"brace-expansion@5": "5.0.9",
"@tootallnate/once@2": "2.0.1",
"@vue/server-renderer@3.5.26": "3.5.40",
@ -197,11 +197,13 @@
"@opentelemetry/exporter-prometheus@<=0.217.0": "0.217.0",
"@opentelemetry/sdk-node@<=0.217.0": "0.217.0",
"langsmith": "0.6.0",
"hono": "4.12.27",
"hono": "4.12.34",
"@tiptap/core": "catalog:",
"pg": "catalog:",
"file-type": "catalog:",
"linkify-it@<=5.0.1": "5.0.1",
"linkify-it@<=5.0.1": "5.0.2",
"highlight.js@11": "catalog:frontend",
"jose@<6.2.9": "6.2.9",
"node-rsa": "2.0.0",
"@turbo/darwin-64@<=2.9.18": "2.9.18",
"@turbo/linux-64@<=2.9.18": "2.9.18",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-network",
"version": "1.10.0",
"version": "1.10.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -0,0 +1,78 @@
import type { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { installProxyConnectionGuard } from '../connection-guards';
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
describe('installProxyConnectionGuard', () => {
function guarded(connectOpts: { host?: string | null; hostname?: string | null }) {
const connect = vi.fn().mockResolvedValue('SOCKET');
const agent = { connectOpts, connect };
return { agent, connect };
}
it.each([
['host', { host: 'proxy.internal' }],
['hostname', { hostname: 'proxy.internal' }],
])(
'reads the proxy host from `%s` and delegates when the policy allows it',
async (_label, connectOpts) => {
const bridge = makeSsrfBridge();
const { agent, connect } = guarded(connectOpts);
installProxyConnectionGuard(agent, bridge);
const req = {} as http.ClientRequest;
const opts = {} as ConnectOpts;
await expect(agent.connect(req, opts)).resolves.toBe('SOCKET');
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('proxy.internal');
expect(connect).toHaveBeenCalledWith(req, opts);
},
);
it('rejects the connection when the policy denies the proxy host', async () => {
const error = new Error('blocked');
const bridge = makeSsrfBridge({
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const { agent, connect } = guarded({ host: '169.254.169.254' });
installProxyConnectionGuard(agent, bridge);
await expect(agent.connect({} as http.ClientRequest, {} as ConnectOpts)).rejects.toBe(error);
expect(connect).not.toHaveBeenCalled();
});
it('validates the proxy host on every connection, not once at installation', async () => {
const bridge = makeSsrfBridge();
const { agent } = guarded({ host: 'proxy.internal' });
installProxyConnectionGuard(agent, bridge);
await agent.connect({} as http.ClientRequest, {} as ConnectOpts);
agent.connectOpts.host = 'other-proxy.internal';
await agent.connect({} as http.ClientRequest, {} as ConnectOpts);
expect(bridge.validateConnectionHost).toHaveBeenNthCalledWith(1, 'proxy.internal');
expect(bridge.validateConnectionHost).toHaveBeenNthCalledWith(2, 'other-proxy.internal');
});
it.each([
['missing', {}],
['null', { host: null, hostname: null }],
['empty', { host: '' }],
['empty while `hostname` is set', { host: '', hostname: 'proxy.internal' }],
])('rejects the connection when the proxy host is %s', async (_label, connectOpts) => {
const bridge = makeSsrfBridge();
const { agent, connect } = guarded(connectOpts);
installProxyConnectionGuard(agent, bridge);
await expect(agent.connect({} as http.ClientRequest, {} as ConnectOpts)).rejects.toThrow(
'Cannot determine the host for this connection',
);
expect(connect).not.toHaveBeenCalled();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
});

View File

@ -1,24 +1,47 @@
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import type { MockInstance } from 'vitest';
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
// drives which branch runs, so nothing hits the network.
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
const { getProxyForUrl, proxyAddRequest, proxyConnect, proxyAgents } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
proxyAddRequest: vi.fn(),
proxyConnect: vi.fn(),
proxyAgents: [] as unknown[],
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
vi.mock('http-proxy-agent', () => ({
HttpProxyAgent: class {
connectOpts: { host: string };
addRequest = proxyAddRequest;
constructor(
proxyUrl: string,
readonly agentOptions?: { lookup?: unknown },
) {
this.connectOpts = { host: new URL(proxyUrl).hostname };
proxyAgents.push(this);
}
async connect(...args: unknown[]): Promise<unknown> {
return await proxyConnect(...args);
}
},
}));
type CreatedProxyAgent = {
agentOptions?: { lookup?: unknown };
connect: (req: unknown, opts: unknown) => Promise<unknown>;
};
const req = {} as http.ClientRequest;
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
@ -28,6 +51,9 @@ describe('EnvProxyHttpAgent', () => {
beforeEach(() => {
getProxyForUrl.mockReset();
proxyAddRequest.mockReset();
proxyConnect.mockReset();
proxyConnect.mockResolvedValue('SOCKET');
proxyAgents.length = 0;
// `super.addRequest` is the only path that would open a real socket.
// `addRequest` is an internal Agent method untyped on the public types.
superAddRequest = vi
@ -57,4 +83,37 @@ describe('EnvProxyHttpAgent', () => {
expect(proxyAddRequest).not.toHaveBeenCalled();
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
});
describe('SSRF scope of the resolved proxy agent', () => {
beforeEach(() => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
});
function resolvedProxyAgent(lookup?: LookupFunction): CreatedProxyAgent {
new EnvProxyHttpAgent(lookup).addRequest(req, options({ host: 'a.example', port: 80 }));
return proxyAgents[0] as CreatedProxyAgent;
}
it.each([
['a lookup is given', () => makeLookupFn()],
['no lookup is given', () => undefined],
])('opens the connection to the proxy unchecked when %s', async (_label, makeLookup) => {
const lookup = makeLookup();
const proxyAgent = resolvedProxyAgent(lookup);
await expect(proxyAgent.connect(req, {})).resolves.toBe('SOCKET');
expect(proxyAgent.agentOptions?.lookup).toBeUndefined();
});
it('keeps the secure lookup on its own direct pool', () => {
const lookupFn = makeLookupFn();
const agent = new EnvProxyHttpAgent(lookupFn) as unknown as {
options: { lookup?: unknown };
};
expect(agent.options.lookup).toBe(lookupFn);
});
});
});

View File

@ -1,25 +1,48 @@
import type http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import type { MockInstance } from 'vitest';
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
// drives which branch runs, so nothing hits the network.
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
const { getProxyForUrl, proxyAddRequest, proxyConnect, proxyAgents } = vi.hoisted(() => ({
getProxyForUrl: vi.fn<(url: string) => string>(),
proxyAddRequest: vi.fn(),
proxyConnect: vi.fn(),
proxyAgents: [] as unknown[],
}));
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
vi.mock('https-proxy-agent', () => ({
HttpsProxyAgent: class {
connectOpts: { host: string };
addRequest = proxyAddRequest;
constructor(
proxyUrl: string,
readonly agentOptions?: { lookup?: unknown },
) {
this.connectOpts = { host: new URL(proxyUrl).hostname };
proxyAgents.push(this);
}
async connect(...args: unknown[]): Promise<unknown> {
return await proxyConnect(...args);
}
},
}));
type CreatedProxyAgent = {
agentOptions?: { lookup?: unknown };
connect: (req: unknown, opts: unknown) => Promise<unknown>;
};
const req = {} as http.ClientRequest;
const options = (o: Partial<https.RequestOptions>): https.RequestOptions =>
o as https.RequestOptions;
@ -30,6 +53,9 @@ describe('EnvProxyHttpsAgent', () => {
beforeEach(() => {
getProxyForUrl.mockReset();
proxyAddRequest.mockReset();
proxyConnect.mockReset();
proxyConnect.mockResolvedValue('SOCKET');
proxyAgents.length = 0;
// `super.addRequest` is the only path that would open a real socket.
// `addRequest` is an internal Agent method untyped on the public types.
superAddRequest = vi
@ -59,4 +85,37 @@ describe('EnvProxyHttpsAgent', () => {
expect(proxyAddRequest).not.toHaveBeenCalled();
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
});
describe('SSRF scope of the resolved proxy agent', () => {
beforeEach(() => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
});
function resolvedProxyAgent(lookup?: LookupFunction): CreatedProxyAgent {
new EnvProxyHttpsAgent(lookup).addRequest(req, options({ host: 'a.example', port: 443 }));
return proxyAgents[0] as CreatedProxyAgent;
}
it.each([
['a lookup is given', () => makeLookupFn()],
['no lookup is given', () => undefined],
])('opens the connection to the proxy unchecked when %s', async (_label, makeLookup) => {
const lookup = makeLookup();
const proxyAgent = resolvedProxyAgent(lookup);
await expect(proxyAgent.connect(req, {})).resolves.toBe('SOCKET');
expect(proxyAgent.agentOptions?.lookup).toBeUndefined();
});
it('keeps the secure lookup on its own direct pool', () => {
const lookupFn = makeLookupFn();
const agent = new EnvProxyHttpsAgent(lookupFn) as unknown as {
options: { lookup?: unknown };
};
expect(agent.options.lookup).toBe(lookupFn);
});
});
});

View File

@ -1,13 +1,21 @@
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import type { SsrfBridge } from '../../ssrf';
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import {
makeDenyingLookup,
makeLookupFn,
makeSsrfBridge,
} from '../../ssrf/__tests__/mock-ssrf-bridge';
import { installConnectionGuard, UnknownConnectionHostError } from '../connection-guards';
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
import { buildNodeAgents, installConnectionGuard } from '../node-agents';
import { type LocalServer, startServer } from '../local-server';
import { buildNodeAgents, type ProxyUrl } from '../node-agents';
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
// (unlike http.Agent and HttpProxyAgent which use `options`).
@ -103,39 +111,25 @@ describe('buildNodeAgents', () => {
});
});
describe('SSRF lookup placement (direct connections only)', () => {
it('proxy: false → injects the secure lookup on both agents', () => {
describe('SSRF lookup placement', () => {
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
['proxy: false', false],
['proxy: env', 'env'],
['proxy: explicit URL', 'http://proxy.internal:3128'],
];
it.each(modes)('%s → injects the secure lookup on both agents', (_label, proxy) => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents(false, bridge);
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, bridge);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('proxy: env → injects the secure lookup for the direct path', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents('env', bridge);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
it('proxy: explicit URL → does NOT inject the lookup (proxy validates the target)', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
const { httpAgent, httpsAgent } = buildNodeAgents('http://proxy.internal:3128', bridge);
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
it('ssrf disabled → no lookup on the direct path', () => {
const { httpAgent, httpsAgent } = buildNodeAgents('env', 'disabled');
it.each(modes)('%s with SSRF disabled → no lookup on either agent', (_label, proxy) => {
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, 'disabled');
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
@ -185,6 +179,24 @@ describe('buildNodeAgents', () => {
expect(socket).toBe('SOCKET');
});
it.each([
['missing', {}],
['null', { host: null, hostname: null }],
['empty', { host: '' }],
['empty while `hostname` is set', { host: '', hostname: 'target.example' }],
])('rejects the connection when the host is %s', (_label, options) => {
const bridge = makeSsrfBridge();
const { createConnection, original } = guarded(bridge);
const onCreate = vi.fn();
const result = createConnection(options, onCreate);
expect(onCreate).toHaveBeenCalledWith(expect.any(UnknownConnectionHostError));
expect(original).not.toHaveBeenCalled();
expect(result).toBeUndefined();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
it('passes the raw host through to the bridge (normalization is the service’s job)', () => {
const bridge = makeSsrfBridge();
const { createConnection } = guarded(bridge);
@ -214,4 +226,107 @@ describe('buildNodeAgents', () => {
},
);
});
describe('proxy host validation (explicit proxy URL)', () => {
let proxyServer: LocalServer;
let proxyByIp: ProxyUrl;
let proxyByHostname: ProxyUrl;
const realLookup = () => dns.lookup as unknown as LookupFunction;
async function getThroughAgent(agent: http.Agent): Promise<string> {
return await new Promise((resolve, reject) => {
const req = http.get('http://proxied-target.invalid/x', { agent, timeout: 3000 }, (res) => {
let data = '';
res.on('data', (chunk) => (data += String(chunk)));
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('timeout'));
});
});
}
beforeEach(async () => {
proxyServer = await startServer((_req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.end('proxied');
});
const { port } = new URL(proxyServer.url);
proxyByIp = `http://127.0.0.1:${port}`;
proxyByHostname = `http://localhost:${port}`;
});
afterEach(async () => {
await proxyServer.close();
});
it('rejects a proxy host the SSRF policy denies, without reaching the proxy', async () => {
const error = new Error(
'The request was blocked because it resolves to a restricted IP address',
);
const bridge = makeSsrfBridge({
createSecureLookup: realLookup,
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const { httpAgent } = buildNodeAgents(proxyByIp, bridge);
await expect(getThroughAgent(httpAgent)).rejects.toThrow(error.message);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
expect(proxyServer.captured).toEqual([]);
});
it.each(['httpAgent', 'httpsAgent'] as const)(
'validates the proxy host before the %s opens its connection',
async (which) => {
const error = new Error('blocked');
const bridge = makeSsrfBridge({
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
const agents = buildNodeAgents('http://proxy.internal:3128', bridge);
const agent = agents[which] as unknown as {
connect: (req: unknown, opts: unknown) => Promise<unknown>;
};
await expect(agent.connect({}, {})).rejects.toBe(error);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('proxy.internal');
},
);
it('rejects a proxy hostname that resolves to a restricted address', async () => {
const error = new Error(
'The request was blocked because it resolves to a restricted IP address',
);
const lookup = makeDenyingLookup(error);
const bridge = makeSsrfBridge({ createSecureLookup: () => lookup });
const { httpAgent } = buildNodeAgents(proxyByHostname, bridge);
await expect(getThroughAgent(httpAgent)).rejects.toThrow(error.message);
expect(lookup).toHaveBeenCalledWith('localhost', expect.any(Object), expect.any(Function));
expect(proxyServer.captured).toEqual([]);
});
it('connects through a proxy host the SSRF policy allows', async () => {
const bridge = makeSsrfBridge({ createSecureLookup: realLookup });
const { httpAgent } = buildNodeAgents(proxyByIp, bridge);
await expect(getThroughAgent(httpAgent)).resolves.toBe('proxied');
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
expect(proxyServer.captured).toEqual(['http://proxied-target.invalid/x']);
});
it('connects through the proxy unchanged when SSRF protection is disabled', async () => {
const { httpAgent } = buildNodeAgents(proxyByHostname, 'disabled');
await expect(getThroughAgent(httpAgent)).resolves.toBe('proxied');
expect(proxyServer.captured).toEqual(['http://proxied-target.invalid/x']);
});
});
});

View File

@ -1,8 +1,8 @@
import type { Logger } from '@n8n/backend-common';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import http from 'node:http';
import https from 'node:https';
import type http from 'node:http';
import type https from 'node:https';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
@ -30,15 +30,9 @@ function getAgentLookup(agent: http.Agent | https.Agent): unknown {
// ---------------------------------------------------------------------------
describe('getNodeAgent', () => {
it('proxy: false → plain http/https.Agent (no proxy class)', () => {
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: false }).getNodeAgent();
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
expect(httpAgent).not.toBeInstanceOf(HttpProxyAgent);
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
});
// Proxy-mode → agent-class matrix is owned by `node-agents.test.ts`
// (`buildNodeAgents` → `describe('agent classes per proxy mode')`); this is a
// representative case proving the facade forwards to it correctly.
it('proxy: explicit URL → HttpProxyAgent / HttpsProxyAgent', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ proxy: 'http://proxy.internal:3128' })
@ -48,13 +42,6 @@ describe('getNodeAgent', () => {
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
});
it('proxy: env → custom env-routing agents (http/https.Agent subclasses)', () => {
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: 'env' }).getNodeAgent();
expect(httpAgent).toBeInstanceOf(http.Agent);
expect(httpsAgent).toBeInstanceOf(https.Agent);
});
it('returns the same agent instances on repeated calls', () => {
const client = makeFacade().transport();
const a1 = client.getNodeAgent();
@ -79,62 +66,29 @@ describe('getNodeAgent', () => {
// ---------------------------------------------------------------------------
describe('getNodeAgent SSRF lookup injection', () => {
describe('proxy: false', () => {
it('injects createSecureLookup when SSRF is enabled', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: false })
.getNodeAgent();
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
// The full proxy-mode matrix is owned by `node-agents.test.ts`
// (`buildNodeAgents` → `describe('SSRF lookup placement')`); this is a
// representative case proving the facade forwards to it correctly.
it('the agents carry the lookup created by the SSRF bridge', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
it('does NOT inject lookup when SSRF is disabled', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: 'disabled', proxy: false })
.getNodeAgent();
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: false })
.getNodeAgent();
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
describe('proxy: explicit URL', () => {
it('does NOT inject lookup behind an explicit proxy (proxy validates the target)', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: 'http://proxy.internal:3128' })
.getNodeAgent();
it('no lookup when SSRF is disabled', () => {
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: 'disabled', proxy: false })
.getNodeAgent();
// SSRF lookup is applied to direct connections only. Behind a proxy it
// would resolve the proxy host, not the final target, so it is omitted.
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});
describe('proxy: env', () => {
it('injects createSecureLookup when SSRF is enabled', () => {
const lookupFn = makeLookupFn();
const bridge = makeSsrfBridge({
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
});
const { httpAgent, httpsAgent } = makeFacade()
.transport({ ssrf: bridge, proxy: 'env' })
.getNodeAgent();
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
// EnvProxy* agents inherit from http/https.Agent and pass lookup to super()
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
});
expect(getAgentLookup(httpAgent)).toBeUndefined();
expect(getAgentLookup(httpsAgent)).toBeUndefined();
});
});

View File

@ -5,13 +5,18 @@ import type { Dispatcher } from 'undici';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import {
makeDenyingLookup,
makeSsrfBridge,
useCleanProxyEnv,
} from '../../ssrf/__tests__/mock-ssrf-bridge';
import { type LocalServer, startServer } from '../local-server';
import { OutboundHttp } from '../outbound-http';
import { createSsrfInterceptor } from '../undici/transport';
// SSRF enforcement lives in a single place: the dispatcher interceptor.
// This file proves it at two levels:
// SSRF enforcement on the dispatch path lives in the dispatcher interceptor
// (an explicit proxy URI is additionally checked when the dispatcher is built).
// This file proves the interceptor at two levels:
// (a) a direct unit test of `createSsrfInterceptor`, and
// (b) end-to-end tests against a real local server (no mocked `fetch`), so the
// interceptor actually runs and we assert that a 30x cannot smuggle a
@ -49,6 +54,8 @@ function makeOpts(path: string, origin?: string) {
return { path, origin } as unknown as Dispatcher.DispatchOptions;
}
useCleanProxyEnv();
describe('createSsrfInterceptor', () => {
it('validates the reconstructed target URL and dispatches when allowed', async () => {
const bridge = makeSsrfBridge();
@ -89,7 +96,7 @@ describe('createSsrfInterceptor', () => {
expect(bridge.validateUrl).not.toHaveBeenCalled();
expect(innerDispatch).not.toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalled();
expect(handler.onResponseError).toHaveBeenCalledWith(null, expect.any(TypeError));
});
it('falls back to onError when onResponseError is unavailable', async () => {
@ -309,14 +316,123 @@ describe('connect-time secure lookup (DNS rebinding)', () => {
await expect(fetchFn('http://rebind.example/')).rejects.toThrow();
});
it('does not derive a connect-time lookup behind an explicit proxy', () => {
const createSecureLookup = vi.fn(makeLookupFn);
const bridge = makeSsrfBridge({ createSecureLookup });
it('resolves the proxy host through the secure lookup behind an explicit proxy', async () => {
const denied = new Error('blocked: restricted IP address');
const lookupSpy = makeDenyingLookup(denied);
const bridge = makeSsrfBridge({ createSecureLookup: () => lookupSpy });
const fetchFn = makeTransport({
ssrf: bridge,
proxy: 'http://proxy.internal:3128',
}).asCustomFetch();
// Forces the lazy dispatcher to build. The proxy resolves the target, so
// the secure lookup must not be consulted on our side.
makeTransport({ ssrf: bridge, proxy: 'http://proxy.invalid:3128' }).getDispatcher();
const rejection = await fetchFn('http://target.invalid/x').catch((e: unknown) => e);
expect(createSecureLookup).not.toHaveBeenCalled();
expect(lookupSpy).toHaveBeenCalledWith('proxy.internal', expect.anything(), expect.anything());
expect(rootCauseMessage(rejection)).toBe(denied.message);
});
});
describe('proxy host validation', () => {
const realLookup = () => dns.lookup as unknown as LookupFunction;
function denyingBridge(error: Error) {
return makeSsrfBridge({
createSecureLookup: realLookup,
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
});
}
function bridgeDenyingProxyHost(deniedHostname: string) {
const error = new Error('The proxy host is not permitted by policy');
const bridge = makeSsrfBridge({
createSecureLookup: realLookup,
validateUrl: vi.fn(async (url: string | URL) => {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve(
hostname === deniedHostname
? { ok: false as const, error }
: { ok: true as const, result: undefined },
);
}),
});
return { bridge, error };
}
it('rejects an explicit proxy host the policy denies', () => {
const error = new Error('The proxy host is not permitted by policy');
const bridge = denyingBridge(error);
expect(() =>
makeTransport({ ssrf: bridge, proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
).toThrow(error.message);
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
});
it('surfaces the rejection of an explicit proxy host through fetch', async () => {
const error = new Error('The proxy host is not permitted by policy');
const fetchFn = makeTransport({
ssrf: denyingBridge(error),
proxy: 'http://127.0.0.1:3128',
}).asCustomFetch();
await expect(fetchFn('http://target.invalid/x')).rejects.toThrow(error.message);
});
// The environment's proxies describe the deployment, so the policy that decides
// which targets a workflow may reach does not decide them.
it.each([
['HTTP_PROXY', 'http://target.invalid/x'],
['HTTPS_PROXY', 'https://target.invalid/x'],
] as const)('leaves a proxy configured through %s unchecked', async (envKey, target) => {
process.env[envKey] = 'http://127.0.0.1:3128';
const lookupSpy = makeDenyingLookup(new Error('blocked: restricted IP address'));
const { bridge } = bridgeDenyingProxyHost('127.0.0.1');
bridge.createSecureLookup = () => lookupSpy;
const transport = makeTransport({ ssrf: bridge, proxy: 'env' });
expect(() => transport.getDispatcher()).not.toThrow();
await transport
.asCustomFetch()(target)
.catch(() => undefined);
const validated = vi.mocked(bridge.validateUrl).mock.calls.map(([url]) => String(url));
expect(validated).toEqual([target]);
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
expect(lookupSpy).not.toHaveBeenCalled();
});
it('leaves an explicit proxy unchecked when SSRF protection is disabled', () => {
const bridge = denyingBridge(new Error('The proxy host is not permitted by policy'));
expect(() =>
makeTransport({ ssrf: 'disabled', proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
).not.toThrow();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
it('serves a target the environment exempts from the direct path', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('ok');
});
process.env.HTTP_PROXY = 'http://proxy.internal:3128';
process.env.NO_PROXY = '127.0.0.1';
const { bridge } = bridgeDenyingProxyHost('proxy.internal');
const fetchFn = makeTransport({ ssrf: bridge, proxy: 'env' }).asCustomFetch();
try {
const res = await fetchFn(`${server.url}/x`);
expect(res.status).toBe(200);
} finally {
await server.close();
}
});
it('leaves the dispatcher untouched when no proxy is configured in the environment', () => {
const bridge = makeSsrfBridge({ createSecureLookup: realLookup });
expect(() => makeTransport({ ssrf: bridge, proxy: 'env' }).getDispatcher()).not.toThrow();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
});

View File

@ -4,7 +4,7 @@ import dns from 'node:dns';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { makeSsrfBridge, useCleanProxyEnv } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { getBeforeRedirectFn, setAxiosAgents } from '../axios/utils';
import { type LocalServer, startServer } from '../local-server';
import { buildNodeAgents } from '../node-agents';
@ -31,9 +31,10 @@ async function httpGetWithAgent(url: string, agent: http.Agent): Promise<string>
}
describe('outbound transport integration', () => {
useCleanProxyEnv();
let target: LocalServer;
let proxy: LocalServer;
const ORIGINAL_ENV = { ...process.env };
beforeAll(async () => {
target = await startServer((req, res) => {
@ -58,18 +59,10 @@ describe('outbound transport integration', () => {
});
beforeEach(() => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.ALL_PROXY;
target.clear();
proxy.clear();
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
describe('setAxiosAgents routing', () => {
it('routes through an explicit custom proxy', async () => {
const config: AxiosRequestConfig = { url: `${target.url}/x`, method: 'GET', proxy: false };
@ -120,7 +113,7 @@ describe('outbound transport integration', () => {
});
});
describe('SSRF secure lookup is applied to direct connections only', () => {
describe('SSRF secure lookup placement', () => {
it('invokes the secure lookup for a direct (hostname) connection', async () => {
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
dns.lookup(hostname, options, onResult as never),
@ -142,8 +135,17 @@ describe('outbound transport integration', () => {
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
});
it('does NOT invoke the secure lookup when routed through a proxy', async () => {
process.env.HTTP_PROXY = proxy.url;
// An explicit proxy URL can come from a request, so its host is resolved
// through the policy; the environment's is part of the deployment and is not.
it.each([
['leaves the env proxy host to the platform resolver', true],
['invokes the secure lookup for an explicit proxy host', false],
])('%s', async (_case, fromEnv) => {
const { port } = new URL(proxy.url);
const proxyUrl = `http://localhost:${port}`;
if (fromEnv) {
process.env.HTTP_PROXY = proxyUrl;
}
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
dns.lookup(hostname, options, onResult as never),
);
@ -156,14 +158,21 @@ describe('outbound transport integration', () => {
method: 'GET',
proxy: false,
};
setAxiosAgents(config, undefined, undefined, bridge);
setAxiosAgents(config, undefined, fromEnv ? undefined : proxyUrl, bridge);
const res = await axios<{ message: string }>(config);
expect(res.data.message).toBe('proxied');
// The proxy host is an IP (no lookup) and the proxy resolves the final
// target, so the secure lookup is never consulted on our side.
expect(lookupSpy).not.toHaveBeenCalled();
if (fromEnv) {
expect(lookupSpy).not.toHaveBeenCalled();
} else {
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
}
expect(lookupSpy).not.toHaveBeenCalledWith(
'proxied-target.invalid',
expect.anything(),
expect.anything(),
);
});
});

View File

@ -1,8 +1,10 @@
import type { Logger } from '@n8n/backend-common';
import dns from 'node:dns';
import type { LookupFunction } from 'node:net';
import { mock } from 'vitest-mock-extended';
import type { SsrfBridge } from '../../../ssrf';
import { makeSsrfBridge } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { makeSsrfBridge, useCleanProxyEnv } from '../../../ssrf/__tests__/mock-ssrf-bridge';
import { executeLegacyRequest } from '../../legacy-request';
import { type LocalServer, startServer } from '../../local-server';
import { configureGlobalAxiosDefaults } from '../config';
@ -16,8 +18,6 @@ import { httpRequest } from '../request';
configureGlobalAxiosDefaults();
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] as const;
async function startRedirectServer(): Promise<LocalServer> {
let serverUrl = '';
const server = await startServer((req, res) => {
@ -67,13 +67,11 @@ function makeBridge(blockedPath: string): { bridge: SsrfBridge; error: Error } {
}
describe('httpRequest manual redirect following with SSRF + proxy', () => {
useCleanProxyEnv();
let server: LocalServer;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(async () => {
for (const key of PROXY_ENV_KEYS) {
savedEnv[key] = process.env[key];
}
// A proxy is configured (engages the manual follower), but loopback is
// exempt so the request connects directly to the local server.
process.env.HTTP_PROXY = 'http://127.0.0.1:1';
@ -84,13 +82,6 @@ describe('httpRequest manual redirect following with SSRF + proxy', () => {
});
afterEach(async () => {
for (const key of PROXY_ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
await server.close();
});
@ -218,6 +209,177 @@ describe('httpRequest manual redirect following with SSRF + proxy', () => {
});
});
describe('proxy host validation across redirect hops', () => {
let proxyServer: LocalServer;
const START_URL = 'http://redirect-target.invalid/start';
const INTERNAL_URL = 'http://redirect-target.invalid/internal';
beforeEach(async () => {
proxyServer = await startServer((req, res) => {
if (req.url === START_URL) {
res.writeHead(302, { Location: INTERNAL_URL });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('reached:proxied');
});
});
afterEach(async () => {
await proxyServer.close();
});
const proxyConfig = () => ({
host: 'localhost',
port: Number(new URL(proxyServer.url).port),
protocol: 'http',
});
it('follows the redirect through a proxy the policy allows', async () => {
const bridge = makeSsrfBridge({
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
});
const response = await httpRequest(
{ method: 'GET', url: START_URL, proxy: proxyConfig() },
bridge,
);
expect(response).toBe('reached:proxied');
expect(proxyServer.captured).toEqual([START_URL, INTERNAL_URL]);
});
it('blocks the redirect hop when the proxy host is no longer allowed', async () => {
const denied = new Error(
'The request was blocked because it resolves to a restricted IP address',
);
const lookup = ((hostname: string, options: dns.LookupOptions, onResult: unknown) => {
if (proxyServer.captured.length > 0) {
(onResult as (error: Error | null, address?: unknown, family?: number) => void)(
denied,
options.all ? [] : '',
undefined,
);
return;
}
dns.lookup(hostname, options, onResult as never);
}) as unknown as LookupFunction;
const bridge = makeSsrfBridge({ createSecureLookup: () => lookup });
await expect(
httpRequest({ method: 'GET', url: START_URL, proxy: proxyConfig() }, bridge),
).rejects.toThrow(denied.message);
expect(proxyServer.captured).toEqual([START_URL]);
});
});
describe('proxy host validation before the request is sent', () => {
let proxyServer: LocalServer;
const TARGET_URL = 'http://proxied-target.invalid/x';
beforeEach(async () => {
proxyServer = await startServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('reached:proxied');
});
});
afterEach(async () => {
await proxyServer.close();
});
const proxyUrl = () => `http://localhost:${new URL(proxyServer.url).port}`;
const proxyConfig = () => ({
host: 'localhost',
port: Number(new URL(proxyServer.url).port),
protocol: 'http',
});
function bridgeDenying(hostname: string, error: Error): SsrfBridge {
return makeSsrfBridge({
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
validateUrl: vi.fn(async (url: string | URL) => {
const host = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve(
host === hostname
? { ok: false as const, error }
: { ok: true as const, result: undefined },
);
}),
});
}
const requestPaths = [
[
'httpRequest',
async (bridge: SsrfBridge) =>
await httpRequest({ method: 'GET', url: TARGET_URL, proxy: proxyConfig() }, bridge),
],
[
'executeLegacyRequest',
async (bridge: SsrfBridge) =>
await executeLegacyRequest(
{ uri: TARGET_URL, proxy: proxyUrl() },
bridge,
mock<Logger>(),
),
],
] as const;
it.each(requestPaths)(
'blocks %s when the policy denies the proxy host',
async (_name, send) => {
const error = new Error('The proxy host is not permitted by policy');
await expect(send(bridgeDenying('localhost', error))).rejects.toBe(error);
expect(proxyServer.captured).toEqual([]);
},
);
it.each(requestPaths)('sends %s when the policy allows the proxy host', async (_name, send) => {
const bridge = bridgeDenying('never-matches.invalid', new Error('unused'));
expect(await send(bridge)).toBe('reached:proxied');
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ hostname: 'localhost' }),
);
});
it('reports the target URL denial before checking the proxy host', async () => {
const targetError = new Error('The target host is not permitted by policy');
const bridge = makeSsrfBridge({
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
validateUrl: vi.fn(async (url: string | URL) => {
const host = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve({
ok: false as const,
error: host === 'localhost' ? new Error('proxy denied') : targetError,
});
}),
});
await expect(
httpRequest({ method: 'GET', url: TARGET_URL, proxy: proxyConfig() }, bridge),
).rejects.toBe(targetError);
});
it('sends the request through the proxy when SSRF protection is disabled', async () => {
const response = await httpRequest({
method: 'GET',
url: TARGET_URL,
proxy: proxyConfig(),
});
expect(response).toBe('reached:proxied');
expect(proxyServer.captured).toEqual([TARGET_URL]);
});
});
describe('credential headers across redirects', () => {
let origin: LocalServer;
let crossOrigin: LocalServer;

View File

@ -24,6 +24,7 @@ import {
searchForHeader,
setAxiosAgents,
tryParseUrl,
validateProxySsrf,
} from '../utils';
// Agent construction is owned by `buildNodeAgents` (./factory).
@ -431,6 +432,66 @@ describe('getUrlFromProxyConfig', () => {
});
});
describe('validateProxySsrf', () => {
const denyingBridge = (deniedHostname: string, error: Error) =>
makeSsrfBridge({
validateUrl: vi.fn(async (url: string | URL) => {
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname;
return await Promise.resolve(
hostname === deniedHostname
? { ok: false as const, error }
: { ok: true as const, result: undefined },
);
}),
});
it.each([
['a proxy URL string', 'http://proxy.example.com:8080'],
['a proxy config object', { host: 'proxy.example.com', port: 8080 }],
])('throws when the policy denies the host of %s', async (_label, proxyConfig) => {
const error = new Error('The proxy host is not permitted by policy');
await expect(
validateProxySsrf(proxyConfig, denyingBridge('proxy.example.com', error)),
).rejects.toBe(error);
});
it('validates the URL composed from a proxy config object', async () => {
const bridge = makeSsrfBridge();
await validateProxySsrf(
{
protocol: 'https',
host: 'proxy.example.com',
port: 9443,
auth: { username: 'user', password: 'pass' },
},
bridge,
);
expect(bridge.validateUrl).toHaveBeenCalledWith(
expect.objectContaining({ href: 'https://user:pass@proxy.example.com:9443/' }),
);
});
it.each([
['no proxy is configured', undefined],
['the proxy config has no host', { host: '', port: 8080 }],
['the proxy scheme is not supported', 'socks5://proxy.example.com:1080'],
['the proxy value is not a URL', 'not-a-url'],
])('does not consult the policy when %s', async (_label, proxyConfig) => {
const bridge = makeSsrfBridge();
await validateProxySsrf(proxyConfig, bridge);
expect(bridge.validateUrl).not.toHaveBeenCalled();
});
it('does not consult the policy when no bridge is provided', async () => {
await expect(validateProxySsrf('http://proxy.example.com:8080')).resolves.toBeUndefined();
});
});
describe('buildTargetUrl', () => {
it('should return url as-is when no baseURL', () => {
expect(buildTargetUrl('https://example.com/path')).toBe('https://example.com/path');

View File

@ -20,6 +20,7 @@ import {
searchForHeader,
setAxiosAgents,
throwIfDomainNotAllowed,
validateProxySsrf,
validateUrlSsrf,
} from './utils';
import type { SsrfBridge } from '../../ssrf';
@ -187,6 +188,7 @@ export async function httpRequest(
const url = buildTargetUrl(requestOptions.url, requestOptions.baseURL) ?? requestOptions.url;
await validateUrlSsrf(url, ssrfBridge);
await validateProxySsrf(requestOptions.proxy, ssrfBridge);
const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge);
if (

View File

@ -139,8 +139,6 @@ export const getBeforeRedirectFn =
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
// SSRF lookup is applied to direct connections only; behind a proxy the
// proxy validates the final target.
const targetUrl = redirectedRequest.href;
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, redirectAgentOptions);
@ -397,8 +395,6 @@ export function setAxiosAgents(
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
const proxy = resolveProxyOption(customProxyUrl);
// SSRF lookup is applied to direct connections only; behind a proxy the
// proxy validates the final target.
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, agentOptions);
config.httpAgent = httpAgent;
config.httpsAgent = httpsAgent;
@ -420,6 +416,16 @@ export async function validateUrlSsrf(
}
}
export async function validateProxySsrf(
proxyConfig: IHttpRequestOptions['proxy'] | string | undefined,
ssrfBridge?: SsrfBridge,
): Promise<void> {
const proxyUrl = getUrlFromProxyConfig(proxyConfig);
if (!isSupportedProxyUrl(proxyUrl)) return;
await validateUrlSsrf(proxyUrl, ssrfBridge);
}
/**
* Resolves the raw target of a legacy request object, i.e. the value the axios
* config carries as its `url`.

View File

@ -0,0 +1,85 @@
import type { HttpsProxyAgent } from 'https-proxy-agent';
import { UnexpectedError } from 'n8n-workflow';
import type http from 'node:http';
import type net from 'node:net';
import type { SsrfBridge } from '../ssrf';
/** Per-hop connect options of a proxy agent, as `agent-base` hands them to `connect()`. */
type ProxyConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
export class UnknownConnectionHostError extends UnexpectedError {
constructor() {
super('Cannot determine the host for this connection');
}
}
/** Subset of an agent's connection options we read to find the target host. */
type ConnectionOptions = { host?: string | null; hostname?: string | null };
/**
* Runtime-only `createConnection` method of Node's http(s) agents.
*/
type CreateConnection = (
options: ConnectionOptions,
onConnect?: (error: Error | null, stream?: unknown) => void,
) => unknown;
interface ProxyConnectionAgent {
connectOpts: ConnectionOptions;
connect(req: http.ClientRequest, opts: ProxyConnectOpts): Promise<net.Socket>;
}
function connectionHostError(ssrf: SsrfBridge, options: ConnectionOptions): Error | undefined {
const host = options.host ?? options.hostname ?? undefined;
if (typeof host !== 'string' || host.length === 0) {
return new UnknownConnectionHostError();
}
const result = ssrf.validateConnectionHost(host);
return result.ok ? undefined : result.error;
}
/**
* Validates the proxy's own host before the socket to it opens.
*
* `agent-base` opens that socket inside `connect()`, from `connectOpts`.
* The agent's `createConnection` receives the target's options and never sees the proxy.
* This guard decides IP literals only.
* A hostname proxy relies on the secure lookup in the agent's options, which validates
* the address that hostname resolves to.
*/
export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: SsrfBridge): void {
const connect = agent.connect.bind(agent);
agent.connect = async (req, opts) => {
const error = connectionHostError(ssrf, agent.connectOpts);
if (error) {
throw error;
}
return await connect(req, opts);
};
}
/**
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
*
* Node invokes the custom `lookup` to resolve hostnames only.
* An IP-literal target therefore reaches the socket unresolved, and needs this check
* at connection time.
*/
export function installConnectionGuard(
target: { createConnection: CreateConnection },
ssrf: SsrfBridge,
): void {
const createConnection = target.createConnection.bind(target);
target.createConnection = (options, onConnect) => {
const error = connectionHostError(ssrf, options);
if (error) {
if (onConnect) {
onConnect(error);
return undefined;
}
throw error;
}
return createConnection(options, onConnect);
};
}

View File

@ -13,8 +13,10 @@ type HttpProxyReqOpts = HttpAddRequestArgs[1];
/**
* `http.Agent` that delegates per-request env-proxy routing and caching to a shared {@link EnvProxyRouter}.
*
* The optional SSRF `lookup` is applied to the direct path only
* (behind a proxy it would resolve the proxy host, so the proxy validates the target).
* The optional SSRF `lookup` is applied to the direct path only.
* A proxy named by the environment belongs to the deployment rather than to a request.
* The policy that decides which targets a workflow may reach does not decide such a
* proxy (see `buildNodeAgents`).
*
* Also backs `installGlobalProxyAgent` (http-proxy.ts), keeping a single env-proxy agent implementation.
*/

View File

@ -15,7 +15,12 @@ import type { SsrfBridge } from '../ssrf';
import { invokeAxios } from './axios/invoke';
import { buildAxiosConfigFromLegacyRequest, buildLegacyAgentOptions } from './axios/legacy';
import { followSsrfRedirects, shouldFollowRedirectsManually } from './axios/redirect';
import { resolveLegacyRequestUrl, throwIfDomainNotAllowed, validateUrlSsrf } from './axios/utils';
import {
resolveLegacyRequestUrl,
throwIfDomainNotAllowed,
validateProxySsrf,
validateUrlSsrf,
} from './axios/utils';
import { binaryToString } from './binary-string';
import { parseIncomingMessage } from './parse-incoming-message';
@ -56,6 +61,7 @@ export async function executeLegacyRequest(
const url = resolveLegacyRequestUrl(requestObject);
await validateUrlSsrf(url, ssrfBridge);
await validateProxySsrf(requestObject.proxy, ssrfBridge);
axiosConfig = Object.assign(
axiosConfig,

View File

@ -3,6 +3,7 @@ import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { installConnectionGuard, installProxyConnectionGuard } from './connection-guards';
import { EnvProxyHttpAgent } from './env-proxy-http-agent';
import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents';
@ -54,9 +55,25 @@ export type NodeAgentOptions = https.AgentOptions;
* undici factory (`undici/factory.ts`), the axios transport layer
* (`axios/utils.ts`) and the global proxy agents (`http-proxy.ts`).
*
* SSRF lookup is injected only for **direct** connections. Behind a proxy the
* lookup resolves the proxy host, not the final target, so it is omitted there
* and the proxy validates the final target.
* The SSRF policy covers every host this builder opens a socket to, with one
* exception: a proxy named by the environment.
* `HTTP_PROXY` / `HTTPS_PROXY` describe the deployment rather than a request, and
* such a proxy commonly sits on a private address the policy blocks for targets.
* Setting them already requires control of the process.
* An explicit {@link ProxyUrl} can come from a request, so the policy decides its
* host like any other.
*
* The lookup resolves the target on a direct connection, and the proxy's own host
* behind an explicit proxy.
* Behind any proxy the final target never reaches these agents, so validating it
* belongs to the caller:
* - the axios entry points (`httpRequest`, `executeLegacyRequest`) check it before
* the request, and on each hop with the manual redirect follower
* - callers taking the agents on their own (`HttpTransport.getNodeAgent`) get
* neither check, and validate the targets they send through them
*
* A pre-request check does not pin an address to the socket, so a target the proxy
* resolves stays open to a rebind between the check and the connection.
*
* The `lookup` is owned by this builder: it is derived from the SSRF policy and
* always overrides anything in `agentOptions`. Passing `agentOptions.lookup`
@ -101,24 +118,18 @@ export function buildNodeAgents(
);
}
// Explicit proxy URL. No direct path, so no SSRF lookup is injected.
return {
httpAgent: createProxiedHttpAgent(proxy, agentOptions),
httpsAgent: createProxiedHttpsAgent(proxy, agentOptions),
// Explicit proxy URL. No direct path, so the lookup only guards the proxy host.
const agents = {
httpAgent: createProxiedHttpAgent(proxy, { ...agentOptions, lookup }),
httpsAgent: createProxiedHttpsAgent(proxy, { ...agentOptions, lookup }),
};
if (ssrf !== 'disabled') {
installProxyConnectionGuard(agents.httpAgent, ssrf);
installProxyConnectionGuard(agents.httpsAgent, ssrf);
}
return agents;
}
/** Subset of an agent's connection options we read to find the target host. */
type ConnectionOptions = { host?: string | null; hostname?: string | null };
/**
* Runtime-only `createConnection` method of Node's http(s) agents.
*/
type CreateConnection = (
options: ConnectionOptions,
onConnect?: (error: Error | null, stream?: unknown) => void,
) => unknown;
/**
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
*/
@ -132,28 +143,3 @@ function applyConnectionGuard(
}
return agents;
}
/**
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
* Node invokes the custom `lookup` only to resolve hostnames, so it also requires a check at connection time.
*/
export function installConnectionGuard(
target: { createConnection: CreateConnection },
ssrf: SsrfBridge,
): void {
const createConnection = target.createConnection.bind(target);
target.createConnection = (options, onConnect) => {
const host = options.host ?? options.hostname ?? undefined;
if (typeof host === 'string') {
const result = ssrf.validateConnectionHost(host);
if (!result.ok) {
if (onConnect) {
onConnect(result.error);
return undefined;
}
throw result.error;
}
}
return createConnection(options, onConnect);
};
}

View File

@ -139,8 +139,13 @@ export interface HttpRequestClient {
* SSRF coverage is identical for `asCustomFetch()` and `getDispatcher()` (same
* underlying dispatcher): every dispatched request — initial and each redirect
* hop — is validated, and direct connections also carry a connect-time secure
* DNS lookup that defeats DNS-rebinding (TOCTOU). `getNodeAgent()` enforces the
* same connect-time secure lookup for direct connections.
* DNS lookup that defeats DNS-rebinding (TOCTOU). `getNodeAgent()` carries that
* same lookup, applied to whichever host its socket opens to: the target on a
* direct connection, the proxy behind an explicit one.
* It does **not** validate the targets sent through it.
* Behind any proxy the proxy resolves the final target, so no client-side
* connect-time check of that target is possible.
* Callers taking the agents validate those targets themselves (see `buildNodeAgents`).
*/
export interface HttpTransport {
asCustomFetch(): CustomFetch;

View File

@ -108,11 +108,53 @@ function buildDispatcherFromProxy(
return new Agent({ ...agentOptions, ...secureConnect(ssrf) });
}
if (proxy === 'env') {
// The environment's proxies are part of the deployment, not of a request,
// so the policy does not decide them (see `buildNodeAgents`).
return new EnvHttpProxyAgent({ ...agentOptions, ...secureConnect(ssrf) });
}
// Explicit proxy URL: no direct path, so no connect-time lookup is injected —
// the proxy resolves the target. Mirrors `buildNodeAgents`.
return new ProxyAgent({ uri: proxy, ...agentOptions });
assertProxyHostAllowed(ssrf, proxy);
return new ProxyAgent({
uri: proxy,
...agentOptions,
...secureProxyConnect(ssrf),
});
}
/**
* A connect-time secure DNS lookup for the socket opened to a proxy.
*
* undici builds the connector for the proxy socket from `proxyTls`.
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
* tunnel handshake.
*/
function secureProxyConnect(ssrf: SsrfOption) {
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } };
}
/**
* Catches an IP-literal proxy host, which no lookup sees.
* Applies to an explicit proxy URI, the one form that can come from a request.
*/
function assertProxyHostAllowed(ssrf: SsrfOption, proxyUri: string): void {
if (ssrf === 'disabled') {
return;
}
const hostname = proxyHostname(proxyUri);
if (hostname === undefined) {
return;
}
const result = ssrf.validateConnectionHost(hostname);
if (!result.ok) {
throw result.error;
}
}
function proxyHostname(uri: string): string | undefined {
try {
return new URL(uri).hostname;
} catch {
return undefined;
}
}
/**
@ -174,8 +216,11 @@ function lazyValue<T>(factory: () => T): () => T {
* `fetch` re-dispatches through this dispatcher for each redirect hop, so this
* validates the initial request **and** every redirect target (both hostname
* and direct-IP targets), unlike a connect-time DNS lookup which never fires for
* IP-literal targets. Validation runs against the request target, never the
* proxy, so it is proxy-agnostic.
* IP-literal targets.
*
* This interceptor does not validate a proxy's own host.
* That host is fixed for the dispatcher rather than per request, so
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built.
*/
export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.DispatcherComposeInterceptor {
return (dispatch) => (opts, handler) => {

View File

@ -1,5 +1,6 @@
import type dns from 'node:dns';
import type { LookupFunction } from 'node:net';
import { vi } from 'vitest';
import { afterEach, beforeEach, vi, type Mock } from 'vitest';
import type { SsrfBridge } from '..';
@ -7,6 +8,50 @@ export function makeLookupFn(): LookupFunction {
return vi.fn() as unknown as LookupFunction;
}
/** A `lookup` that fails every resolution with `error`. */
export function makeDenyingLookup(error: Error): LookupFunction & Mock {
return vi.fn((_hostname: string, options: dns.LookupOptions, onResult: unknown) => {
(onResult as (error: Error | null, address?: unknown, family?: number) => void)(
error,
options.all ? [] : '',
undefined,
);
}) as unknown as LookupFunction & Mock;
}
const PROXY_ENV_KEYS = [
'HTTP_PROXY',
'http_proxy',
'HTTPS_PROXY',
'https_proxy',
'NO_PROXY',
'no_proxy',
'ALL_PROXY',
'all_proxy',
] as const;
/** Clears the proxy environment around each test and restores it afterwards. */
export function useCleanProxyEnv(): void {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of PROXY_ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of PROXY_ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});
}
export function makeSsrfBridge(overrides?: Partial<SsrfBridge>): SsrfBridge {
return {
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -277,7 +277,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
}
/**
* Create an ivm.Reference callback for getting value/metadata at a path.
* Create an ivm.Callback for getting value/metadata at a path.
*
* Used by createDeepLazyProxy when accessing properties. Returns metadata
* markers for arrays and objects, or the primitive value directly.
@ -291,8 +291,8 @@ export class IsolatedVmBridge implements RuntimeBridge {
* @param data - Current workflow data to use for callback responses
* @private
*/
private createGetValueAtPathRef(data: WorkflowData): ivm.Reference {
return new (getIvm().Reference)((path: string[]) => {
private createGetValueAtPathRef(data: WorkflowData): ivm.Callback {
return new (getIvm().Callback)((path: string[]) => {
try {
// Navigate to value
// Special-case: paths starting with ['$item', index] call data.$item(index)
@ -361,15 +361,15 @@ export class IsolatedVmBridge implements RuntimeBridge {
}
/**
* Create an ivm.Reference callback for getting array elements at an index.
* Create an ivm.Callback for getting array elements at an index.
*
* Used by array proxy when accessing numeric indices.
*
* @param data - Current workflow data to use for callback responses
* @private
*/
private createGetArrayElementRef(data: WorkflowData): ivm.Reference {
return new (getIvm().Reference)((path: string[], index: number) => {
private createGetArrayElementRef(data: WorkflowData): ivm.Callback {
return new (getIvm().Callback)((path: string[], index: number) => {
try {
// Navigate to array
// Special-case: paths starting with ['$item', index] call data.$item(index)
@ -462,11 +462,17 @@ export class IsolatedVmBridge implements RuntimeBridge {
* in this switch; the `type` field selects a static branch in source,
* not a property lookup on a runtime object.
*
* Return-value note: handlers must return plain, structured-clone-able
* data. Results cross into the isolate through an ivm.Callback, which copies
* them via the structured-clone algorithm — return JSON-shaped values, not
* isolated-vm objects (`Reference`/`ExternalCopy`) or other non-cloneable
* values.
*
* @param data - Current workflow data
* @private
*/
private createCallHostRef(data: WorkflowData): ivm.Reference {
return new (getIvm().Reference)((rawMsg: unknown) => {
private createCallHostRef(data: WorkflowData): ivm.Callback {
return new (getIvm().Callback)((rawMsg: unknown) => {
try {
const msg = bridgeMessageSchema.parse(rawMsg);
switch (msg.type) {
@ -695,7 +701,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
* Execute JavaScript code in the isolated context.
*
* Flow:
* 1. Create three ivm.Reference callbacks scoped to the current data:
* 1. Create three ivm.Callback instances scoped to the current data:
* `getValueAtPath`, `getArrayElement`, `callHost`.
* 2. Use evalClosureSync to run the code in a closure where `$0`/`$1`/`$2`
* are the callback references — no global mutable state.
@ -715,6 +721,10 @@ export class IsolatedVmBridge implements RuntimeBridge {
throw new Error('Bridge not initialized. Call initialize() first.');
}
// Host callbacks are ivm.Callback instances: inside the isolate they
// arrive as plain functions with structured-clone marshaling, so the
// runtime invokes them directly. Callbacks are GC-managed; there is no
// release() to call in `finally`.
const getValueAtPath = this.createGetValueAtPathRef(data);
const getArrayElement = this.createGetArrayElementRef(data);
const callHost = this.createCallHostRef(data);
@ -796,10 +806,6 @@ try {
);
}
throw new Error(`Expression evaluation failed: ${errorMessage}`);
} finally {
getValueAtPath.release();
getArrayElement.release();
callHost.release();
}
}

View File

@ -1,9 +1,7 @@
import { buildContext } from '../context';
function makeRef(impl: (args: unknown[]) => unknown) {
return {
applySync: (_thisArg: unknown, args: unknown[]) => impl(args),
};
return (...args: unknown[]) => impl(args);
}
describe('buildContext proxy', () => {

View File

@ -5,8 +5,6 @@ import { createDeepLazyProxy, isLazyProxy, getProxyPath } from '../lazy-proxy';
// Helpers
// ---------------------------------------------------------------------------
const ivmCallOpts = { arguments: { copy: true }, result: { copy: true } };
function mockApplySync(returnValue: unknown = undefined) {
return vi.fn().mockReturnValue(returnValue);
}
@ -22,8 +20,8 @@ function createMockCallbacks(
const getArrayElement = overrides.getArrayElement ?? mockApplySync();
const callbacks = {
getValueAtPath: { applySync: getValueAtPath },
getArrayElement: { applySync: getArrayElement },
getValueAtPath,
getArrayElement,
};
return { getValueAtPath, getArrayElement, callbacks };
@ -231,7 +229,7 @@ describe('createDeepLazyProxy', () => {
const p = proxy(['data']);
mocks.getArrayElement.mockReturnValue('val');
p.list[3];
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['data', 'list'], 3], ivmCallOpts);
expect(mocks.getArrayElement).toHaveBeenCalledWith(['data', 'list'], 3);
});
it('returns undefined for non-numeric non-length properties', () => {
@ -272,15 +270,15 @@ describe('createDeepLazyProxy', () => {
// Each level triggers __getValueAtPath and creates a nested proxy
// a -> returns object metadata
const a = p.a;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a']);
// a.b -> returns object metadata
const b = a.b;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a', 'b']);
// a.b.c -> returns object metadata
const c = b.c;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b', 'c']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a', 'b', 'c']);
expect(getProxyPath(c)).toEqual(['a', 'b', 'c']);
});
@ -303,7 +301,7 @@ describe('createDeepLazyProxy', () => {
mocks.getValueAtPath.mockReturnValue('val');
const p = proxy(['$json']);
p.user;
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['$json', 'user']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenCalledWith(['$json', 'user']);
});
it('nested proxies inherit full path', () => {
@ -315,11 +313,7 @@ describe('createDeepLazyProxy', () => {
// Accessing a property on the nested proxy should build the full path
mocks.getValueAtPath.mockReturnValue('Alice');
user.name;
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(
null,
[['$json', 'user', 'name']],
ivmCallOpts,
);
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['$json', 'user', 'name']);
});
});
@ -369,7 +363,7 @@ describe('createDeepLazyProxy', () => {
mocks.getValueAtPath.mockReturnValue('val');
const p = proxy(['$json']);
'foo' in p;
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['$json', 'foo']], ivmCallOpts);
expect(mocks.getValueAtPath).toHaveBeenCalledWith(['$json', 'foo']);
});
});
@ -461,7 +455,7 @@ describe('createDeepLazyProxy', () => {
const p = arrayProxy(['arr'], 3);
mocks.getArrayElement.mockReturnValue('first');
expect(p[0]).toBe('first');
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['arr'], 0], ivmCallOpts);
expect(mocks.getArrayElement).toHaveBeenCalledWith(['arr'], 0);
});
it('nested array element returns an array-shaped child proxy', () => {

View File

@ -52,30 +52,23 @@ type InputRpcType = (typeof INPUT_RPC_TYPES)[keyof typeof INPUT_RPC_TYPES];
// ============================================================================
/**
* The subset of `ivm.Reference` shape the in-isolate runtime relies on.
* Declared locally rather than importing from `isolated-vm` because this
* module is bundled into the isolate IIFE, where the native module is
* unavailable. The host wires real `ivm.Reference` instances which
* structurally satisfy this interface.
* A bridge callback as seen from inside the isolate: a plain function.
* The host wires these as `ivm.Callback` instances, which arrive in the
* isolate as ordinary functions whose arguments and return value are copied
* via the structured-clone algorithm.
*/
interface BridgeCallback {
applySync(
thisArg: unknown,
args: unknown[],
options?: { arguments?: { copy?: boolean }; result?: { copy?: boolean } },
): unknown;
}
type BridgeCallback = (...args: unknown[]) => unknown;
/**
* Bridge callbacks the in-isolate runtime can invoke synchronously via
* `ivm.Reference.applySync`.
* Bridge callbacks the in-isolate runtime invokes synchronously as plain
* functions (host-side `ivm.Callback` instances).
*
* - `getValueAtPath`, `getArrayElement`: data-access primitives used by the
* lazy-proxy system. Hot path; one ivm.Reference each for minimum overhead.
* lazy-proxy system. Hot path; one ivm.Callback each for minimum overhead.
* - `callHost`: typed-RPC dispatcher. The in-isolate runtime constructs
* an envelope (e.g. `{ type: 'getNodeFirst', nodeName, ... }`) and the
* host-side dispatcher validates it with zod before routing to a handler.
* A single ivm.Reference covers every typed operation; new operations
* A single ivm.Callback covers every typed operation; new operations
* are new schemas in `bridge/bridge-messages.ts` + new cases in the
* dispatcher switch. The name reflects what this is: a synchronous
* host RPC, not a postMessage-style async send.
@ -198,11 +191,7 @@ export function buildContext(
const lazyProxy = createDeepLazyProxy(['$', nodeName], undefined, callbacks);
const sendNodeMethod = (type: NodeRpcType) => {
return (branchIndex?: number, runIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type, nodeName, branchIndex, runIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({ type, nodeName, branchIndex, runIndex });
throwIfErrorSentinel(result);
return result;
};
@ -217,18 +206,12 @@ export function buildContext(
type: 'getNodePairedItem' | 'getNodeItemMatching',
itemIndex?: number,
) => {
const result = callbacks.callHost.applySync(null, [{ type, nodeName, itemIndex }], {
arguments: { copy: true },
result: { copy: true },
});
const result = callbacks.callHost({ type, nodeName, itemIndex });
throwIfErrorSentinel(result);
return result;
};
const sendGetNodeItem = () => {
const result = callbacks.callHost.applySync(null, [{ type: 'getNodeItem', nodeName }], {
arguments: { copy: true },
result: { copy: true },
});
const result = callbacks.callHost({ type: 'getNodeItem', nodeName });
throwIfErrorSentinel(result);
return result;
};
@ -271,10 +254,7 @@ export function buildContext(
const lazyInputProxy = createDeepLazyProxy(['$input'], undefined, callbacks);
const sendInputMethod = (type: InputRpcType) => {
return () => {
const result = callbacks.callHost.applySync(null, [{ type }], {
arguments: { copy: true },
result: { copy: true },
});
const result = callbacks.callHost({ type });
throwIfErrorSentinel(result);
return result;
};
@ -295,11 +275,7 @@ export function buildContext(
// args, and the host's `WorkflowDataProxy.$items` applies its own
// defaults when fields are undefined.
target.$items = (nodeName?: string, outputIndex?: number, runIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'getItems', nodeName, outputIndex, runIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({ type: 'getItems', nodeName, outputIndex, runIndex });
throwIfErrorSentinel(result);
return result;
};
@ -316,11 +292,13 @@ export function buildContext(
valueType?: string,
defaultValue?: unknown,
) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'fromAi', name, description, valueType, defaultValue }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({
type: 'fromAi',
name,
description,
valueType,
defaultValue,
});
throwIfErrorSentinel(result);
return result;
};
@ -333,11 +311,7 @@ export function buildContext(
// Under the VM engine this re-enters the bridge on a fresh evaluation;
// the legacy engine handles it inline.
target.$evaluateExpression = (expression: string, itemIndex?: number) => {
const result = callbacks.callHost.applySync(
null,
[{ type: 'evaluateExpression', expression, itemIndex }],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({ type: 'evaluateExpression', expression, itemIndex });
throwIfErrorSentinel(result);
return result;
};
@ -352,18 +326,12 @@ export function buildContext(
incomingSourceData: unknown,
initialPairedItem: unknown,
) => {
const result = callbacks.callHost.applySync(
null,
[
{
type: 'getPairedItem',
destinationNodeName,
incomingSourceData,
initialPairedItem,
},
],
{ arguments: { copy: true }, result: { copy: true } },
);
const result = callbacks.callHost({
type: 'getPairedItem',
destinationNodeName,
incomingSourceData,
initialPairedItem,
});
throwIfErrorSentinel(result);
return result;
};
@ -374,7 +342,7 @@ export function buildContext(
// so each key is fetched at most once per evaluation.
// -------------------------------------------------------------------------
// Track keys we've already probed so we never call applySync twice
// Track keys we've already probed so we never call getValueAtPath twice
// for the same key — even if the host returned undefined.
const probedKeys = new Set<string>();
@ -383,10 +351,7 @@ export function buildContext(
let value: unknown;
try {
value = callbacks.getValueAtPath.applySync(null, [[key]], {
arguments: { copy: true },
result: { copy: true },
});
value = callbacks.getValueAtPath([key]);
} catch {
// Don't mark as probed — the throw may be transient
// (e.g. host data not yet available) and a retry should be allowed.

View File

@ -106,7 +106,7 @@ export type ProxyMeta = { kind: 'object'; keys?: string[] } | { kind: 'array'; l
*
* @param basePath - Current path in object tree (e.g., ['$json', 'user'])
* @param meta - Optional shape descriptor (object/array + known keys/length)
* @param callbacks - ivm.Reference callbacks for cross-isolate communication
* @param callbacks - ivm.Callback functions for cross-isolate communication
* @returns Proxy object with lazy loading behavior
*/
export function createDeepLazyProxy(
@ -133,10 +133,7 @@ export function createDeepLazyProxy(
function resolveObjectKeys(): string[] {
if (objectKeys) return objectKeys;
if (fetchedKeys) return fetchedKeys;
const value = getValueAtPath.applySync(null, [basePath], {
arguments: { copy: true },
result: { copy: true },
});
const value = getValueAtPath(basePath);
throwIfErrorSentinel(value);
if (isObjectMetadata(value)) {
fetchedKeys = value.__keys;
@ -252,10 +249,7 @@ export function createDeepLazyProxy(
if (isArray) {
const idx = isInArrayBounds(prop);
if (idx === undefined) return undefined;
const element = getArrayElement.applySync(null, [basePath, idx], {
arguments: { copy: true },
result: { copy: true },
});
const element = getArrayElement(basePath, idx);
// Primitives (and null) skip `materializeChild`'s metadata checks.
if (element === null || typeof element !== 'object') {
targetObj[prop] = element;
@ -269,12 +263,8 @@ export function createDeepLazyProxy(
// materializeChild rebuilds it only inside metadata branches.
const path = [...basePath, prop];
// Call back to host to get metadata/value
// Note: getValueAtPath is an ivm.Reference passed via callbacks
const value = getValueAtPath.applySync(null, [path], {
arguments: { copy: true },
result: { copy: true },
});
// Call back to host to get metadata/value.
const value = getValueAtPath(path);
targetObj[prop] = materializeChild(basePath, prop, value);
return targetObj[prop];
@ -301,10 +291,7 @@ export function createDeepLazyProxy(
// Build path and check existence via callback
const path = [...basePath, prop];
const value = getValueAtPath.applySync(null, [path], {
arguments: { copy: true },
result: { copy: true },
});
const value = getValueAtPath(path);
// Handle errors serialized by host-side callbacks — reconstruct and throw
// so the isolate's outer try-catch can serialize them back via __reportError

View File

@ -137,10 +137,10 @@ export interface NodeProxy {
* type.
*
* Return types are `unknown` rather than `INodeExecutionData` / `[]`:
* results cross the isolate boundary via `applySync({ result: { copy: true } })`,
* which structured-clones the value and erases nominal types. The handlers
* pass the clone through verbatim, so a precise return type would be
* misleading. Matches the `NodeProxy` return type for the same reason.
* `ivm.Callback` structured-clones results as they pass between isolates,
* which erases nominal types. The handlers pass the clone through verbatim,
* so a precise return type would be misleading. Matches the `NodeProxy`
* return type for the same reason.
*/
export interface InputProxy {
first?: () => unknown;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "2.36.0",
"version": "2.36.1",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/tournament",
"version": "1.9.0",
"version": "1.9.1",
"description": "Output compatible rewrite of riot tmpl",
"main": "dist/index.js",
"module": "src/index.ts",

View File

@ -3,7 +3,10 @@ import type {
ExpressionKind,
PatternKind,
PropertyKind,
SpreadElementKind,
SpreadPropertyKind,
StatementKind,
SwitchCaseKind,
VariableDeclaratorKind,
} from 'ast-types/lib/gen/kinds';
@ -23,4 +26,7 @@ export type ParentKind =
| PropertyKind
| PatternKind
| VariableDeclaratorKind
| CatchClauseKind;
| CatchClauseKind
| SpreadElementKind
| SpreadPropertyKind
| SwitchCaseKind;

View File

@ -80,6 +80,10 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
}
},
Property(path, parent: namedTypes.Property, dataNode) {
if (parent.computed && parent.key === path.node) {
polyfillVar(path, dataNode);
return;
}
if (path.node !== parent.value) {
return;
}
@ -117,6 +121,36 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
polyfillVar(path, dataNode);
}
},
SpreadElement(path, parent: namedTypes.SpreadElement, dataNode) {
if (parent.argument === path.node) {
polyfillVar(path, dataNode);
}
},
SpreadProperty(path, parent: namedTypes.SpreadProperty, dataNode) {
if (parent.argument === path.node) {
polyfillVar(path, dataNode);
}
},
MethodDefinition(path, parent: namedTypes.MethodDefinition, dataNode) {
if (parent.computed && parent.key === path.node) {
polyfillVar(path, dataNode);
}
},
SwitchCase(path, parent: namedTypes.SwitchCase, dataNode) {
if (parent.test === path.node) {
polyfillVar(path, dataNode);
}
},
ClassDeclaration(path, parent: namedTypes.ClassDeclaration, dataNode) {
if (parent.superClass === path.node) {
polyfillVar(path, dataNode);
}
},
ClassExpression(path, parent: namedTypes.ClassExpression, dataNode) {
if (parent.superClass === path.node) {
polyfillVar(path, dataNode);
}
},
};
export const jsVariablePolyfill = (
@ -143,6 +177,12 @@ export const jsVariablePolyfill = (
case 'OptionalMemberExpression':
case 'VariableDeclarator':
case 'ArrowFunctionExpression':
case 'SpreadElement':
case 'SpreadProperty':
case 'MethodDefinition':
case 'SwitchCase':
case 'ClassDeclaration':
case 'ClassExpression':
if (!customPatches[parent.type]) {
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
}
@ -211,12 +251,9 @@ export const jsVariablePolyfill = (
case 'RestElement':
case 'ArrayPattern':
case 'ObjectPattern':
case 'ClassExpression':
case 'RecordExpression':
case 'V8IntrinsicIdentifier':
case 'TopicReference':
case 'MethodDefinition':
case 'ClassDeclaration':
case 'ClassProperty':
case 'StaticBlock':
case 'ClassBody':
@ -314,6 +351,7 @@ export const jsVariablePolyfill = (
// This is a simple type guard that guarantees we haven't missed
// a case. It'll result in a type error at compile time.
assertNever(parent);
polyfillVar(path, dataNode);
break;
}
},

View File

@ -0,0 +1,107 @@
import { Tournament } from '../src/index';
const evaluator = new Tournament((e) => {
throw e;
});
/**
* Every identifier that is a free read has to be resolved through the data
* context. One that is skipped resolves against the host scope instead, which
* is how `process` and friends become reachable from an expression.
*/
describe('jsVariablePolyfill', () => {
describe('free reads resolve through the data context', () => {
it.each([
['object spread', '{{ ({...value}).a }}'],
['nested object spread', '{{ ({...({...value})}).a }}'],
['computed object key', '{{ ({[key]: 1}).a }}'],
['computed class field', '{{ (() => { class X { [key] = 1; } return new X().a; })() }}'],
[
'computed class method',
'{{ (() => { class X { [key]() { return 1; } } return new X().a(); })() }}',
],
['switch case', '{{ (() => { switch (1) { case one: return 1; } })() }}'],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, { value: { a: 1 }, key: 'a', one: 1 })).toBe(1);
});
it('array spread', () => {
expect(evaluator.execute('{{ [...value].length }}', { value: [1, 2, 3] })).toBe(3);
});
it('call argument spread', () => {
expect(evaluator.execute('{{ Math.max(...value) }}', { value: [1, 5, 3] })).toBe(5);
});
it('base class', () => {
class Base {
greet() {
return 'hello';
}
}
expect(
evaluator.execute('{{ (() => { class X extends Base {} return new X().greet(); })() }}', {
Base,
}),
).toBe('hello');
});
});
describe('unresolved free reads do not fall through to the host scope', () => {
it.each([
['object spread', '{{ ({...process}) }}'],
['nested object spread', '{{ ({...({...process})}) }}'],
['spread inside a function', '{{ (() => ({...process}))() }}'],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, {})).toEqual({});
});
it.each([
['array spread', '{{ [...process] }}'],
['call argument spread', '{{ ((a) => a)(...process) }}'],
])('%s', (_, expression) => {
expect(() => evaluator.execute(expression, {})).toThrow(/is not iterable/);
});
it('base class', () => {
expect(() => evaluator.execute('{{ (() => { class X extends Buffer {} })() }}', {})).toThrow(
/is not a constructor or null/,
);
});
it('switch case', () => {
expect(
evaluator.execute(
'{{ (() => { switch (1) { case process: return "host"; } return "safe"; })() }}',
{},
),
).toBe('safe');
});
it.each([
['computed object key', '{{ Object.keys({[process]: 1})[0] }}'],
[
'computed class method key',
'{{ (() => { class X { [process]() {} } return Object.getOwnPropertyNames(X.prototype)[1]; })() }}',
],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, { Object })).toBe('undefined');
});
});
describe('bindings are left alone', () => {
it.each([
['rest property', '{{ (() => { const {...rest} = value; return rest.a; })() }}'],
['rest element', '{{ (() => { const [...rest] = [1]; return rest[0]; })() }}'],
['rest parameter', '{{ ((...rest) => rest[0])(1) }}'],
[
'local shadowing a host global',
'{{ (() => { const process = value; return {...process}.a; })() }}',
],
['parameter shadowing a host global', '{{ ((process) => ({...process}).a)(value) }}'],
])('%s', (_, expression) => {
expect(evaluator.execute(expression, { value: { a: 1 } })).toBe(1);
});
});
});

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/workflow-sdk",
"version": "0.29.0",
"version": "0.29.1",
"description": "TypeScript SDK for programmatically creating n8n workflows",
"exports": {
".": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "2.36.0",
"version": "2.36.2",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",
@ -227,7 +227,7 @@
"ioredis": "5.3.2",
"isbot": "3.6.13",
"isolated-vm": "catalog:",
"jose": "^6.2.2",
"jose": "^6.2.9",
"json-diff": "1.0.6",
"json-schema": "catalog:",
"jsonschema": "1.4.1",

View File

@ -5,6 +5,7 @@ import type {
ActionResultRequestDto,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import type { ExecutionContextService } from 'n8n-core';
import type {
ILoadOptions,
IWorkflowExecuteAdditionalData,
@ -14,19 +15,25 @@ import type {
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { AuthService } from '@/auth/auth.service';
import { DynamicNodeParametersController } from '@/controllers/dynamic-node-parameters.controller';
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import * as AdditionalData from '@/workflow-execute-additional-data';
describe('DynamicNodeParametersController', () => {
let service: Mocked<DynamicNodeParametersService>;
let authService: Mocked<AuthService>;
let executionContextService: Mocked<ExecutionContextService>;
let controller: DynamicNodeParametersController;
let mockUser: { id: string };
let baseAdditionalData: IWorkflowExecuteAdditionalData;
beforeEach(() => {
service = mock<DynamicNodeParametersService>();
controller = new DynamicNodeParametersController(service);
authService = mock<AuthService>();
authService.getCookieToken.mockReturnValue(undefined);
executionContextService = mock<ExecutionContextService>();
controller = new DynamicNodeParametersController(service, authService, executionContextService);
mockUser = { id: 'user123' };
baseAdditionalData = mock<IWorkflowExecuteAdditionalData>();
@ -221,4 +228,59 @@ describe('DynamicNodeParametersController', () => {
expect(result).toEqual(expectedResult);
});
});
describe('execution context', () => {
const payload: ResourceLocatorRequestDto = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
methodName: 'testMethod',
};
it("seals the request's own auth cookie and request context", async () => {
// Design-time loading resolves end-user credentials against the requesting
// user's connection, so their identity has to travel with the request.
authService.getCookieToken.mockReturnValue('n8n-auth-cookie-jwt');
authService.getMethod.mockReturnValue('POST');
authService.getEndpoint.mockReturnValue('/rest/dynamic-node-parameters/options');
authService.getBrowserId.mockReturnValue('browser-abc');
executionContextService.buildRequestBoundCredentials.mockResolvedValue('sealed');
service.getResourceLocatorResults.mockResolvedValue({ results: [] });
await controller.getResourceLocatorResults(
{ user: mockUser } as AuthenticatedRequest,
mock(),
payload,
);
expect(executionContextService.buildRequestBoundCredentials).toHaveBeenCalledWith(
'n8n-auth-cookie-jwt',
{
method: 'POST',
endpoint: '/rest/dynamic-node-parameters/options',
browserId: 'browser-abc',
},
);
expect(baseAdditionalData.executionContext).toEqual({
version: 1,
establishedAt: expect.any(Number),
source: 'internal',
credentials: 'sealed',
});
});
it('leaves a caller without an auth cookie without a context', async () => {
// API-key callers keep the existing static-data behaviour rather than failing.
authService.getCookieToken.mockReturnValue(undefined);
service.getResourceLocatorResults.mockResolvedValue({ results: [] });
await controller.getResourceLocatorResults(
{ user: mockUser } as AuthenticatedRequest,
mock(),
payload,
);
expect(executionContextService.buildRequestBoundCredentials).not.toHaveBeenCalled();
expect(baseAdditionalData.executionContext).toBeUndefined();
});
});
});

View File

@ -6,14 +6,52 @@ import {
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Post, RestController, Body } from '@n8n/decorators';
import type { INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow';
import { ExecutionContextService } from 'n8n-core';
import type { IExecutionContext, INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow';
import { AuthService } from '@/auth/auth.service';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { getBase } from '@/workflow-execute-additional-data';
@RestController('/dynamic-node-parameters')
export class DynamicNodeParametersController {
constructor(private readonly dynamicNodeParametersService: DynamicNodeParametersService) {}
constructor(
private readonly dynamicNodeParametersService: DynamicNodeParametersService,
private readonly authService: AuthService,
private readonly executionContextService: ExecutionContextService,
) {}
/**
* Seals the requesting user's own identity into an execution context, so that
* dropdowns backed by an end-user credential resolve against the connection that
* user already made. These routes run in mode `internal`, which skips dynamic
* credential resolution unless the context carries a credential context — without
* one the node falls back to static data that holds no per-user token.
*
* Returns `undefined` for callers without an auth cookie (API keys, for instance),
* which keeps them on the existing static-data behaviour rather than failing them.
*/
private async buildExecutionContext(
req: AuthenticatedRequest,
): Promise<IExecutionContext | undefined> {
const authCookie = this.authService.getCookieToken(req);
if (authCookie === undefined) return undefined;
// Request-bound rather than `manual-execution`: the browser id, method and endpoint
// re-checked at resolution time are the ones this request already authenticated
// with, so the check cannot newly fail while keeping the cookie usable only for the
// request it came in on.
const credentials = await this.executionContextService.buildRequestBoundCredentials(
authCookie,
{
method: this.authService.getMethod(req),
endpoint: this.authService.getEndpoint(req),
browserId: this.authService.getBrowserId(req),
},
);
return { version: 1, establishedAt: Date.now(), source: 'internal', credentials };
}
@Post('/options')
async getOptions(
@ -39,6 +77,7 @@ export class DynamicNodeParametersController {
currentNodeParameters,
});
additionalData.dataTableProjectId = projectId;
additionalData.executionContext = await this.buildExecutionContext(req);
if (methodName) {
return await this.dynamicNodeParametersService.getOptionsViaMethodName(
@ -89,6 +128,7 @@ export class DynamicNodeParametersController {
currentNodeParameters,
});
additionalData.dataTableProjectId = projectId;
additionalData.executionContext = await this.buildExecutionContext(req);
return await this.dynamicNodeParametersService.getResourceLocatorResults(
methodName,
@ -119,6 +159,7 @@ export class DynamicNodeParametersController {
currentNodeParameters,
});
additionalData.dataTableProjectId = projectId;
additionalData.executionContext = await this.buildExecutionContext(req);
return await this.dynamicNodeParametersService.getResourceMappingFields(
methodName,
@ -146,6 +187,8 @@ export class DynamicNodeParametersController {
projectId,
});
// No execution context here: local resource mapping reads a sub-workflow's own
// inputs and never touches a credential.
return await this.dynamicNodeParametersService.getLocalResourceMappingFields(
methodName,
path,
@ -177,6 +220,7 @@ export class DynamicNodeParametersController {
projectId,
currentNodeParameters,
});
additionalData.executionContext = await this.buildExecutionContext(req);
return await this.dynamicNodeParametersService.getActionResult(
handler,

View File

@ -145,9 +145,9 @@ describe('AgentKnowledgeMirrorService', () => {
makeAgentFile({ id: 'file-delta', fileName: 'delta.md' }),
];
function makeGlobService(): AgentKnowledgeMirrorService {
function makeGlobService(files: AgentFile[] = fixtureFiles): AgentKnowledgeMirrorService {
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue(fixtureFiles);
agentFileRepository.findByAgentId.mockResolvedValue(files);
const agentRepository = mock<AgentRepository>();
agentRepository.existsBy.mockResolvedValue(true);
return makeService({ runtimeService, agentFileRepository, agentRepository });
@ -204,6 +204,21 @@ describe('AgentKnowledgeMirrorService', () => {
service.globKnowledgeFiles(projectId, agentId, { pattern: '../secrets' }),
).rejects.toThrow('Invalid knowledge file pattern');
});
it('propagates glob matching timeouts', async () => {
const service = makeGlobService(
Array.from({ length: 200 }, (_, index) =>
makeAgentFile({
id: `file-quarterly-${index}`,
fileName: `quarterly-knowledge-${index}.pdf`,
}),
),
);
await expect(
service.globKnowledgeFiles(projectId, agentId, { pattern: '******Z' }),
).rejects.toThrow('Regular expression execution timed out');
});
});
it('returns an empty search without acquiring a sandbox when no files exist', async () => {

View File

@ -2,7 +2,7 @@ import type { CommandResult } from '@n8n/agents/sandbox';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { OperationalError } from 'n8n-workflow';
import { OperationalError, safeRegex } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -51,6 +51,8 @@ import {
import { AgentFileRepository } from './repositories/agent-file.repository';
import { AgentRepository } from './repositories/agent.repository';
const GLOB_MATCH_TIMEOUT_MS = 250;
interface AgentKnowledgeMirrorRuntime extends AgentSandboxRuntime {
paths: AgentKnowledgePaths;
}
@ -538,10 +540,24 @@ function matchKnowledgeFilesByGlob(
request: GlobKnowledgeFilesRequest,
): AgentKnowledgeFileReference[] {
const caseSensitive = request.caseSensitive === true;
const regex = globPatternToRegExp(request.pattern, caseSensitive);
const source = globPatternToRegexSource(request.pattern);
const flags = caseSensitive ? undefined : 'i';
const patternTokens = tokenizeKnowledgeFilePattern(request.pattern, caseSensitive);
const deadline = performance.now() + GLOB_MATCH_TIMEOUT_MS;
const matchesGlob = (fileName: string): boolean => {
if (performance.now() >= deadline) {
throw new Error('Regular expression execution timed out');
}
const matches = safeRegex.test(source, fileName, flags);
if (performance.now() >= deadline) {
throw new Error('Regular expression execution timed out');
}
return matches;
};
return files
.filter((file) => regex.test(file.file) || regex.test(file.displayName))
.filter((file) => matchesGlob(file.file) || matchesGlob(file.displayName))
.map((file) => ({
file,
bucket: getKnowledgeFileMatchBucket(file, patternTokens, caseSensitive),
@ -627,7 +643,7 @@ function containsTokenSequence(fileTokens: string[], patternTokens: string[]): b
return false;
}
function globPatternToRegExp(pattern: string, caseSensitive: boolean): RegExp {
function globPatternToRegexSource(pattern: string): string {
let source = '^';
for (const character of pattern) {
@ -643,7 +659,7 @@ function globPatternToRegExp(pattern: string, caseSensitive: boolean): RegExp {
}
source += '$';
return new RegExp(source, caseSensitive ? undefined : 'i');
return source;
}
function escapeRegExp(value: string): string {

View File

@ -7,7 +7,12 @@ import type { AuthService } from '@/auth/auth.service';
import { AuthError } from '@/errors/response-errors/auth.error';
import type { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.service';
import { N8NIdentifier } from '../n8n-identifier';
import {
carriesN8nIdentity,
N8N_IDENTITY_SOURCES,
N8NIdentifierMetadataSchema,
N8NIdentifier,
} from '../n8n-identifier';
describe('N8NIdentifier', () => {
let identifier: N8NIdentifier;
@ -412,3 +417,31 @@ describe('N8NIdentifier', () => {
});
});
});
describe('carriesN8nIdentity', () => {
it.each(N8N_IDENTITY_SOURCES)('recognises the %s source', (source) => {
expect(carriesN8nIdentity({ identity: 'token', version: 1, metadata: { source } })).toBe(true);
});
it('recognises an n8n source even when the rest of the metadata is missing', () => {
// Fail-safe: an incomplete `cookie-source` context still carries an n8n token, and
// must not be waved through to a resolver that would treat it as an external one.
expect(
carriesN8nIdentity({ identity: 'token', version: 1, metadata: { source: 'cookie-source' } }),
).toBe(true);
});
it.each([
['slack-signature', { source: 'slack-signature' }],
['http-header', { source: 'http-header', headerName: 'authorization' }],
['no source at all', {}],
])('does not claim %s', (_label, metadata) => {
expect(carriesN8nIdentity({ identity: 'token', version: 1, metadata })).toBe(false);
});
it('covers every source the identifier accepts', () => {
// Guards against a source being added to the schema but not to the list, which
// would silently hand an n8n token to an external-subject resolver.
expect(N8NIdentifierMetadataSchema.options).toHaveLength(3);
});
});

View File

@ -6,12 +6,20 @@ import { z } from 'zod';
import { CredentialResolverError } from '@n8n/decorators';
import { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.service';
/**
* The `source` values this identifier accepts, declared once so the schemas below and
* {@link N8N_IDENTITY_SOURCES} cannot disagree: adding a value here reaches both.
*/
const MANUAL_EXECUTION_SOURCE = 'manual-execution';
const REQUEST_BOUND_SOURCES = ['chat-hub-injected', 'cookie-source'] as const;
const N8N_OAUTH_SOURCE = 'n8n-oauth';
const ManualExecutionMetadataSchema = z.object({
source: z.literal('manual-execution'),
source: z.literal(MANUAL_EXECUTION_SOURCE),
});
const RequestBoundMetadataSchema = z.object({
source: z.enum(['chat-hub-injected', 'cookie-source']),
source: z.enum(REQUEST_BOUND_SOURCES),
method: z.string(),
endpoint: z.string(),
browserId: z.string().optional(),
@ -28,18 +36,45 @@ const OAuthResourceGrantSchema = z.object({
}) satisfies z.ZodType<OAuthResourceGrant, z.ZodTypeDef, unknown>;
const N8nOAuthMetadataSchema = z.object({
source: z.literal('n8n-oauth'),
source: z.literal(N8N_OAUTH_SOURCE),
resource: z.string(),
/** Absent for contexts sealed before grants existed, and for long-lived resources. */
grant: OAuthResourceGrantSchema.optional(),
});
const N8NIdentifierMetadataSchema = z.discriminatedUnion('source', [
/** Exported for the drift test that keeps {@link N8N_IDENTITY_SOURCES} in step with it. */
export const N8NIdentifierMetadataSchema = z.discriminatedUnion('source', [
ManualExecutionMetadataSchema,
RequestBoundMetadataSchema,
N8nOAuthMetadataSchema,
]);
/**
* Every `source` the union above accepts, built from the same constants the schemas
* use, so a new value cannot reach the schema without reaching this list. A new
* metadata *shape* still has to be added here by hand — `n8n-identifier.test.ts`
* fails if the union grows a branch.
*/
export const N8N_IDENTITY_SOURCES: readonly string[] = [
MANUAL_EXECUTION_SOURCE,
...REQUEST_BOUND_SOURCES,
N8N_OAUTH_SOURCE,
];
/**
* Whether the context's identity is a token n8n itself issued (session cookie or
* n8n-issued OAuth access token), as opposed to one minted by an external provider.
*
* Callers use it to keep an n8n token away from resolvers that would hand it to a
* third party as if it were their own. Deliberately keyed on `source` alone rather
* than the full schema: a malformed n8n-sourced context must still be recognised as
* carrying an n8n token, not waved through as external.
*/
export function carriesN8nIdentity(context: ICredentialContext): boolean {
const source = (context.metadata as { source?: unknown } | undefined)?.source;
return typeof source === 'string' && N8N_IDENTITY_SOURCES.includes(source);
}
/**
* N8N JWT token identifier.
* Validates n8n authentication tokens and resolves them to user IDs.

View File

@ -0,0 +1,16 @@
import { CredentialResolutionError } from './credential-resolution.error';
/**
* Thrown when an n8n session identity is offered to a resolver that keys on an
* external subject (OAuth introspection, Slack). Those resolvers treat the context
* identity as a token issued by *their* provider, so passing an n8n session token
* would both fail and send that token to a third party.
*/
export class N8nIdentityNotSupportedError extends CredentialResolutionError {
constructor(credentialName: string) {
super(
`"${credentialName}" resolves credentials per external user, so it can only be resolved by a request carrying that user's token`,
);
this.name = 'N8nIdentityNotSupportedError';
}
}

View File

@ -1,4 +1,7 @@
import type { Logger } from '@n8n/backend-common';
import type { Request } from 'express';
import { ExecutionContextService } from 'n8n-core';
import type { Cipher, ExecutionContextHookRegistry } from 'n8n-core';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -20,7 +23,16 @@ describe('DynamicCredentialWebService', () => {
getEndpoint: vi.fn(),
});
service = new DynamicCredentialWebService(mockAuthService);
// The real context builder, so these tests keep asserting the shape the resolver
// validates. Its collaborators are unused by the plaintext builder.
service = new DynamicCredentialWebService(
mockAuthService,
new ExecutionContextService(
mock<Logger>(),
mock<ExecutionContextHookRegistry>(),
mock<Cipher>(),
),
);
});
describe('getCredentialContextFromRequest', () => {

View File

@ -24,6 +24,7 @@ import type { DynamicCredentialResolver } from '../../database/entities/credenti
import type { DynamicCredentialResolverRepository } from '../../database/repositories/credential-resolver.repository';
import type { DynamicCredentialsConfig } from '../../dynamic-credentials.config';
import { CredentialResolutionError } from '../../errors/credential-resolution.error';
import { N8nIdentityNotSupportedError } from '../../errors/n8n-identity-not-supported.error';
import { CredentialResolverNotConfiguredError } from '../../errors/credential-resolver-not-configured.error';
import { CredentialResolverNotFoundError } from '../../errors/credential-resolver-not-found.error';
import { MissingExecutionContextError } from '../../errors/missing-execution-context.error';
@ -78,6 +79,8 @@ describe('DynamicCredentialService', () => {
shouldSucceed = true,
shouldThrowDataNotFound = false,
customData?: ICredentialDataDecryptedObject,
/** Set for resolvers that key on n8n users, as the real n8n resolver does. */
keysOnN8nUser = false,
): Mocked<ICredentialResolver> => ({
metadata: {
name: 'test-resolver-1.0',
@ -94,6 +97,7 @@ describe('DynamicCredentialService', () => {
}),
setSecret: vi.fn(),
validateOptions: vi.fn(),
...(keysOnN8nUser && { resolveOwningUserId: vi.fn().mockResolvedValue('user-123') }),
});
const createMockExecutionContext = (credentials?: string): IExecutionContext => ({
@ -501,6 +505,33 @@ describe('DynamicCredentialService', () => {
);
});
it('refuses to hand an n8n identity to an external-subject resolver', async () => {
const credentialsEntity = createMockCredentialsMetadata();
// Default resolver type is an external (non-n8n) resolver: its identifier reads
// `context.identity` as a token its own provider issued, so giving it an n8n
// session token would send that token to the provider.
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
const executionContext = createMockExecutionContext('encrypted-credentials');
const credentialContext = createMockCredentialContext({ source: 'manual-execution' });
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
mockCipher.decryptV2.mockResolvedValue(JSON.stringify(credentialContext));
await expect(
service.resolveIfNeeded(
credentialsEntity,
staticData,
additionalData.executionContext,
undefined,
),
).rejects.toThrow(N8nIdentityNotSupportedError);
expect(mockResolver.getSecret).not.toHaveBeenCalled();
});
it('external-identity resolver throws CredentialResolverDataNotFoundError keeps the generic message', async () => {
const credentialsEntity = createMockCredentialsMetadata();
// Default resolver type is an external (non-n8n) resolver
@ -531,7 +562,7 @@ describe('DynamicCredentialService', () => {
it('n8n private-credential resolver throws CredentialResolverDataNotFoundError surfaces the not-connected message', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity({ type: SYSTEM_RESOLVER_TYPE });
const mockResolver = createMockResolver(false, true);
const mockResolver = createMockResolver(false, true, undefined, true);
const executionContext = createMockExecutionContext('encrypted-credentials');
const credentialContext = createMockCredentialContext({ source: 'manual-execution' });
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
@ -557,7 +588,7 @@ describe('DynamicCredentialService', () => {
it('n8n private-credential resolver surfaces the not-connected message regardless of trigger source', async () => {
const credentialsEntity = createMockCredentialsMetadata();
const resolverEntity = createMockResolverEntity({ type: SYSTEM_RESOLVER_TYPE });
const mockResolver = createMockResolver(false, true);
const mockResolver = createMockResolver(false, true, undefined, true);
const executionContext = createMockExecutionContext('encrypted-credentials');
// Chat-hub triggered run still resolves to an n8n user, so the message applies
const credentialContext = createMockCredentialContext({ source: 'chat-hub-injected' });

View File

@ -1,6 +1,7 @@
import { Z } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { Request } from 'express';
import { ExecutionContextService } from 'n8n-core';
import { ICredentialContext } from 'n8n-workflow';
import { z } from 'zod';
@ -32,23 +33,21 @@ function getBearerToken(req: Request): string | null {
@Service()
export class DynamicCredentialWebService {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly executionContextService: ExecutionContextService,
) {}
private buildCookieCredentialContext(req: Request): ICredentialContext {
const sessionCookie = this.authService.getCookieToken(req);
if (sessionCookie === undefined) {
throw new UnauthenticatedError('Session cookie is missing');
}
return {
identity: sessionCookie,
version: 1,
metadata: {
source: 'cookie-source',
browserId: this.authService.getBrowserId(req),
method: this.authService.getMethod(req),
endpoint: this.authService.getEndpoint(req),
},
};
return this.executionContextService.buildRequestBoundCredentialContext(sessionCookie, {
method: this.authService.getMethod(req),
endpoint: this.authService.getEndpoint(req),
browserId: this.authService.getBrowserId(req),
});
}
getCredentialContextFromRequest(req: Request): ICredentialContext {

View File

@ -16,6 +16,7 @@ import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { StaticAuthService } from '@/services/static-auth-service';
import { carriesN8nIdentity } from '../credential-resolvers/identifiers/n8n-identifier';
import { DynamicCredentialResolverRegistry } from './credential-resolver-registry.service';
import { ResolverConfigExpressionService } from './resolver-config-expression.service';
import { extractSharedFields } from './shared-fields';
@ -31,6 +32,7 @@ import { CredentialResolutionError } from '../errors/credential-resolution.error
import { CredentialResolverNotConfiguredError } from '../errors/credential-resolver-not-configured.error';
import { CredentialResolverNotFoundError } from '../errors/credential-resolver-not-found.error';
import { MissingExecutionContextError } from '../errors/missing-execution-context.error';
import { N8nIdentityNotSupportedError } from '../errors/n8n-identity-not-supported.error';
/**
* Service for resolving credentials dynamically via configured resolvers.
@ -103,6 +105,20 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
return this.handleMissingContext(credentialsResolveMetadata);
}
if (carriesN8nIdentity(credentialContext) && !resolver.resolveOwningUserId) {
// The identity is an n8n session token and this resolver keys on an external
// subject, so it would hand that token to the external provider it validates
// against (its identifier reads `context.identity` as its own token). Refuse
// instead of leaking it on a request that cannot succeed anyway.
this.logger.debug('Refused to resolve external-subject credential with n8n identity', {
credentialId: credentialsResolveMetadata.id,
credentialName: credentialsResolveMetadata.name,
resolverId,
resolverType: resolverEntity.type,
});
throw new N8nIdentityNotSupportedError(credentialsResolveMetadata.name);
}
try {
const credentialType = this.loadNodesAndCredentials.getCredential(
credentialsResolveMetadata.type,

View File

@ -35,7 +35,7 @@ afterAll(async () => {
describe('InsightsController', () => {
const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository);
const workflowSharingService = mockInstance(WorkflowSharingService);
mockInstance(WorkflowSharingService);
let controller: InsightsController;
const sevenDaysAgo = DateTime.now().minus({ days: 7 }).toJSDate();
const today = DateTime.now().toJSDate();
@ -365,13 +365,6 @@ describe('InsightsController', () => {
},
];
beforeEach(() => {
// getSharedWorkflowIds returns all workflow IDs for the owner-like user
workflowSharingService.getSharedWorkflowIds.mockResolvedValue(
mockRows.map((row) => row.workflowId),
);
});
it('should return empty insights by workflow if no data', async () => {
// ARRANGE
insightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({ count: 0, rows: [] });

View File

@ -2,12 +2,14 @@ import type { LicenseState } from '@n8n/backend-common';
import {
createTeamProject,
createWorkflow,
linkUserToProject,
mockLogger,
testDb,
testModules,
} from '@n8n/backend-test-utils';
import type { InstanceType } from '@n8n/constants';
import type { IWorkflowDb, Project, User, WorkflowEntity } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { DateTime } from 'luxon';
import type { InstanceSettings } from 'n8n-core';
@ -16,9 +18,12 @@ import type { MockInstance, Mocked } from 'vitest';
import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { createMember } from '@test-integration/db/users';
import { createCompactedInsightsEvent } from '../database/entities/__tests__/db-utils';
import type { InsightsByPeriod } from '../database/entities/insights-by-period';
import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
import { InsightsCollectionService } from '../insights-collection.service';
import type { InsightsCompactionService } from '../insights-compaction.service';
@ -36,8 +41,11 @@ describe('InsightsService (Integration)', () => {
'InsightsRaw',
'InsightsByPeriod',
'InsightsMetadata',
'SharedWorkflow',
'WorkflowEntity',
'ProjectRelation',
'Project',
'User',
]);
});
@ -144,6 +152,10 @@ describe('InsightsService (Integration)', () => {
let project: Project;
let workflow: IWorkflowDb & WorkflowEntity;
const globalWorkflowReadUser = {
role: { scopes: [{ slug: 'workflow:read' }] },
} as unknown as User;
beforeEach(async () => {
project = await createTeamProject();
workflow = await createWorkflow({}, project);
@ -198,6 +210,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const summary = await insightsService.getInsightsSummary({
user: globalWorkflowReadUser,
startDate: startDate.toJSDate(),
endDate: endDate.toJSDate(),
});
@ -227,6 +240,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const summary = await insightsService.getInsightsSummary({
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
});
@ -294,6 +308,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const summary = await insightsService.getInsightsSummary({
user: globalWorkflowReadUser,
startDate: startDate.toJSDate(),
endDate: endDate.toJSDate(),
projectId: project.id,
@ -308,13 +323,157 @@ describe('InsightsService (Integration)', () => {
total: { value: 10, unit: 'count', deviation: -5 },
});
});
describe('project scoping', () => {
let member: User;
let otherProject: Project;
let startDate: Date;
let endDate: Date;
let workflowInsights: InsightsByPeriod;
let otherWorkflowInsights: InsightsByPeriod;
beforeEach(async () => {
member = await createMember();
otherProject = await createTeamProject();
const otherWorkflow = await createWorkflow({}, otherProject);
const now = DateTime.utc();
startDate = now.minus({ days: 6 }).toJSDate();
endDate = now.toJSDate();
// 4 successes in `project`, 10 in `otherProject`
[workflowInsights, otherWorkflowInsights] = await Promise.all([
createCompactedInsightsEvent(workflow, {
type: 'success',
value: 4,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
}),
createCompactedInsightsEvent(otherWorkflow, {
type: 'success',
value: 10,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
}),
]);
});
test('should aggregate only accessible projects when no project is requested', async () => {
await linkUserToProject(member, project, 'project:viewer');
const summary = await insightsService.getInsightsSummary({
user: member,
startDate,
endDate,
});
expect(summary.total.value).toBe(workflowInsights.value);
});
test('should return no results for a user with no accessible projects', async () => {
const summary = await insightsService.getInsightsSummary({
user: member,
startDate,
endDate,
});
expect(summary.total.value).toBe(0);
expect(summary.failed.value).toBe(0);
});
test('should aggregate all projects for users with the global workflow read scope', async () => {
const summary = await insightsService.getInsightsSummary({
user: globalWorkflowReadUser,
startDate,
endDate,
});
expect(summary.total.value).toBe(workflowInsights.value + otherWorkflowInsights.value);
});
test('should aggregate the requested project when it is accessible', async () => {
await linkUserToProject(member, project, 'project:viewer');
const summary = await insightsService.getInsightsSummary({
user: member,
startDate,
endDate,
projectId: project.id,
});
expect(summary.total.value).toBe(workflowInsights.value);
});
test('should throw a forbidden error when the requested project is not accessible', async () => {
await linkUserToProject(member, project, 'project:viewer');
await expect(
insightsService.getInsightsSummary({
user: member,
startDate,
endDate,
projectId: otherProject.id,
}),
).rejects.toThrow(ForbiddenError);
});
test('should throw a forbidden error when the requested project does not exist', async () => {
await expect(
insightsService.getInsightsSummary({
user: member,
startDate,
endDate,
projectId: 'non-existing-project-id',
}),
).rejects.toThrow(ForbiddenError);
});
test('should retain history from deleted workflows for users with the global workflow read scope', async () => {
// Deleting a workflow nulls the insights metadata FK but keeps the row
await Container.get(WorkflowRepository).delete({ id: workflow.id });
const summary = await insightsService.getInsightsSummary({
user: globalWorkflowReadUser,
startDate,
endDate,
});
expect(summary.total.value).toBe(workflowInsights.value + otherWorkflowInsights.value);
});
test('should exclude deleted workflow history when scoped to the requested project', async () => {
await linkUserToProject(member, project, 'project:viewer');
const deletedWorkflow = await createWorkflow({}, project);
await createCompactedInsightsEvent(deletedWorkflow, {
type: 'success',
value: 5,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ days: 1 }),
});
await Container.get(WorkflowRepository).delete({ id: deletedWorkflow.id });
const summary = await insightsService.getInsightsSummary({
user: member,
startDate,
endDate,
projectId: project.id,
});
// Only the live workflow's 4 successes; the deleted workflows are excluded
expect(summary.total.value).toBe(workflowInsights.value);
});
});
});
describe('getInsightsByWorkflow', () => {
let insightsService: InsightsService;
// Owner-like user with the global `workflow:read` scope, so every workflow is accessible
const owner = { role: { scopes: [{ slug: 'workflow:read' }] } } as unknown as User;
const globalWorkflowReadUser = {
role: { scopes: [{ slug: 'workflow:read' }] },
} as unknown as User;
beforeAll(() => {
insightsService = Container.get(InsightsService);
@ -415,7 +574,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: owner,
user: globalWorkflowReadUser,
startDate,
endDate,
});
@ -482,7 +641,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: owner,
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
sortBy: 'runTime:desc',
@ -510,7 +669,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: owner,
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
sortBy: 'succeeded:desc',
@ -538,7 +697,7 @@ describe('InsightsService (Integration)', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: owner,
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
skip: 10,
@ -608,7 +767,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: owner,
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
projectId: project.id,
@ -655,7 +814,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: owner,
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
});
@ -664,6 +823,148 @@ describe('InsightsService (Integration)', () => {
expect(byWorkflow.count).toEqual(0);
expect(byWorkflow.data).toHaveLength(0);
});
describe('project scoping', () => {
let member: User;
let startDate: Date;
let endDate: Date;
beforeEach(async () => {
member = await createMember();
const now = DateTime.utc();
startDate = now.minus({ days: 6 }).toJSDate();
endDate = now.toJSDate();
// workflow1 (in `project`) is accessible; workflow4 (in `project2`) is not
await createCompactedInsightsEvent(workflow1, {
type: 'success',
value: 4,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
});
await createCompactedInsightsEvent(workflow4, {
type: 'success',
value: 10,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
});
});
test('should list only workflows in projects the caller can read when no project is requested', async () => {
await linkUserToProject(member, project, 'project:viewer');
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
});
expect(byWorkflow.count).toBe(1);
expect(byWorkflow.data.map((row) => row.workflowId)).toEqual([workflow1.id]);
});
test('should list no workflows for a user with no accessible projects', async () => {
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
});
expect(byWorkflow.count).toBe(0);
expect(byWorkflow.data).toEqual([]);
});
test('should list workflows for the requested project when it is accessible', async () => {
await linkUserToProject(member, project, 'project:viewer');
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
projectId: project.id,
});
expect(byWorkflow.count).toBe(1);
expect(byWorkflow.data.map((row) => row.workflowId)).toEqual([workflow1.id]);
});
test('should throw a forbidden error when the requested project is not accessible', async () => {
await linkUserToProject(member, project, 'project:viewer');
await expect(
insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
projectId: project2.id,
}),
).rejects.toThrow(ForbiddenError);
});
test('should throw a forbidden error when the requested project does not exist', async () => {
await expect(
insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
projectId: 'non-existing-project-id',
}),
).rejects.toThrow(ForbiddenError);
});
test('should report a count consistent with the returned rows when results are scoped', async () => {
await linkUserToProject(member, project, 'project:viewer');
// A second accessible workflow, so pagination has something to page through
await createCompactedInsightsEvent(workflow2, {
type: 'success',
value: 1,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ days: 1 }),
});
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
take: 1,
});
// The inaccessible workflow4 must not inflate the count
expect(byWorkflow.count).toBe(2);
expect(byWorkflow.data).toHaveLength(1);
});
test('should retain a row for a deleted workflow when the caller has the global workflow read scope', async () => {
await Container.get(WorkflowRepository).delete({ id: workflow4.id });
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: globalWorkflowReadUser,
startDate,
endDate,
});
expect(byWorkflow.data.find((row) => row.workflowId === null)).toMatchObject({
total: 10,
hasReadAccess: false,
});
});
test('should exclude a row for a deleted workflow when the caller is scoped to its project', async () => {
await linkUserToProject(member, project2, 'project:viewer');
await Container.get(WorkflowRepository).delete({ id: workflow4.id });
const byWorkflow = await insightsService.getInsightsByWorkflow({
user: member,
startDate,
endDate,
projectId: project2.id,
});
expect(byWorkflow.data).toHaveLength(0);
});
});
});
describe('getInsightsByTime', () => {
@ -672,6 +973,10 @@ describe('InsightsService (Integration)', () => {
insightsService = Container.get(InsightsService);
});
const globalWorkflowReadUser = {
role: { scopes: [{ slug: 'workflow:read' }] },
} as unknown as User;
let project: Project;
let otherProject: Project;
let workflow1: IWorkflowDb & WorkflowEntity;
@ -691,6 +996,7 @@ describe('InsightsService (Integration)', () => {
const now = DateTime.utc();
const startDate = now.minus({ days: 14 }).toJSDate();
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
});
@ -709,6 +1015,7 @@ describe('InsightsService (Integration)', () => {
const startDate = now.minus({ days: 14 }).startOf('day').toJSDate();
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
});
@ -773,6 +1080,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
});
@ -851,6 +1159,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
insightTypes: ['time_saved_min', 'failure'],
@ -931,6 +1240,7 @@ describe('InsightsService (Integration)', () => {
// ACT
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate: now.toJSDate(),
projectId: project.id,
@ -985,6 +1295,141 @@ describe('InsightsService (Integration)', () => {
]),
);
});
describe('project scoping', () => {
let member: User;
let startDate: Date;
let endDate: Date;
let workflowInsights: InsightsByPeriod;
let otherWorkflowInsights: InsightsByPeriod;
beforeEach(async () => {
member = await createMember();
const now = DateTime.utc();
startDate = now.minus({ days: 6 }).toJSDate();
endDate = now.toJSDate();
// 4 successes in `project`, 10 in `otherProject`
[workflowInsights, otherWorkflowInsights] = await Promise.all([
createCompactedInsightsEvent(workflow1, {
type: 'success',
value: 4,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
}),
createCompactedInsightsEvent(workflow3, {
type: 'success',
value: 10,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
}),
]);
});
test('should aggregate only accessible projects when no project is requested', async () => {
await linkUserToProject(member, project, 'project:viewer');
const byTime = await insightsService.getInsightsByTime({
user: member,
startDate,
endDate,
});
expect(byTime).toHaveLength(1);
expect(byTime[0].values.succeeded).toBe(workflowInsights.value);
});
test('should return no results for a user with no accessible projects', async () => {
const byTime = await insightsService.getInsightsByTime({
user: member,
startDate,
endDate,
});
expect(byTime).toHaveLength(0);
});
test('should aggregate the requested project when it is accessible', async () => {
await linkUserToProject(member, project, 'project:viewer');
const byTime = await insightsService.getInsightsByTime({
user: member,
startDate,
endDate,
projectId: project.id,
});
expect(byTime).toHaveLength(1);
expect(byTime[0].values.succeeded).toBe(workflowInsights.value);
});
test('should throw a forbidden error when the requested project is not accessible', async () => {
await linkUserToProject(member, project, 'project:viewer');
await expect(
insightsService.getInsightsByTime({
user: member,
startDate,
endDate,
projectId: otherProject.id,
}),
).rejects.toThrow(ForbiddenError);
});
test('should throw a forbidden error when the requested project does not exist', async () => {
await expect(
insightsService.getInsightsByTime({
user: member,
startDate,
endDate,
projectId: 'non-existing-project-id',
}),
).rejects.toThrow(ForbiddenError);
});
test('should aggregate all projects for users with the global workflow read scope', async () => {
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate,
});
expect(byTime).toHaveLength(1);
expect(byTime[0].values.succeeded).toBe(
workflowInsights.value + otherWorkflowInsights.value,
);
});
test('should retain deleted workflow history for users with the global workflow read scope', async () => {
await Container.get(WorkflowRepository).delete({ id: workflow3.id });
const byTime = await insightsService.getInsightsByTime({
user: globalWorkflowReadUser,
startDate,
endDate,
});
expect(byTime[0].values.succeeded).toBe(
workflowInsights.value + otherWorkflowInsights.value,
);
});
test('should exclude deleted workflow history when scoped to the requested project', async () => {
await linkUserToProject(member, otherProject, 'project:viewer');
await Container.get(WorkflowRepository).delete({ id: workflow3.id });
const byTime = await insightsService.getInsightsByTime({
user: member,
startDate,
endDate,
projectId: otherProject.id,
});
expect(byTime).toHaveLength(0);
});
});
});
describe('validateDateFiltersLicense', () => {

View File

@ -5,6 +5,7 @@ import type { InstanceSettings } from 'n8n-core';
import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { TypeToNumber } from '../database/entities/insights-shared';
@ -13,6 +14,12 @@ import type { InsightsCompactionService } from '../insights-compaction.service';
import type { InsightsPruningService } from '../insights-pruning.service';
import { InsightsService } from '../insights.service';
vi.mock('@/permissions.ee/check-access', () => ({
userHasScopes: vi.fn(),
}));
const user = mock<User>({ id: 'user-1' });
describe('InsightsService', () => {
let insightsService: InsightsService;
@ -32,6 +39,7 @@ describe('InsightsService', () => {
mockLicenseState = mock<LicenseState>();
mockInstanceSettings = mock<InstanceSettings>();
mockWorkflowSharingService = mock<WorkflowSharingService>();
vi.mocked(userHasScopes).mockResolvedValue(true);
insightsService = new InsightsService(
mockInsightsByPeriodRepository,
@ -61,6 +69,7 @@ describe('InsightsService', () => {
]);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -194,6 +203,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -216,6 +226,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -238,6 +249,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -259,6 +271,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -283,6 +296,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -303,6 +317,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -324,6 +339,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -348,6 +364,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -374,6 +391,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -396,6 +414,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -420,6 +439,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -442,6 +462,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -464,6 +485,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -483,6 +505,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -505,6 +528,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -527,6 +551,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -550,6 +575,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -572,6 +598,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -594,6 +621,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -616,6 +644,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -639,6 +668,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -661,6 +691,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -683,6 +714,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -705,6 +737,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -728,6 +761,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -750,6 +784,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -771,6 +806,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -793,6 +829,7 @@ describe('InsightsService', () => {
);
const result = await insightsService.getInsightsSummary({
user,
startDate,
endDate,
});
@ -802,6 +839,79 @@ describe('InsightsService', () => {
});
});
});
describe('project access', () => {
beforeEach(() => {
mockInsightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue(
[],
);
});
it('should not check project access when no project is requested', async () => {
await insightsService.getInsightsSummary({ user, startDate, endDate });
expect(userHasScopes).not.toHaveBeenCalled();
});
it('should throw a forbidden error when the requested project is not accessible', async () => {
vi.mocked(userHasScopes).mockResolvedValue(false);
await expect(
insightsService.getInsightsSummary({ user, startDate, endDate, projectId: 'project-1' }),
).rejects.toThrow('You do not have access to insights for this project.');
expect(
mockInsightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates,
).not.toHaveBeenCalled();
});
it('should query the requested project when it is accessible', async () => {
await insightsService.getInsightsSummary({
user,
startDate,
endDate,
projectId: 'project-1',
});
expect(userHasScopes).toHaveBeenCalledWith(user, ['workflow:read'], false, {
projectId: 'project-1',
});
expect(
mockInsightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates,
).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'project-1' }));
});
it('should query without an access filter for users with the global workflow read scope', async () => {
mockWorkflowSharingService.rolesGrantingScope.mockResolvedValue(undefined);
await insightsService.getInsightsSummary({ user, startDate, endDate });
expect(
mockInsightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates,
).toHaveBeenCalledWith(expect.objectContaining({ accessFilter: undefined }));
});
it('should query with an access filter for users without the global workflow read scope', async () => {
mockWorkflowSharingService.rolesGrantingScope.mockResolvedValue({
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
});
await insightsService.getInsightsSummary({ user, startDate, endDate });
expect(
mockInsightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates,
).toHaveBeenCalledWith(
expect.objectContaining({
accessFilter: {
user,
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
},
}),
);
});
});
});
describe('timeZone forwarding', () => {
@ -813,7 +923,12 @@ describe('InsightsService', () => {
[],
);
await insightsService.getInsightsSummary({ startDate, endDate, timeZone: 'Europe/Berlin' });
await insightsService.getInsightsSummary({
user,
startDate,
endDate,
timeZone: 'Europe/Berlin',
});
expect(
mockInsightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates,
@ -842,7 +957,12 @@ describe('InsightsService', () => {
it('forwards timeZone to getInsightsByTime', async () => {
mockInsightsByPeriodRepository.getInsightsByTime.mockResolvedValue([]);
await insightsService.getInsightsByTime({ startDate, endDate, timeZone: 'Europe/Berlin' });
await insightsService.getInsightsByTime({
user,
startDate,
endDate,
timeZone: 'Europe/Berlin',
});
expect(mockInsightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith(
expect.objectContaining({ timeZone: 'Europe/Berlin' }),
@ -871,55 +991,148 @@ describe('InsightsService', () => {
const makeUser = (scopes: string[]) =>
({ role: { scopes: scopes.map((slug) => ({ slug })) } }) as unknown as User;
it('sets hasReadAccess only for workflows in the shared set for non-admins', async () => {
beforeEach(() => {
mockInsightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({
count: 0,
rows: [],
});
});
it('sets hasReadAccess true for a row with a workflowId', async () => {
const user = makeUser([]);
mockInsightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({
count: 2,
rows: [makeRow('wf-accessible'), makeRow('wf-inaccessible')],
count: 1,
rows: [makeRow('wf-1')],
});
mockWorkflowSharingService.getSharedWorkflowIds.mockResolvedValue(['wf-accessible']);
const result = await insightsService.getInsightsByWorkflow({ user, startDate, endDate });
expect(mockWorkflowSharingService.getSharedWorkflowIds).toHaveBeenCalledWith(user, {
scopes: ['workflow:read'],
projectId: undefined,
});
expect(result.data.find((r) => r.workflowId === 'wf-accessible')?.hasReadAccess).toBe(true);
expect(result.data.find((r) => r.workflowId === 'wf-inaccessible')?.hasReadAccess).toBe(
false,
);
expect(result.data[0].hasReadAccess).toBe(true);
});
it('sets hasReadAccess true for all rows for admins', async () => {
it('sets hasReadAccess false for a row with a null workflowId (deleted workflow)', async () => {
const user = makeUser(['workflow:read']);
mockInsightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({
count: 2,
rows: [makeRow('wf-1'), makeRow('wf-2')],
});
// getSharedWorkflowIds returns all workflow IDs for users with the global scope
mockWorkflowSharingService.getSharedWorkflowIds.mockResolvedValue(['wf-1', 'wf-2']);
const result = await insightsService.getInsightsByWorkflow({ user, startDate, endDate });
expect(mockWorkflowSharingService.getSharedWorkflowIds).toHaveBeenCalledWith(user, {
scopes: ['workflow:read'],
projectId: undefined,
});
expect(result.data.every((r) => r.hasReadAccess)).toBe(true);
});
it('sets hasReadAccess false for rows with a null workflowId (deleted workflow)', async () => {
const user = makeUser([]);
mockInsightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({
count: 1,
rows: [makeRow(null)],
});
mockWorkflowSharingService.getSharedWorkflowIds.mockResolvedValue([]);
const result = await insightsService.getInsightsByWorkflow({ user, startDate, endDate });
expect(result.data[0].hasReadAccess).toBe(false);
});
describe('project access', () => {
it('should not check project access when no project is requested', async () => {
await insightsService.getInsightsByWorkflow({ user, startDate, endDate });
expect(userHasScopes).not.toHaveBeenCalled();
});
it('should throw a forbidden error when the requested project is not accessible', async () => {
vi.mocked(userHasScopes).mockResolvedValue(false);
await expect(
insightsService.getInsightsByWorkflow({
user,
startDate,
endDate,
projectId: 'project-1',
}),
).rejects.toThrow('You do not have access to insights for this project.');
expect(mockInsightsByPeriodRepository.getInsightsByWorkflow).not.toHaveBeenCalled();
});
it('should query without an access filter for users with the global workflow read scope', async () => {
mockWorkflowSharingService.rolesGrantingScope.mockResolvedValue(undefined);
await insightsService.getInsightsByWorkflow({ user, startDate, endDate });
expect(mockInsightsByPeriodRepository.getInsightsByWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ accessFilter: undefined }),
);
});
it('should query with an access filter for users without the global workflow read scope', async () => {
mockWorkflowSharingService.rolesGrantingScope.mockResolvedValue({
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
});
await insightsService.getInsightsByWorkflow({ user, startDate, endDate });
expect(mockInsightsByPeriodRepository.getInsightsByWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
accessFilter: {
user,
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
},
}),
);
});
});
});
describe('getInsightsByTime', () => {
const startDate = new Date('2024-01-01');
const endDate = new Date('2024-01-07');
beforeEach(() => {
mockInsightsByPeriodRepository.getInsightsByTime.mockResolvedValue([]);
});
describe('project access', () => {
it('should not check project access when no project is requested', async () => {
await insightsService.getInsightsByTime({ user, startDate, endDate });
expect(userHasScopes).not.toHaveBeenCalled();
});
it('should throw a forbidden error when the requested project is not accessible', async () => {
vi.mocked(userHasScopes).mockResolvedValue(false);
await expect(
insightsService.getInsightsByTime({
user,
startDate,
endDate,
projectId: 'project-1',
}),
).rejects.toThrow('You do not have access to insights for this project.');
expect(mockInsightsByPeriodRepository.getInsightsByTime).not.toHaveBeenCalled();
});
it('should query without an access filter for users with the global workflow read scope', async () => {
mockWorkflowSharingService.rolesGrantingScope.mockResolvedValue(undefined);
await insightsService.getInsightsByTime({ user, startDate, endDate });
expect(mockInsightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith(
expect.objectContaining({ accessFilter: undefined }),
);
});
it('should query with an access filter for users without the global workflow read scope', async () => {
mockWorkflowSharingService.rolesGrantingScope.mockResolvedValue({
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
});
await insightsService.getInsightsByTime({ user, startDate, endDate });
expect(mockInsightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith(
expect.objectContaining({
accessFilter: {
user,
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
},
}),
);
});
});
});
});

View File

@ -1,11 +1,22 @@
import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import {
createTeamProject,
createWorkflow,
linkUserToProject,
testDb,
testModules,
} from '@n8n/backend-test-utils';
import type { Project, User, WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { DateTime } from 'luxon';
import { InsightsConfig } from '@/modules/insights/insights.config';
import { createMember } from '@test-integration/db/users';
import { createCompactedInsightsEvent, createMetadata } from '../../entities/__tests__/db-utils';
import type { InsightsByPeriod } from '../../entities/insights-by-period';
import { TypeToNumber } from '../../entities/insights-shared';
import type { InsightsAccessFilter } from '../insights-by-period.repository';
import { InsightsByPeriodRepository } from '../insights-by-period.repository';
const isPostgres = Container.get(GlobalConfig).database.type === 'postgresdb';
@ -268,4 +279,152 @@ describe('InsightsByPeriodRepository', () => {
expect(transactionSpy).toHaveBeenCalledTimes(1);
});
});
describe('access filter', () => {
let member: User;
let accessibleProject: Project;
let accessibleWorkflow: WorkflowEntity;
let accessibleInsight: InsightsByPeriod;
let inaccessibleProject: Project;
let inaccessibleWorkflow: WorkflowEntity;
let inaccessibleInsight: InsightsByPeriod;
let accessFilter: InsightsAccessFilter;
let startDate: Date;
let endDate: Date;
beforeAll(async () => {
member = await createMember();
accessibleProject = await createTeamProject();
await linkUserToProject(member, accessibleProject, 'project:viewer');
accessibleWorkflow = await createWorkflow({}, accessibleProject);
inaccessibleProject = await createTeamProject();
inaccessibleWorkflow = await createWorkflow({}, inaccessibleProject);
const now = DateTime.utc();
startDate = now.minus({ days: 7 }).toJSDate();
endDate = now.toJSDate();
[accessibleInsight, inaccessibleInsight] = await Promise.all([
createCompactedInsightsEvent(accessibleWorkflow, {
type: 'success',
value: 4,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
}),
createCompactedInsightsEvent(inaccessibleWorkflow, {
type: 'success',
value: 10,
periodUnit: 'day',
periodStart: now.minus({ days: 1 }),
}),
]);
accessFilter = {
user: member,
projectRoles: ['project:viewer'],
workflowRoles: ['workflow:owner'],
};
});
describe('getPreviousAndCurrentPeriodTypeAggregates', () => {
test('should aggregate both workflows when no access filter is applied', async () => {
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
const rows = await insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates({
startDate,
endDate,
});
const currentSuccessTotal = rows.find(
(row) => row.period === 'current' && row.type === TypeToNumber.success,
)?.total_value;
expect(Number(currentSuccessTotal)).toBe(
accessibleInsight.value + inaccessibleInsight.value,
);
});
test('should exclude workflows outside the access filter', async () => {
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
const rows = await insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates({
startDate,
endDate,
accessFilter,
});
const currentSuccessTotal = rows.find(
(row) => row.period === 'current' && row.type === TypeToNumber.success,
)?.total_value;
expect(Number(currentSuccessTotal)).toBe(accessibleInsight.value);
});
});
describe('getInsightsByWorkflow', () => {
test('should return both workflows when no access filter is applied', async () => {
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
const { count, rows } = await insightsByPeriodRepository.getInsightsByWorkflow({
startDate,
endDate,
});
expect(count).toBe(2);
expect(rows.map((row) => row.workflowId).sort()).toEqual(
[accessibleWorkflow.id, inaccessibleWorkflow.id].sort(),
);
});
test('should exclude workflows outside the access filter', async () => {
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
const { count, rows } = await insightsByPeriodRepository.getInsightsByWorkflow({
startDate,
endDate,
accessFilter,
});
expect(count).toBe(1);
expect(rows).toHaveLength(1);
expect(rows[0].workflowId).toBe(accessibleWorkflow.id);
expect(rows[0].succeeded).toBe(accessibleInsight.value);
});
});
describe('getInsightsByTime', () => {
test('should aggregate both workflows when no access filter is applied', async () => {
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
const rows = await insightsByPeriodRepository.getInsightsByTime({
startDate,
endDate,
periodUnit: 'day',
insightTypes: ['success'],
});
const totalSucceeded = rows.reduce((sum, row) => sum + (row.succeeded ?? 0), 0);
expect(totalSucceeded).toBe(accessibleInsight.value + inaccessibleInsight.value);
});
test('should exclude workflows outside the access filter', async () => {
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
const rows = await insightsByPeriodRepository.getInsightsByTime({
startDate,
endDate,
periodUnit: 'day',
insightTypes: ['success'],
accessFilter,
});
const totalSucceeded = rows.reduce((sum, row) => sum + (row.succeeded ?? 0), 0);
expect(totalSucceeded).toBe(accessibleInsight.value);
});
});
});
});

View File

@ -1,10 +1,12 @@
import { isValidTimeZone } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
import { parseListQuerySortBy, sql } from '@n8n/db';
import type { User } from '@n8n/db';
import { parseListQuerySortBy, sql, SharedWorkflowRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, LessThanOrEqual, Repository } from '@n8n/typeorm';
import { DateTime } from 'luxon';
import { UnexpectedError } from 'n8n-workflow';
import { z } from 'zod';
import { getDateRangesCommonTableExpressionQuery } from './insights-by-period-query.helper';
@ -73,11 +75,24 @@ const aggregatedInsightsByTimeParser = z
})
.array();
/**
* Identifies a caller whose insights must be limited to the workflows they can
* read.
*/
export type InsightsAccessFilter = {
user: User;
projectRoles: string[];
workflowRoles: string[];
};
@Service()
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
private isRunningCompaction = false;
constructor(dataSource: DataSource) {
constructor(
dataSource: DataSource,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {
super(InsightsByPeriod, dataSource.manager);
}
@ -85,6 +100,30 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return this.manager.connection.driver.escape(fieldName);
}
/**
* Limits a query to insights for workflows the caller can read, by
* correlating against their workflow shares.
*/
private applyAccessFilter(
qb: SelectQueryBuilder<InsightsByPeriod>,
accessFilter: InsightsAccessFilter,
) {
// Ensure the metadata relation is joined, so we can correlate against workflowId
if (!qb.expressionMap.joinAttributes.some((join) => join.alias.name === 'metadata')) {
throw new UnexpectedError('The metadata relation must be joined before the access filter');
}
const subquery = this.sharedWorkflowRepository.buildSharedWorkflowIdsSubquery(
accessFilter.user,
{
projectRoles: accessFilter.projectRoles,
workflowRoles: accessFilter.workflowRoles,
},
);
subquery.andWhere('"sw"."workflowId" = metadata."workflowId"');
qb.andWhere(`EXISTS (${subquery.getQuery()})`).setParameters(subquery.getParameters());
}
private getPeriodFilterExpr(maxAgeInDays = 0) {
// Database-specific period start expression to filter out data to compact by days matching the periodUnit
let periodStartExpr = `date('now', '-${maxAgeInDays} days')`;
@ -305,7 +344,14 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
endDate,
projectId,
timeZone,
}: { projectId?: string; startDate: Date; endDate: Date; timeZone?: string }): Promise<
accessFilter,
}: {
projectId?: string;
startDate: Date;
endDate: Date;
accessFilter?: InsightsAccessFilter;
timeZone?: string;
}): Promise<
Array<{
period: 'previous' | 'current';
type: 0 | 1 | 2 | 3;
@ -336,10 +382,17 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
.groupBy('period')
.addGroupBy('insights.type');
// If we're filtering by projectId or accessFilter, we need a metadata join to access projectId and workflowId.
if (projectId || accessFilter) {
rawRowsQuery.innerJoin('insights.metadata', 'metadata');
}
if (projectId) {
rawRowsQuery
.innerJoin('insights.metadata', 'metadata')
.andWhere('metadata.projectId = :projectId', { projectId });
rawRowsQuery.andWhere('metadata.projectId = :projectId', { projectId });
}
if (accessFilter) {
this.applyAccessFilter(rawRowsQuery, accessFilter);
}
const rawRows = await rawRowsQuery.getRawMany();
@ -368,6 +421,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
sortBy = 'total:desc',
projectId,
timeZone,
accessFilter,
}: {
skip?: number;
take?: number;
@ -376,6 +430,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
startDate: Date;
endDate: Date;
timeZone?: string;
accessFilter?: InsightsAccessFilter;
}) {
const { column: sortField, direction: sortOrder } = parseListQuerySortBy(sortBy);
const sumOfExecutions = sql`SUM(CASE WHEN insights.type IN (${TypeToNumber.success.toString()}, ${TypeToNumber.failure.toString()}) THEN value ELSE 0 END)`;
@ -417,6 +472,10 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
rawRowsQuery.andWhere('metadata.projectId = :projectId', { projectId });
}
if (accessFilter) {
this.applyAccessFilter(rawRowsQuery, accessFilter);
}
const paginatedQuery = rawRowsQuery
.clone()
.orderBy(this.escapeField(sortField), sortOrder)
@ -438,6 +497,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
startDate,
endDate,
timeZone,
accessFilter,
}: {
periodUnit: PeriodUnit;
insightTypes: TypeUnit[];
@ -445,6 +505,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
startDate: Date;
endDate: Date;
timeZone?: string;
accessFilter?: InsightsAccessFilter;
}) {
const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate, timeZone });
@ -469,10 +530,17 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
.groupBy(periodStartExpr)
.orderBy(periodStartExpr, 'ASC');
// If we're filtering by projectId or accessFilter, we need a metadata join to access projectId and workflowId.
if (projectId || accessFilter) {
rawRowsQuery.innerJoin('insights.metadata', 'metadata');
}
if (projectId) {
rawRowsQuery
.innerJoin('insights.metadata', 'metadata')
.andWhere('metadata.projectId = :projectId', { projectId });
rawRowsQuery.andWhere('metadata.projectId = :projectId', { projectId });
}
if (accessFilter) {
this.applyAccessFilter(rawRowsQuery, accessFilter);
}
const rawRows = await rawRowsQuery.getRawMany();

View File

@ -24,13 +24,14 @@ export class InsightsController {
@Get('/summary')
@GlobalScope('insights:list')
async getInsightsSummary(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
_res: Response,
@Query query: InsightsDateFilterDto = {},
): Promise<InsightsSummary> {
const { startDate, endDate, timeZone } = this.prepareDateFilters(query);
return await this.insightsService.getInsightsSummary({
user: req.user,
startDate,
endDate,
timeZone,
@ -64,7 +65,7 @@ export class InsightsController {
@GlobalScope('insights:list')
@Licensed('feat:insights:viewDashboard')
async getInsightsByTime(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
_res: Response,
@Query query: InsightsDateFilterDto,
): Promise<InsightsByTime[]> {
@ -73,6 +74,7 @@ export class InsightsController {
// Cast to full insights by time type
// as the service returns all types by default
return (await this.insightsService.getInsightsByTime({
user: req.user,
projectId: query.projectId,
startDate,
endDate,
@ -87,7 +89,7 @@ export class InsightsController {
@Get('/by-time/time-saved')
@GlobalScope('insights:list')
async getTimeSavedInsightsByTime(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
_res: Response,
@Query query: InsightsDateFilterDto,
): Promise<RestrictedInsightsByTime[]> {
@ -96,6 +98,7 @@ export class InsightsController {
// Cast to restricted insights by time type
// as the service returns only time saved data
return (await this.insightsService.getInsightsByTime({
user: req.user,
insightTypes: ['time_saved_min'],
projectId: query.projectId,
startDate,

View File

@ -7,10 +7,13 @@ import { DateTime } from 'luxon';
import { InstanceSettings } from 'n8n-core';
import { UserError } from 'n8n-workflow';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { userHasScopes } from '@/permissions.ee/check-access';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import type { PeriodUnit, TypeUnit } from './database/entities/insights-shared';
import { NumberToType, TypeToNumber } from './database/entities/insights-shared';
import type { InsightsAccessFilter } from './database/repositories/insights-by-period.repository';
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
import { InsightsCompactionService } from './insights-compaction.service';
import { InsightsPruningService } from './insights-pruning.service';
@ -70,22 +73,56 @@ export class InsightsService {
await this.stopCompactionAndPruningTimers();
}
/**
* Resolves what insights the caller may read. A requested project must be
* readable by them. When no specific project is requested, results are limited to the
* workflows they can read.
*
* Returns the filter to apply, or `undefined` when the caller's global role
* already grants access to every workflow.
*/
private async resolveAccessFilter(
user: User,
projectId?: string,
): Promise<InsightsAccessFilter | undefined> {
if (projectId) {
const userHasRequiredProjectScopes = await userHasScopes(user, ['workflow:read'], false, {
projectId,
});
if (!userHasRequiredProjectScopes) {
throw new ForbiddenError('You do not have access to insights for this project.');
}
}
const workflowReadRoles = await this.workflowSharingService.rolesGrantingScope(
user,
'workflow:read',
);
return workflowReadRoles && { user, ...workflowReadRoles };
}
async getInsightsSummary({
user,
startDate,
endDate,
projectId,
timeZone,
}: {
user: User;
projectId?: string;
startDate: Date;
endDate: Date;
timeZone?: string;
}): Promise<InsightsSummary> {
const accessFilter = await this.resolveAccessFilter(user, projectId);
const rows = await this.insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates({
startDate,
endDate,
projectId,
timeZone,
accessFilter,
});
// Initialize data structures for both periods
@ -186,6 +223,8 @@ export class InsightsService {
endDate: Date;
timeZone?: string;
}) {
const accessFilter = await this.resolveAccessFilter(user, projectId);
const { count, rows } = await this.insightsByPeriodRepository.getInsightsByWorkflow({
startDate,
endDate,
@ -194,18 +233,13 @@ export class InsightsService {
sortBy,
projectId,
timeZone,
accessFilter,
});
const accessibleWorkflowIds = new Set(
await this.workflowSharingService.getSharedWorkflowIds(user, {
scopes: ['workflow:read'],
projectId,
}),
);
// A non-null means the caller can read it; null means the workflow has since been deleted.
const data = rows.map((row) => ({
...row,
hasReadAccess: row.workflowId !== null && accessibleWorkflowIds.has(row.workflowId),
hasReadAccess: row.workflowId !== null,
}));
return {
@ -215,6 +249,7 @@ export class InsightsService {
}
async getInsightsByTime({
user,
// Default to all insight types
insightTypes = Object.keys(TypeToNumber) as TypeUnit[],
projectId,
@ -222,12 +257,14 @@ export class InsightsService {
endDate,
timeZone,
}: {
user: User;
insightTypes?: TypeUnit[];
projectId?: string;
startDate: Date;
endDate: Date;
timeZone?: string;
}) {
const accessFilter = await this.resolveAccessFilter(user, projectId);
const periodUnit = this.getDateFiltersGranularity({ startDate, endDate });
const rows = await this.insightsByPeriodRepository.getInsightsByTime({
periodUnit,
@ -236,6 +273,7 @@ export class InsightsService {
startDate,
endDate,
timeZone,
accessFilter,
});
return rows.map((r) => {

View File

@ -194,6 +194,19 @@ describe('buildDiscoverResponse', () => {
expect(createEndpoint?.requestSchema).toHaveProperty('type');
});
it('should include requestSchema for a decorator route when includeSchemas is true', async () => {
const result = await buildDiscoverResponse(['role:manage'] as ApiKeyScope[], {
includeSchemas: true,
});
const createEndpoint = result.resources.role?.endpoints.find(
(e) => e.operationId === 'createRole',
);
expect(createEndpoint).toBeDefined();
expect(createEndpoint?.requestSchema).toBeDefined();
expect(createEndpoint?.requestSchema).toHaveProperty('properties');
});
it('should not include requestSchema on GET endpoints even with includeSchemas', async () => {
const result = await buildDiscoverResponse(['tag:list'] as ApiKeyScope[], {
includeSchemas: true,

View File

@ -12,6 +12,7 @@ import {
scopesInRequirement,
toOpenApiPathTemplate,
} from '../../../public-api-route-resolver';
import { buildRequestBodyJsonSchema } from '../../openapi-gen/decorator-routes';
import { extractScopeFromEovHandlerChain } from '../../shared/public-api-scope-lookup';
import '../../controllers';
@ -151,6 +152,7 @@ function buildDecoratorEndpoints(): EndpointInfo[] {
operationId: route.handlerName,
tag: route.tags?.[0] ?? 'Other',
scope: route.apiKeyScope ?? null,
requestSchema: buildRequestBodyJsonSchema(route),
}));
}

View File

@ -67,6 +67,7 @@ const insightsHandlers: InsightsHandlers = {
}
const summary = await Container.get(InsightsService).getInsightsSummary({
user: req.user,
startDate,
endDate,
projectId: query.data.projectId,

View File

@ -0,0 +1,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'yaml';
import { resolvePublicApiRoutes } from '@/public-api/public-api-route-resolver';
import { buildRequestBodyJsonSchema, getDecoratorGeneratedOperations } from '../decorator-routes';
/**
* The build writes a request body schema into the spec. `/discover` builds the same schema at
* runtime, through `buildRequestBodyJsonSchema`. Nothing else checks that the two agree.
*/
const V1_DIR = path.resolve(__dirname, '../..');
// The build writes one spec file per route and names it after the handler method, so the handler
// name is what links a route to its file.
const SPEC_FILE_BY_HANDLER = new Map(
getDecoratorGeneratedOperations().map((operation) => [
operation.config.operationId,
operation.outputPath,
]),
);
function schemaInSpecFile(handlerName: string): unknown {
const specFile = SPEC_FILE_BY_HANDLER.get(handlerName);
if (!specFile) throw new Error(`The build generated no spec file for ${handlerName}`);
const spec = parse(fs.readFileSync(path.join(V1_DIR, specFile), 'utf8')) as {
requestBody?: { content?: Record<string, { schema?: unknown }> };
};
return spec.requestBody?.content?.['application/json']?.schema;
}
describe('buildRequestBodyJsonSchema', () => {
const routesWithBody = resolvePublicApiRoutes().filter((route) => route.requestBodyDto);
it.each(routesWithBody)('$handlerName matches its committed spec file', (route) => {
expect(buildRequestBodyJsonSchema(route)).toEqual(schemaInSpecFile(route.handlerName));
});
});

View File

@ -5,8 +5,10 @@ import './zod-extend';
// resolvePublicApiRoutes()
import '../controllers';
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
import type { ResponseDtoClass } from '@n8n/decorators';
import { isRecord } from '@n8n/utils/is-record';
import { UnexpectedError } from 'n8n-workflow';
import { z } from 'zod';
@ -17,6 +19,8 @@ import {
toOpenApiPathTemplate,
} from '@/public-api/public-api-route-resolver';
const REQUEST_BODY_COMPONENT = 'RequestBody';
// Query fields backed by shared hand-written parameter files instead of being generated
const SHARED_PAGINATION_PARAMS: Record<string, { $ref: string }> = {
limit: { $ref: '../../../../shared/spec/parameters/limit.yml' },
@ -137,6 +141,25 @@ function buildRequestBody(
};
}
/**
* Converts a route's `@Body` DTO into JSON Schema, using the same conversion the build runs when
* it writes the OpenAPI files. zod-to-openapi can only convert a schema that a registry holds, so
* this registers one under a throwaway name and then reads the result back out.
*/
export function buildRequestBodyJsonSchema(
route: ResolvedPublicApiRoute,
): Record<string, unknown> | undefined {
if (!route.requestBodyDto) return undefined;
const registry = new OpenAPIRegistry();
registry.register(REQUEST_BODY_COMPONENT, route.requestBodyDto.schema);
const { components } = new OpenApiGeneratorV3(registry.definitions).generateComponents();
const schema = components?.schemas?.[REQUEST_BODY_COMPONENT];
return isRecord(schema) ? schema : undefined;
}
/**
* Response set is derived from what `PublicApiControllerRegistry` actually does at runtime, not
* invented: the success status is the one `@ApiResponse` declares (and the same one the registry

View File

@ -2,7 +2,8 @@ import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import { mockInstance } from '@n8n/backend-test-utils';
import { SharedWorkflowRepository } from '@n8n/db';
import { CredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
import type { CredentialsEntity } from '@n8n/db';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { RoutingNode } from 'n8n-core';
@ -10,6 +11,7 @@ import {
type ILoadOptionsFunctions,
type INodeParameters,
type INodeType,
type IExecutionContext,
type IWorkflowExecuteAdditionalData,
type ResourceMapperFields,
Expression,
@ -39,12 +41,14 @@ describe('DynamicNodeParametersService', () => {
const workflowLoaderService = mockInstance(WorkflowLoaderService);
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
const credentialsFinderService = mockInstance(CredentialsFinderService);
const credentialsRepository = mockInstance(CredentialsRepository);
const service = new DynamicNodeParametersService(
logger,
nodeTypes,
workflowLoaderService,
sharedWorkflowRepository,
credentialsFinderService,
credentialsRepository,
);
beforeEach(() => {
@ -430,6 +434,55 @@ describe('DynamicNodeParametersService', () => {
});
});
describe('declarative loadOptions routing', () => {
it("should hand the entry point's execution context to the routing node", async () => {
// `ExecuteContext` reads the execution context off `runExecutionData`, not off
// `additionalData`, so end-user credentials on declarative nodes only resolve if
// the design-time context is threaded through there.
const runNode = vi.fn().mockResolvedValue([[{ json: { name: 'opt', value: 'v' } }]]);
(RoutingNode as unknown as Mock).mockImplementation(function () {
return { runNode };
});
vi.spyOn(Expression.prototype, 'acquireIsolate').mockResolvedValue(true);
vi.spyOn(Expression.prototype, 'releaseIsolate').mockResolvedValue(undefined);
nodeTypes.getByNameAndVersion.mockReturnValue({
description: {
name: 'TestNode',
displayName: 'TestNode',
group: [],
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [],
requestDefaults: { baseURL: 'https://api.example.com' },
},
} as unknown as INodeType);
const executionContext: IExecutionContext = {
version: 1,
establishedAt: 1,
source: 'internal',
credentials: 'sealed-credential-context',
};
const additionalData = mock<IWorkflowExecuteAdditionalData>();
additionalData.executionContext = executionContext;
await service.getOptionsViaLoadOptions(
{ routing: { request: { url: '/v1/models' } } },
additionalData,
{ name: 'TestNode', version: 1 },
{} as INodeParameters,
);
const [executeFunctions] = (RoutingNode as unknown as Mock).mock.calls[0] as [
ILoadOptionsFunctions,
];
expect(executeFunctions.getExecutionContext()).toBe(executionContext);
});
});
describe('getMethod', () => {
it('should throw BadRequestError when the requested method does not exist', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue({
@ -512,6 +565,8 @@ describe('DynamicNodeParametersService', () => {
beforeEach(() => {
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
sharedWorkflowRepository.getWorkflowOwningProject.mockResolvedValue(undefined);
// No end-user credentials unless a test says otherwise.
credentialsRepository.getManyByIds.mockResolvedValue([]);
});
afterEach(() => {
@ -557,6 +612,59 @@ describe('DynamicNodeParametersService', () => {
).rejects.toThrow(ForbiddenError);
});
it('should not ask for connect scope when no credential is end-user', async () => {
credentialsFinderService.findCredentialIdsWithScopeForUser.mockResolvedValue(
new Set(['cred-1']),
);
credentialsRepository.getManyByIds.mockResolvedValue([
mock<CredentialsEntity>({ id: 'cred-1', isResolvable: false }),
]);
await service.refineResourceIds(user, {
credentials: { openAi: { id: 'cred-1', name: 'My OpenAI' } },
});
expect(credentialsFinderService.findCredentialIdsWithScopeForUser).toHaveBeenCalledTimes(1);
});
it('should allow an end-user credential the user may connect', async () => {
// Loading the list resolves the credential against this user's own connection.
credentialsFinderService.findCredentialIdsWithScopeForUser.mockResolvedValue(
new Set(['cred-1']),
);
credentialsRepository.getManyByIds.mockResolvedValue([
mock<CredentialsEntity>({ id: 'cred-1', isResolvable: true }),
]);
await expect(
service.refineResourceIds(user, {
credentials: { openAi: { id: 'cred-1', name: 'My OpenAI' } },
}),
).resolves.not.toThrow();
expect(credentialsFinderService.findCredentialIdsWithScopeForUser).toHaveBeenLastCalledWith(
['cred-1'],
user,
['credential:connect'],
);
});
it('should throw for an end-user credential the user may read but not connect', async () => {
// A project viewer holds `credential:read` without `credential:connect`.
credentialsFinderService.findCredentialIdsWithScopeForUser
.mockResolvedValueOnce(new Set(['cred-1']))
.mockResolvedValueOnce(new Set());
credentialsRepository.getManyByIds.mockResolvedValue([
mock<CredentialsEntity>({ id: 'cred-1', isResolvable: true }),
]);
await expect(
service.refineResourceIds(user, {
credentials: { openAi: { id: 'cred-1', name: 'Shared end-user credential' } },
}),
).rejects.toThrow(ForbiddenError);
});
it('should throw when any credential in a multi-credential request is inaccessible', async () => {
credentialsFinderService.findCredentialIdsWithScopeForUser.mockResolvedValue(
new Set(['cred-1']),

View File

@ -1,5 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { SharedWorkflowRepository, User } from '@n8n/db';
import { CredentialsRepository, SharedWorkflowRepository, User } from '@n8n/db';
import { Service } from '@n8n/di';
import { LoadOptionsContext, RoutingNode, LocalLoadOptionsContext, ExecuteContext } from 'n8n-core';
import type {
@ -25,7 +25,7 @@ import type {
import {
Workflow,
UnexpectedError,
createEmptyRunExecutionData,
createRunExecutionData,
findDisplayedProperty,
} from 'n8n-workflow';
@ -68,6 +68,7 @@ export class DynamicNodeParametersService {
private workflowLoaderService: WorkflowLoaderService,
private sharedWorkflowRepository: SharedWorkflowRepository,
private credentialsFinderService: CredentialsFinderService,
private credentialsRepository: CredentialsRepository,
) {}
async refineResourceIds(
@ -124,10 +125,39 @@ export class DynamicNodeParametersService {
if (forbiddenId !== undefined) {
throw new ForbiddenError();
}
await this.assertMayUseEndUserCredentials(user, credentialIds);
}
}
}
/**
* Loading a list with an end-user credential resolves it against the requesting
* user's own connection, so it requires `credential:connect` — the same scope the
* connect flow itself demands — on top of the `credential:read` checked above.
*
* Read access alone is not enough: a project viewer holds `credential:read` but may
* not connect an account, and should be told so rather than shown a list built from
* a connection they are not allowed to hold.
*/
private async assertMayUseEndUserCredentials(user: User, credentialIds: string[]) {
const credentials = await this.credentialsRepository.getManyByIds(credentialIds);
const endUserIds = credentials.filter((c) => c.isResolvable).map((c) => c.id);
if (endUserIds.length === 0) return;
const connectableIds = await this.credentialsFinderService.findCredentialIdsWithScopeForUser(
endUserIds,
user,
['credential:connect'],
);
if (endUserIds.some((id) => !connectableIds.has(id))) {
throw new ForbiddenError(
'You need permission to connect your own account to this end-user credential before you can load this list',
);
}
}
/** Returns the available options via a predefined method */
async getOptionsViaMethodName(
methodName: string,
@ -203,7 +233,12 @@ export class DynamicNodeParametersService {
const mode = 'internal';
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
const runExecutionData = createEmptyRunExecutionData();
// `runtimeData` is where `ExecuteContext` looks for the execution context, so the
// design-time context has to be threaded through it for declarative nodes to resolve
// end-user credentials. `LoadOptionsContext` reads `additionalData` directly instead.
const runExecutionData = createRunExecutionData({
executionData: { runtimeData: additionalData.executionContext },
});
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const node = workflow.nodes['Temp-Node'];

View File

@ -7,7 +7,7 @@ import type {
WorkflowRepository,
} from '@n8n/db';
import type { EntityManager, UpdateResult } from '@n8n/typeorm';
import type { IWorkflowBase } from 'n8n-workflow';
import type { INode, IWorkflowBase } from 'n8n-workflow';
import { WorkflowActivationError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
@ -115,6 +115,189 @@ describe('EnterpriseWorkflowService', () => {
expect(() => service.validateCredentialPermissionsToUser(workflow, [])).not.toThrow();
});
it('should inspect credentials referenced inside an inline sub-workflow', () => {
const workflow = mock<IWorkflowBase>({
nodes: [
{
type: 'n8n-nodes-base.executeWorkflow',
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [{ credentials: { spotifyApi: { id: 'cred-unknown', name: 'x' } } }],
connections: {},
}),
},
},
],
});
expect(() =>
service.validateCredentialPermissionsToUser(workflow, [
mock<CredentialsEntity>({ id: 'cred-1' }),
]),
).toThrow();
});
it('should pass when an inline sub-workflow credential is in the allowed list', () => {
const workflow = mock<IWorkflowBase>({
nodes: [
{
type: 'n8n-nodes-base.executeWorkflow',
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [{ credentials: { spotifyApi: { id: 'cred-1', name: 'x' } } }],
connections: {},
}),
},
},
],
});
expect(() =>
service.validateCredentialPermissionsToUser(workflow, [
mock<CredentialsEntity>({ id: 'cred-1' }),
]),
).not.toThrow();
});
it('should reject a non-managed credential with a null id (name-only reference)', () => {
// Built as a real object: mock<IWorkflowBase> strips an explicit null id.
const workflow = {
nodes: [{ credentials: { spotifyApi: { id: null, name: 'Someone else prod' } } }],
} as unknown as IWorkflowBase;
expect(() =>
service.validateCredentialPermissionsToUser(workflow, [
mock<CredentialsEntity>({ id: 'cred-1' }),
]),
).toThrow();
});
it('should reject a non-managed credential with an empty-string id', () => {
const workflow = mock<IWorkflowBase>({
nodes: [{ credentials: { spotifyApi: { id: '', name: 'Someone else prod' } } }],
});
expect(() =>
service.validateCredentialPermissionsToUser(workflow, [
mock<CredentialsEntity>({ id: 'cred-1' }),
]),
).toThrow();
});
it('should reject a null-id credential hidden inside an inline sub-workflow', () => {
const workflow = mock<IWorkflowBase>({
nodes: [
{
type: 'n8n-nodes-base.executeWorkflow',
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [{ credentials: { spotifyApi: { id: null, name: 'Someone else prod' } } }],
connections: {},
}),
},
},
],
});
expect(() =>
service.validateCredentialPermissionsToUser(workflow, [
mock<CredentialsEntity>({ id: 'cred-1' }),
]),
).toThrow();
});
it('should inspect credentials nested in a deeper inline sub-workflow', () => {
const inner = JSON.stringify({
nodes: [{ credentials: { spotifyApi: { id: 'cred-unknown', name: 'x' } } }],
connections: {},
});
const workflow = mock<IWorkflowBase>({
nodes: [
{
type: 'n8n-nodes-base.executeWorkflow',
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [
{ type: 'n8n-nodes-base.executeWorkflow', parameters: { workflowJson: inner } },
],
connections: {},
}),
},
},
],
});
expect(() =>
service.validateCredentialPermissionsToUser(workflow, [
mock<CredentialsEntity>({ id: 'cred-1' }),
]),
).toThrow();
});
});
describe('validateWorkflowCredentialUsage() - unresolved credentials', () => {
// Real objects, not mock<IWorkflowBase>, so the explicit null id survives.
const nodeWithNullCred = (id: string, name: string) =>
({
id,
name,
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0],
parameters: {},
credentials: { httpHeaderAuth: { id: null, name: 'some name' } },
}) as unknown as INode;
it('rejects a new node carrying an unresolved (name-only) credential as tampering', () => {
const newVersion = {
nodes: [nodeWithNullCred('new-1', 'Steal')],
} as unknown as IWorkflowBase;
const previousVersion = { nodes: [] } as unknown as IWorkflowBase;
expect(() =>
service.validateWorkflowCredentialUsage(newVersion, previousVersion, []),
).toThrow();
});
it('rejects an unresolved credential smuggled into a new inline sub-workflow node', () => {
const inlineNode = {
id: 'new-inline',
name: 'Sub',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.2,
position: [0, 0],
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [{ credentials: { httpHeaderAuth: { id: null, name: 'y' } } }],
connections: {},
}),
},
} as unknown as INode;
const newVersion = { nodes: [inlineNode] } as unknown as IWorkflowBase;
const previousVersion = { nodes: [] } as unknown as IWorkflowBase;
expect(() =>
service.validateWorkflowCredentialUsage(newVersion, previousVersion, []),
).toThrow();
});
it('keeps an unresolved credential on a pre-existing (read-only) node without throwing', () => {
const existing = nodeWithNullCred('existing-1', 'Call');
const newVersion = {
nodes: [{ ...existing, name: 'Renamed' }],
} as unknown as IWorkflowBase;
const previousVersion = { nodes: [existing] } as unknown as IWorkflowBase;
expect(() =>
service.validateWorkflowCredentialUsage(newVersion, previousVersion, []),
).not.toThrow();
});
});
describe('attemptWorkflowReactivation', () => {

View File

@ -1702,6 +1702,7 @@ describe('WorkflowService', () => {
let externalHooksMock: MockProxy<ExternalHooks>;
let workflowPublishedVersionRepositoryMock: MockProxy<WorkflowPublishedVersionRepository>;
let workflowMutationHooksMock: MockProxy<WorkflowMutationHooksProxy>;
let ownershipServiceMock: MockProxy<OwnershipService>;
const WORKFLOW_ID = 'workflow-1';
@ -1718,6 +1719,7 @@ describe('WorkflowService', () => {
beforeEach(() => {
workflowFinderServiceMock = mock<WorkflowFinderService>();
ownershipServiceMock = mock<OwnershipService>();
workflowRepositoryMock = mock();
executionPersistenceMock = mock();
activeWorkflowManagerMock = mock();
@ -1734,7 +1736,7 @@ describe('WorkflowService', () => {
mock(), // sharedWorkflowRepository
workflowRepositoryMock, // workflowRepository
mock(), // workflowTagMappingRepository
mock(), // ownershipService
ownershipServiceMock, // ownershipService
mock(), // tagService
mock(), // workflowHistoryService
externalHooksMock, // externalHooks
@ -1902,6 +1904,25 @@ describe('WorkflowService', () => {
).toBeLessThan(workflowRepositoryMock.delete.mock.invocationCallOrder[0]);
});
test('invalidates the cached project for the deleted workflow', async () => {
const workflow = makeWorkflowEntity({ isArchived: true, activeVersionId: null });
workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow);
await workflowService.delete(mock<User>(), WORKFLOW_ID, true);
expect(ownershipServiceMock.invalidateWorkflowProjectCacheByIds).toHaveBeenCalledWith([
WORKFLOW_ID,
]);
});
test('does not invalidate the cached project when the workflow is not found', async () => {
workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(null);
await workflowService.delete(mock<User>(), WORKFLOW_ID, true);
expect(ownershipServiceMock.invalidateWorkflowProjectCacheByIds).not.toHaveBeenCalled();
});
test('forwards the acting user to the delete and afterDelete hooks', async () => {
const workflow = makeWorkflowEntity({ isArchived: true, activeVersionId: null });
workflowFinderServiceMock.findWorkflowForUser.mockResolvedValue(workflow);

View File

@ -146,4 +146,27 @@ export class WorkflowSharingService {
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
}
/**
* Resolve the roles granting `scope`. Returns `undefined` when the user's
* global role already grants the scope, meaning no filtering is needed.
*/
async rolesGrantingScope(
user: User,
scope: Scope,
): Promise<{ projectRoles: string[]; workflowRoles: string[] } | undefined> {
if (hasGlobalScope(user, scope)) {
return undefined;
}
const [projectRoles, workflowRoles] = await Promise.all([
this.roleService.rolesWithScope('project', [scope]),
this.roleService.rolesWithScope('workflow', [scope]),
]);
return {
projectRoles,
workflowRoles,
};
}
}

View File

@ -154,21 +154,14 @@ export class EnterpriseWorkflowService {
workflow: IWorkflowBase,
allowedCredentials: CredentialsEntity[],
) {
workflow.nodes.forEach((node) => {
if (!node.credentials) {
return;
}
Object.keys(node.credentials).forEach((credentialType) => {
const credential = node.credentials?.[credentialType];
if (credential?.__aiGatewayManaged && credential?.id === null) return;
const credentialId = credential?.id;
if (credentialId === undefined) return;
const matchedCredential = allowedCredentials.find(({ id }) => id === credentialId);
if (!matchedCredential) {
throw new UserError('The workflow contains credentials that you do not have access to');
}
});
});
// Reuse the shared collector so inline sub-workflow credentials (Execute
// Sub-workflow, Workflow Tool, Workflow Retriever) and unresolved name-only
// references are inspected too, keeping this check aligned with the update path.
const allowedCredentialIds = allowedCredentials.map(({ id }) => id);
const inaccessibleNodes = this.getNodesWithInaccessibleCreds(workflow, allowedCredentialIds);
if (inaccessibleNodes.length > 0) {
throw new UserError('The workflow contains credentials that you do not have access to');
}
}
async preventTampering<T extends IWorkflowBase>(workflow: T, workflowId: string, user: User) {
@ -264,23 +257,35 @@ export class EnterpriseWorkflowService {
return newWorkflowVersion;
}
/** Get all nodes in a workflow where the node credential is not accessible to the user. */
/**
* Get all nodes in a workflow whose credentials the user cannot use: either a
* referenced credential id is not accessible, or the node carries an unresolved
* non-managed reference (id null/empty) that could resolve by name to a
* credential the user does not own. Inline sub-workflow credentials are included.
*/
getNodesWithInaccessibleCreds(workflow: IWorkflowBase, userCredIds: string[]) {
if (!workflow.nodes) {
return [];
}
return workflow.nodes.filter((node) => {
const usedCredentialIds = this.getCredentialIdsUsedByNode(node);
return usedCredentialIds.some((credId) => !userCredIds.includes(credId));
const { ids, hasUnresolved } = this.getNodeCredentialRefs(node);
return hasUnresolved || ids.some((credId) => !userCredIds.includes(credId));
});
}
/**
* Collect every credential id a node references. Besides the node's own
* Collect the credential references a node uses. Besides the node's own
* `credentials`, a node with an inline workflow selector (Execute
* Sub-workflow, Workflow Tool, Workflow Retriever) embeds a whole workflow —
* including its nodes' credentials — inside its `workflowJson` string
* parameter, so those references are parsed out and returned as well.
* parameter, so those references are walked as well.
*
* Returns `ids` (resolvable credential ids) and `hasUnresolved` (a non-managed
* credential carrying no id, i.e. only a name). A name-only reference can be
* resolved by name to a credential the user cannot access, so callers gating a
* save must reject it rather than treat the node as credential-free.
* `__aiGatewayManaged` credentials with a null id are resolved at execution and
* are exempt.
*
* An inline sub-workflow may itself contain inline sub-workflows, so the walk
* is iterative over an explicit stack: every referenced credential is
@ -289,8 +294,9 @@ export class EnterpriseWorkflowService {
* the request size (each level embeds its child as literal escaped JSON) and
* cannot cycle, so the stack cannot grow unbounded.
*/
private getCredentialIdsUsedByNode(node: INode): string[] {
const credentialIds: string[] = [];
private getNodeCredentialRefs(node: INode): { ids: string[]; hasUnresolved: boolean } {
const ids: string[] = [];
let hasUnresolved = false;
const stack: INode[] = [node];
while (stack.length > 0) {
@ -299,7 +305,13 @@ export class EnterpriseWorkflowService {
if (current.credentials) {
for (const nodeCred of Object.values(current.credentials)) {
const id = nodeCred.id?.toString();
if (id) credentialIds.push(id);
if (id) {
ids.push(id);
} else if (nodeCred.__aiGatewayManaged && nodeCred.id === null) {
// Managed credential, resolved at execution — exempt.
} else if (nodeCred.id === null || nodeCred.id === '') {
hasUnresolved = true;
}
}
}
@ -308,7 +320,7 @@ export class EnterpriseWorkflowService {
}
}
return credentialIds;
return { ids, hasUnresolved };
}
/**

View File

@ -1202,6 +1202,8 @@ export class WorkflowService {
await this.workflowRepository.delete(workflowId);
await this.ownershipService.invalidateWorkflowProjectCacheByIds([workflowId]);
// After the cascade, so it can see the rows the delete orphaned. Observes a
// committed delete, so it must not throw — the module swallows its own errors.
await this.workflowMutationHooks.afterWorkflowsDeleted([workflowId]);

View File

@ -0,0 +1,362 @@
/**
* Design-time parameter loading against end-user credentials.
*
* The subject is a real resource locator: the Google Sheets node's `documentId`
* parameter, whose "From list" mode calls the `spreadSheetsSearch` listSearch method.
*
* These paths run through `LoadOptionsContext` in mode `internal`, which
* `CredentialsHelper.getDecrypted` skips dynamic resolution for unless the execution
* context carries a credential context. The controller seals the requesting user's own
* identity into one, so the list loads against the connection they already made instead
* of against static data that holds no per-user token.
*
* Two hazards are covered alongside the happy path: an unconnected user must get an
* error instead of a token-shaped failure, and a credential whose resolver keys on an
* external subject must never be handed the n8n session token.
*/
import {
createTeamProject,
linkUserToProject,
mockInstance,
randomCredentialPayload,
testDb,
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { Cipher } from 'n8n-core';
import type { NodeLoadingDetails } from 'n8n-workflow';
import nock from 'nock';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import {
SYSTEM_RESOLVER_ID,
SYSTEM_RESOLVER_NAME,
SYSTEM_RESOLVER_TYPE,
} from '@/modules/dynamic-credentials.ee/constants';
import { DynamicCredentialUserEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-user-entry-storage';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
import { DynamicCredentialsConfig } from '@/modules/dynamic-credentials.ee/dynamic-credentials.config';
import { Telemetry } from '@/telemetry';
import { decryptCredentialData, saveCredential } from '../shared/db/credentials';
import { createMember } from '../shared/db/users';
import { initNodeTypes, setupTestServer } from '../shared/utils';
import { loadNodesFromDist } from '../shared/utils/node-types-data';
mockInstance(Telemetry);
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
mockInstance(DynamicCredentialsConfig, {
endpointAuthToken: 'static-test-token',
corsOrigin: 'https://app.example.com',
corsAllowCredentials: false,
});
const testServer = setupTestServer({
endpointGroups: ['dynamic-node-parameters', 'credentials'],
enabledFeatures: ['feat:sharing', 'feat:dynamicCredentials'],
modules: ['dynamic-credentials'],
});
/** Resolves the requested credential types (and everything they extend) out of `nodes-base/dist`. */
function registerCredentialTypesFromDist(credentialTypeNames: string[]) {
const baseDir = path.resolve(__dirname, '../../../../nodes-base');
const known = JSON.parse(
readFileSync(path.join(baseDir, 'dist/known/credentials.json'), 'utf-8'),
) as Record<string, NodeLoadingDetails & { extends?: string[]; supportedNodes?: string[] }>;
const loadNodesAndCredentials = Container.get(LoadNodesAndCredentials);
const pending = [...credentialTypeNames];
while (pending.length > 0) {
const name = pending.shift()!;
if (name in loadNodesAndCredentials.loadedCredentials) continue;
const loadInfo = known[name];
if (!loadInfo) throw new Error(`Unknown credential type in dist: ${name}`);
const CredentialClass = require(path.join(baseDir, loadInfo.sourcePath))[loadInfo.className];
loadNodesAndCredentials.loadedCredentials[name] = {
type: new CredentialClass(),
sourcePath: '',
};
loadNodesAndCredentials.knownCredentials[name] = {
className: loadInfo.className,
sourcePath: loadInfo.sourcePath,
extends: loadInfo.extends,
supportedNodes: loadInfo.supportedNodes,
};
pending.push(...(loadInfo.extends ?? []));
}
}
/** Google Sheets `documentId` → "From list" → `spreadSheetsSearch`. */
const GOOGLE_SHEETS = { name: 'n8n-nodes-base.googleSheets', version: 4.7 };
const DRIVE_HOST = 'https://www.googleapis.com';
const DRIVE_FILES_PATH = '/drive/v3/files';
const PER_USER_ACCESS_TOKEN = 'per-user-access-token';
/**
* The OAuth2 resolver keys on a subject the external IdP names, not on an n8n user.
* Ids are 16-char nanoids in production, and Postgres enforces that column width.
*/
const EXTERNAL_RESOLVER_ID = 'external-oauth';
const EXTERNAL_RESOLVER_TYPE = 'credential-resolver.oauth2-1.0';
let member: User;
let otherMember: User;
let viewer: User;
let teamProject: Project;
beforeAll(async () => {
await initNodeTypes(loadNodesFromDist([GOOGLE_SHEETS.name]));
registerCredentialTypesFromDist(['googleSheetsOAuth2Api']);
});
beforeEach(async () => {
await testDb.truncate([
'DynamicCredentialUserEntry',
'DynamicCredentialResolver',
'SharedCredentials',
'CredentialsEntity',
]);
nock.cleanAll();
member = await createMember();
otherMember = await createMember();
viewer = await createMember();
// End-user credentials live in team projects only.
teamProject = await createTeamProject(undefined, member);
await linkUserToProject(otherMember, teamProject, 'project:editor');
// A viewer can read the credential but not connect an account to it.
await linkUserToProject(viewer, teamProject, 'project:viewer');
const resolverRepository = Container.get(DynamicCredentialResolverRepository);
const config = await Container.get(Cipher).encryptV2({});
await resolverRepository.save([
resolverRepository.create({
id: SYSTEM_RESOLVER_ID,
name: SYSTEM_RESOLVER_NAME,
type: SYSTEM_RESOLVER_TYPE,
config,
}),
resolverRepository.create({
id: EXTERNAL_RESOLVER_ID,
name: 'External IdP',
type: EXTERNAL_RESOLVER_TYPE,
config,
}),
]);
});
/**
* An end-user OAuth2 credential: the shared fields (client id/secret) are static, the
* token is not — it lives per user under the resolver.
*/
const createEndUserCredential = async ({ resolverId }: { resolverId?: string } = {}) => {
const credential = await saveCredential(
{
...randomCredentialPayload({ isResolvable: true, type: 'googleSheetsOAuth2Api' }),
// What the credential editor stores for a `googleSheetsOAuth2Api`, hidden
// fields included — everything except the token.
data: {
grantType: 'authorizationCode',
clientId: 'shared-client-id',
clientSecret: 'shared-client-secret',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
accessTokenUrl: 'https://oauth2.googleapis.com/token',
scope: 'https://www.googleapis.com/auth/drive',
authQueryParameters: 'access_type=offline&prompt=consent',
authentication: 'body',
},
},
{ project: teamProject, role: 'credential:owner' },
);
if (resolverId) {
await Container.get(CredentialsRepository).update(credential.id, { resolverId });
}
return credential;
};
/** What the connect flow stores: per-user data, encrypted, keyed by n8n user id. */
const connect = async (
credentialId: string,
user: User,
oauthTokenData: object = { access_token: PER_USER_ACCESS_TOKEN, token_type: 'Bearer' },
) => {
const encrypted = await Container.get(Cipher).encryptV2({ oauthTokenData });
await Container.get(DynamicCredentialUserEntryStorage).setCredentialData(
credentialId,
user.id,
SYSTEM_RESOLVER_ID,
encrypted,
{},
);
};
const requestBody = (credential: { id: string; name: string }) => ({
nodeTypeAndVersion: GOOGLE_SHEETS,
path: 'parameters.documentId',
methodName: 'spreadSheetsSearch',
currentNodeParameters: { authentication: 'oAuth2', resource: 'sheet' },
credentials: { googleSheetsOAuth2Api: { id: credential.id, name: credential.name } },
});
const listResources = async (user: User, credential: { id: string; name: string }) =>
await testServer
.authAgentFor(user)
.post('/dynamic-node-parameters/resource-locator-results')
.send(requestBody(credential));
/** The Drive listing, answered only for a request bearing the given user's token. */
const mockDriveListing = (accessToken = PER_USER_ACCESS_TOKEN) =>
nock(DRIVE_HOST)
.get(DRIVE_FILES_PATH)
.query(true)
.matchHeader('authorization', `Bearer ${accessToken}`)
.reply(200, {
files: [{ id: 'sheet-1', name: 'Q3 Forecast', webViewLink: 'https://example.com/sheet-1' }],
});
describe('design-time parameter loading with end-user credentials', () => {
test("resolves the requesting user's own connection", async () => {
const credential = await createEndUserCredential();
await connect(credential.id, member);
const driveScope = mockDriveListing();
const response = await listResources(member, credential);
expect(response.statusCode).toBe(200);
expect(response.body.data.results).toEqual([
{ name: 'Q3 Forecast', value: 'sheet-1', url: 'https://example.com/sheet-1' },
]);
// The interceptor only matches the per-user token, so a match proves the listing
// ran on the user's own connection rather than on the static credential data.
expect(driveScope.isDone()).toBe(true);
});
test('resolves per user, not per credential', async () => {
// The same credential, shared through the same project, lists against whichever
// user asked — the second user's token, not the first's.
const credential = await createEndUserCredential();
await connect(credential.id, member);
await connect(credential.id, otherMember, {
access_token: 'other-members-token',
token_type: 'Bearer',
});
const otherMemberScope = mockDriveListing('other-members-token');
const response = await listResources(otherMember, credential);
expect(response.statusCode).toBe(200);
expect(otherMemberScope.isDone()).toBe(true);
});
test('tells a user who has not connected, without calling the vendor', async () => {
const credential = await createEndUserCredential();
const driveScope = mockDriveListing();
const response = await listResources(member, credential);
expect(response.statusCode).toBe(500);
expect(response.body.message).toContain('is not connected for you');
expect(driveScope.isDone()).toBe(false);
});
test('refuses to send the n8n session token to an external-subject resolver', async () => {
// This resolver's identifier reads the context identity as a token its own provider
// issued, so resolution must be refused rather than handing over an n8n session token.
const credential = await createEndUserCredential({ resolverId: EXTERNAL_RESOLVER_ID });
const driveScope = mockDriveListing();
const response = await listResources(member, credential);
expect(response.statusCode).toBe(500);
expect(response.body.message).toContain('resolves credentials per external user');
expect(driveScope.isDone()).toBe(false);
});
test('refuses a user who may read the credential but not connect it', async () => {
// Resolution would key on this user's own connection, which they are not allowed
// to hold — so say that, rather than listing anything.
const credential = await createEndUserCredential();
const driveScope = mockDriveListing();
const response = await listResources(viewer, credential);
expect(response.statusCode).toBe(403);
expect(response.body.message).toContain('permission to connect');
expect(driveScope.isDone()).toBe(false);
});
test('leaves a credential that is not end-user alone', async () => {
const credential = await saveCredential(
{
...randomCredentialPayload({ isResolvable: false, type: 'googleSheetsOAuth2Api' }),
data: {
grantType: 'authorizationCode',
clientId: 'shared-client-id',
clientSecret: 'shared-client-secret',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
accessTokenUrl: 'https://oauth2.googleapis.com/token',
scope: 'https://www.googleapis.com/auth/drive',
oauthTokenData: { access_token: 'shared-token', token_type: 'Bearer' },
},
},
{ project: teamProject, role: 'credential:owner' },
);
const driveScope = mockDriveListing('shared-token');
const response = await listResources(member, credential);
expect(response.statusCode).toBe(200);
expect(driveScope.isDone()).toBe(true);
});
test('writes a refreshed token to the connection, not to the shared credential', async () => {
const credential = await createEndUserCredential();
await connect(credential.id, member, {
access_token: 'expired-token',
refresh_token: 'per-user-refresh-token',
token_type: 'Bearer',
});
// Google rejects the stale token, n8n refreshes it, and the retry succeeds.
const rejection = nock(DRIVE_HOST)
.get(DRIVE_FILES_PATH)
.query(true)
.matchHeader('authorization', 'Bearer expired-token')
.reply(401, { error: { message: 'Invalid Credentials' } });
const refresh = nock('https://oauth2.googleapis.com')
.post('/token', /per-user-refresh-token/)
.reply(200, { access_token: 'refreshed-token', token_type: 'Bearer' });
const retry = mockDriveListing('refreshed-token');
const response = await listResources(member, credential);
expect(response.statusCode).toBe(200);
expect([rejection.isDone(), refresh.isDone(), retry.isDone()]).toEqual([true, true, true]);
// The refreshed token belongs to this user alone, so it must land on their
// connection and leave the shared credential's static data untouched.
const entry = await Container.get(DynamicCredentialUserEntryStorage).getCredentialData(
credential.id,
member.id,
SYSTEM_RESOLVER_ID,
{},
);
expect(await Container.get(Cipher).decryptV2(entry!)).toContain('refreshed-token');
const stored = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(stored)).not.toHaveProperty('oauthTokenData');
});
});

View File

@ -1,11 +1,20 @@
import type { InsightsDateRange } from '@n8n/api-types';
import { mockInstance, createWorkflow, createTeamProject, testDb } from '@n8n/backend-test-utils';
import {
mockInstance,
createWorkflow,
createTeamProject,
linkUserToProject,
testDb,
} from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db';
import { DateTime } from 'luxon';
import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils';
import type { InsightsByPeriod } from '@/modules/insights/database/entities/insights-by-period';
import { Telemetry } from '@/telemetry';
import { createCustomRoleWithScopeSlugs } from '../shared/db/roles';
import { createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils';
@ -20,6 +29,17 @@ const testServer = utils.setupTestServer({
modules: ['insights'],
});
const truncateDatabase = async () => {
await testDb.truncate([
'InsightsRaw',
'InsightsByPeriod',
'InsightsMetadata',
'SharedWorkflow',
'WorkflowEntity',
'Project',
]);
};
beforeAll(async () => {
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
const admin = await createUser({ role: GLOBAL_ADMIN_ROLE });
@ -113,6 +133,87 @@ describe('GET /insights routes return 200 if date range inside license limits',
});
});
describe('GET /insights/summary', () => {
describe('scopes results by project access', () => {
let insightsViewer: SuperAgentTest;
let accessibleProject: Project;
let inaccessibleProject: Project;
let accessibleWorkflowInsights: InsightsByPeriod;
beforeAll(async () => {
testServer.license.setDefaults({
features: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
quotas: { 'quota:insights:maxHistoryDays': 365 },
});
// A global role granting only the insights view scopes, and no workflow access
const insightsRole = await createCustomRoleWithScopeSlugs(
['insights:list', 'insights:read'],
{
roleType: 'global',
},
);
const viewer = await createUser({ role: insightsRole });
insightsViewer = testServer.authAgentFor(viewer);
accessibleProject = await createTeamProject();
inaccessibleProject = await createTeamProject();
await linkUserToProject(viewer, accessibleProject, 'project:viewer');
const accessibleWorkflow = await createWorkflow({}, accessibleProject);
const inaccessibleWorkflow = await createWorkflow({}, inaccessibleProject);
const periodStart = DateTime.utc().startOf('day');
accessibleWorkflowInsights = await createCompactedInsightsEvent(accessibleWorkflow, {
type: 'success',
value: 1,
periodUnit: 'day',
periodStart,
});
await createCompactedInsightsEvent(inaccessibleWorkflow, {
type: 'success',
value: 5,
periodUnit: 'day',
periodStart,
});
});
test('should return the summary for a project the user can read', async () => {
const response = await insightsViewer
.get('/insights/summary')
.query({ projectId: accessibleProject.id })
.expect(200);
expect(response.body.data.total.value).toBe(accessibleWorkflowInsights.value);
});
test('should return 403 for a project the user cannot read', async () => {
await insightsViewer
.get('/insights/summary')
.query({ projectId: inaccessibleProject.id })
.expect(403);
});
test('should respond the same for an unknown project id as for an inaccessible one', async () => {
await insightsViewer
.get('/insights/summary')
.query({ projectId: 'non-existing-project-id' })
.expect(403);
});
test('should return the summary when no project is requested', async () => {
const response = await insightsViewer.get('/insights/summary').expect(200);
expect(response.body.data.total.value).toBe(accessibleWorkflowInsights.value);
});
afterAll(async () => {
await truncateDatabase();
});
});
});
describe('GET /insights/by-workflow', () => {
beforeAll(() => {
testServer.license.setDefaults({
@ -189,14 +290,7 @@ describe('GET /insights/by-workflow', () => {
describe('sorting order verification', () => {
afterEach(async () => {
await testDb.truncate([
'InsightsRaw',
'InsightsByPeriod',
'InsightsMetadata',
'SharedWorkflow',
'WorkflowEntity',
'Project',
]);
await truncateDatabase();
});
test('should return workflows sorted by total:desc', async () => {
@ -325,4 +419,215 @@ describe('GET /insights/by-workflow', () => {
});
});
});
describe('scopes results by project access', () => {
let insightsViewer: SuperAgentTest;
let accessibleProject: Project;
let inaccessibleProject: Project;
let inaccessibleWorkflowName: string;
let accessibleWorkflowInsights: InsightsByPeriod;
beforeAll(async () => {
testServer.license.setDefaults({
features: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
quotas: { 'quota:insights:maxHistoryDays': 365 },
});
// A global role granting only the insights view scopes, and no workflow access
const insightsRole = await createCustomRoleWithScopeSlugs(
['insights:list', 'insights:read'],
{
roleType: 'global',
},
);
const viewer = await createUser({ role: insightsRole });
insightsViewer = testServer.authAgentFor(viewer);
accessibleProject = await createTeamProject();
inaccessibleProject = await createTeamProject();
await linkUserToProject(viewer, accessibleProject, 'project:viewer');
const accessibleWorkflow = await createWorkflow({}, accessibleProject);
const inaccessibleWorkflow = await createWorkflow(
{ name: 'TOPSECRET Payroll Sync' },
inaccessibleProject,
);
inaccessibleWorkflowName = inaccessibleWorkflow.name;
const periodStart = DateTime.utc().startOf('day');
accessibleWorkflowInsights = await createCompactedInsightsEvent(accessibleWorkflow, {
type: 'success',
value: 2,
periodUnit: 'day',
periodStart,
});
await createCompactedInsightsEvent(inaccessibleWorkflow, {
type: 'success',
value: 6,
periodUnit: 'day',
periodStart,
});
});
test('should not include workflows from inaccessible projects when no project is requested', async () => {
const response = await insightsViewer.get('/insights/by-workflow').expect(200);
expect(response.body.data.count).toBe(1);
expect(response.body.data.data).toHaveLength(1);
const workflowNames = response.body.data.data.map(
(row: { workflowName: string }) => row.workflowName,
);
expect(workflowNames).not.toContain(inaccessibleWorkflowName);
expect(response.body.data.data[0].total).toBe(accessibleWorkflowInsights.value);
});
test('should return 403 for a project the user cannot read', async () => {
await insightsViewer
.get('/insights/by-workflow')
.query({ projectId: inaccessibleProject.id })
.expect(403);
});
test('should return workflows for a project the user can read', async () => {
const response = await insightsViewer
.get('/insights/by-workflow')
.query({ projectId: accessibleProject.id })
.expect(200);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0].total).toBe(accessibleWorkflowInsights.value);
});
afterAll(async () => {
await truncateDatabase();
});
});
});
describe('GET /insights/by-time', () => {
describe('scopes results by project access', () => {
let insightsViewer: SuperAgentTest;
let accessibleProject: Project;
let inaccessibleProject: Project;
let accessibleWorkflowInsights: InsightsByPeriod;
beforeAll(async () => {
testServer.license.setDefaults({
features: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
quotas: { 'quota:insights:maxHistoryDays': 365 },
});
// A global role granting only the insights view scopes, and no workflow access
const insightsRole = await createCustomRoleWithScopeSlugs(
['insights:list', 'insights:read'],
{
roleType: 'global',
},
);
const viewer = await createUser({ role: insightsRole });
insightsViewer = testServer.authAgentFor(viewer);
accessibleProject = await createTeamProject();
inaccessibleProject = await createTeamProject();
await linkUserToProject(viewer, accessibleProject, 'project:viewer');
const accessibleWorkflow = await createWorkflow({}, accessibleProject);
const inaccessibleWorkflow = await createWorkflow({}, inaccessibleProject);
const periodStart = DateTime.utc().startOf('day');
accessibleWorkflowInsights = await createCompactedInsightsEvent(accessibleWorkflow, {
type: 'success',
value: 3,
periodUnit: 'day',
periodStart,
});
await createCompactedInsightsEvent(inaccessibleWorkflow, {
type: 'success',
value: 7,
periodUnit: 'day',
periodStart,
});
});
test('returns 403 for an inaccessible project and scoped totals otherwise', async () => {
await insightsViewer
.get('/insights/by-time')
.query({ projectId: inaccessibleProject.id })
.expect(403);
const response = await insightsViewer.get('/insights/by-time').expect(200);
expect(response.body.data[0].values.succeeded).toBe(accessibleWorkflowInsights.value);
});
afterAll(async () => {
await truncateDatabase();
});
});
});
describe('GET /insights/by-time/time-saved', () => {
describe('scopes results by project access', () => {
let insightsViewer: SuperAgentTest;
let accessibleProject: Project;
let inaccessibleProject: Project;
let accessibleWorkflowInsights: InsightsByPeriod;
beforeAll(async () => {
testServer.license.setDefaults({
features: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
quotas: { 'quota:insights:maxHistoryDays': 365 },
});
// A global role granting only the insights view scopes, and no workflow access
const insightsRole = await createCustomRoleWithScopeSlugs(
['insights:list', 'insights:read'],
{
roleType: 'global',
},
);
const viewer = await createUser({ role: insightsRole });
insightsViewer = testServer.authAgentFor(viewer);
accessibleProject = await createTeamProject();
inaccessibleProject = await createTeamProject();
await linkUserToProject(viewer, accessibleProject, 'project:viewer');
const accessibleWorkflow = await createWorkflow({}, accessibleProject);
const inaccessibleWorkflow = await createWorkflow({}, inaccessibleProject);
const periodStart = DateTime.utc().startOf('day');
accessibleWorkflowInsights = await createCompactedInsightsEvent(accessibleWorkflow, {
type: 'time_saved_min',
value: 4,
periodUnit: 'day',
periodStart,
});
await createCompactedInsightsEvent(inaccessibleWorkflow, {
type: 'time_saved_min',
value: 8,
periodUnit: 'day',
periodStart,
});
});
afterAll(async () => {
await truncateDatabase();
});
test('returns 403 for an inaccessible project and scoped totals otherwise', async () => {
await insightsViewer
.get('/insights/by-time/time-saved')
.query({ projectId: inaccessibleProject.id })
.expect(403);
const response = await insightsViewer.get('/insights/by-time/time-saved').expect(200);
expect(response.body.data[0].values.timeSaved).toBe(accessibleWorkflowInsights.value);
});
});
});

View File

@ -1,12 +1,18 @@
import { insightsSummarySchema } from '@n8n/api-types';
import { createTeamProject, createWorkflow, testDb } from '@n8n/backend-test-utils';
import { type User } from '@n8n/db';
import {
createTeamProject,
createWorkflow,
linkUserToProject,
testDb,
} from '@n8n/backend-test-utils';
import { type Project, type User } from '@n8n/db';
import { DateTime } from 'luxon';
import { AUTH_COOKIE_NAME } from '@/constants';
import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils';
import { createOwnerWithApiKey } from '../shared/db/users';
import { createCustomRoleWithScopeSlugs } from '../shared/db/roles';
import { addApiKey, createOwnerWithApiKey, createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils';
@ -216,4 +222,69 @@ describe('GET /insights/summary', () => {
expect(response.body.total.value).toBe(4);
expect(response.body.failed.value).toBe(1);
});
describe('project access', () => {
let viewer: User;
let viewerAgent: SuperAgentTest;
let accessibleProject: Project;
let inaccessibleProject: Project;
let accessibleWorkflow: Awaited<ReturnType<typeof createWorkflow>>;
let inaccessibleWorkflow: Awaited<ReturnType<typeof createWorkflow>>;
beforeAll(async () => {
// A global role granting only the insights view scopes, and no workflow access
const insightsRole = await createCustomRoleWithScopeSlugs(
['insights:list', 'insights:read'],
{
roleType: 'global',
},
);
viewer = await createUser({ role: insightsRole });
viewer.apiKeys = [await addApiKey(viewer, { scopes: ['insights:read'] })];
accessibleProject = await createTeamProject();
inaccessibleProject = await createTeamProject();
await linkUserToProject(viewer, accessibleProject, 'project:viewer');
accessibleWorkflow = await createWorkflow({}, accessibleProject);
inaccessibleWorkflow = await createWorkflow({}, inaccessibleProject);
});
beforeEach(() => {
viewerAgent = testServer.publicApiAgentFor(viewer);
});
test('returns 403 for a project the API key holder cannot read', async () => {
await viewerAgent
.get('/insights/summary')
.query({ projectId: inaccessibleProject.id })
.expect(403);
});
test('returns the summary for a project the API key holder can read', async () => {
await viewerAgent
.get('/insights/summary')
.query({ projectId: accessibleProject.id })
.expect(200);
});
test('aggregates only accessible projects when no project is requested', async () => {
const accessibleSuccessfulExecutions = 3;
const inaccessibleSuccessfulExecutions = 5;
await createSummaryMetrics(accessibleWorkflow, { success: accessibleSuccessfulExecutions });
await createSummaryMetrics(inaccessibleWorkflow, {
success: inaccessibleSuccessfulExecutions,
});
const response = await viewerAgent
.get('/insights/summary')
.query({
startDate: DateTime.utc().minus({ days: 2 }).toISO(),
endDate: DateTime.utc().plus({ days: 1 }).toISO(),
})
.expect(200);
expect(response.body.total.value).toBe(accessibleSuccessfulExecutions);
});
});
});

View File

@ -116,4 +116,26 @@ describe('WorkflowSharingService', () => {
expect(sharedWorkflowIds).not.toContain(workflow2.id);
});
});
describe('rolesGrantingScope', () => {
it('should return no options for users holding the scope globally', async () => {
const options = await workflowSharingService.rolesGrantingScope(owner, 'workflow:read');
expect(options).toBeUndefined();
});
it('should return the roles granting the scope for other users', async () => {
const options = await workflowSharingService.rolesGrantingScope(member, 'workflow:read');
expect(options?.projectRoles).toContain('project:viewer');
expect(options?.workflowRoles).toContain('workflow:owner');
});
it('should return only the roles granting the requested scope', async () => {
const options = await workflowSharingService.rolesGrantingScope(member, 'workflow:update');
expect(options?.projectRoles).not.toContain('project:viewer');
expect(options?.projectRoles).toContain('project:admin');
});
});
});

View File

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "2.36.0",
"version": "2.36.2",
"description": "Core functionality of n8n",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -329,6 +329,49 @@ describe('ExecutionContextService', () => {
});
});
describe('buildRequestBoundCredentials()', () => {
it('should encrypt the credential context with the request context in metadata', async () => {
mockCipher.encryptV2.mockResolvedValue('encrypted-credential-blob');
const result = await service.buildRequestBoundCredentials('n8n-auth-cookie-jwt', {
method: 'POST',
endpoint: '/rest/dynamic-node-parameters/resource-locator-results',
browserId: 'browser-abc',
});
expect(mockCipher.encryptV2).toHaveBeenCalledWith({
version: 1,
identity: 'n8n-auth-cookie-jwt',
metadata: {
source: 'cookie-source',
method: 'POST',
endpoint: '/rest/dynamic-node-parameters/resource-locator-results',
browserId: 'browser-abc',
},
});
expect(result).toBe('encrypted-credential-blob');
});
it('should omit an absent browser id', async () => {
mockCipher.encryptV2.mockResolvedValue('encrypted-credential-blob');
await service.buildRequestBoundCredentials('cookie', {
method: 'POST',
endpoint: '/rest/dynamic-node-parameters/options',
});
expect(mockCipher.encryptV2).toHaveBeenCalledWith(
expect.objectContaining({
metadata: {
source: 'cookie-source',
method: 'POST',
endpoint: '/rest/dynamic-node-parameters/options',
},
}),
);
});
});
describe('buildTriggerIdentityCredentials()', () => {
it('should encrypt the credential context with the token as identity and resource in metadata', async () => {
mockCipher.encryptV2.mockResolvedValue('encrypted-credential-blob');

View File

@ -80,7 +80,7 @@ describe('WorkflowExecute node error forwarding to ErrorReporter', () => {
async function runWorkflowThatThrows(
error: unknown,
additionalDataOverrides: Partial<IWorkflowExecuteAdditionalData> = {},
): Promise<void> {
): Promise<IRun> {
const nodeType = mock<INodeType>({
description: {
name: 'manualTrigger',
@ -116,7 +116,23 @@ describe('WorkflowExecute node error forwarding to ErrorReporter', () => {
).mockImplementation(() => {});
await workflowExecute.run({ workflow, startNode: triggerNode });
await waitPromise.promise;
return await waitPromise.promise;
}
function createAxiosError() {
return Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), {
name: 'AxiosError',
isAxiosError: true,
code: 'ECONNREFUSED',
config: {
headers: { 'x-request-header': 'payload' },
data: '{"requestField":"payload"}',
},
options: {
headers: { 'x-request-header': 'payload' },
data: '{"requestField":"payload"}',
},
});
}
it('should report a Error instance as class + stack only, without its message', async () => {
@ -174,6 +190,39 @@ describe('WorkflowExecute node error forwarding to ErrorReporter', () => {
expect(mockErrorReporter.error).not.toHaveBeenCalled();
});
it('should store unhandled axios errors as NodeApiError', async () => {
const run = await runWorkflowThatThrows(createAxiosError());
const nodeError = run.data.resultData.runData.ThrowingNode[0].error;
const resultError = run.data.resultData.error;
expect(nodeError?.name).toBe('NodeApiError');
expect(resultError?.name).toBe('NodeApiError');
});
it('should omit axios config from stored errors', async () => {
const run = await runWorkflowThatThrows(createAxiosError());
const nodeError = run.data.resultData.runData.ThrowingNode[0].error;
const resultError = run.data.resultData.error;
expect(nodeError).not.toHaveProperty('config');
expect(resultError).not.toHaveProperty('config');
});
it('should omit legacy request options from stored errors', async () => {
const run = await runWorkflowThatThrows(createAxiosError());
const nodeError = run.data.resultData.runData.ThrowingNode[0].error;
const resultError = run.data.resultData.error;
expect(nodeError).not.toHaveProperty('options');
expect(resultError).not.toHaveProperty('options');
});
it('should omit request values from serialized execution errors', async () => {
const run = await runWorkflowThatThrows(createAxiosError());
expect(JSON.stringify(run.data.resultData)).not.toContain('payload');
});
it('should not report an ApplicationError with no cause', async () => {
const plainAppError = new ApplicationError('plain operational error', { level: 'error' });

View File

@ -59,6 +59,46 @@ export class ExecutionContextService {
return await this.cipher.encryptV2(payload);
}
/**
* Builds a credential context for work done on behalf of a live HTTP request, whose
* identity is the caller's own n8n session cookie.
*
* Preferred over {@link buildManualExecutionCredentials} whenever there is a request:
* the `cookie-source` metadata is re-validated with the request context (browser id,
* method, endpoint), which the `manual-execution` shape deliberately skips because a
* running execution no longer has a request to check against. The single definition
* of that shape lives here, so it cannot drift from what the resolver validates.
*
* @param n8nAuthCookie - The JWT string extracted from the `n8n-auth` browser cookie.
* @param request - Method, endpoint and browser id of the originating request.
*/
buildRequestBoundCredentialContext(
n8nAuthCookie: string,
{ method, endpoint, browserId }: { method: string; endpoint: string; browserId?: string },
): ICredentialContext {
return {
version: 1,
identity: n8nAuthCookie,
// Listed field by field rather than spread, so the context can only ever carry
// what the resolver re-validates.
metadata: { source: 'cookie-source', method, endpoint, browserId },
};
}
/**
* {@link buildRequestBoundCredentialContext}, encrypted for storage in
* `IExecutionContext.credentials`. Callers that hand the context straight to a
* resolver want the plaintext builder instead.
*/
async buildRequestBoundCredentials(
n8nAuthCookie: string,
request: { method: string; endpoint: string; browserId?: string },
): Promise<string> {
return await this.cipher.encryptV2(
this.buildRequestBoundCredentialContext(n8nAuthCookie, request),
);
}
/**
* Seals the token a trigger authenticated its caller with, plus the grant it was
* accepted under, so the run can re-verify itself for as long as it lasts. See

View File

@ -1,5 +1,6 @@
import type {
ICredentialDataDecryptedObject,
IExecutionContext,
ICredentialsHelper,
INode,
INodeType,
@ -44,7 +45,10 @@ describe('LoadOptionsContext', () => {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
executionContext: undefined,
});
const path = 'testPath';
const loadOptionsContext = new LoadOptionsContext(workflow, node, additionalData, path);
@ -64,6 +68,38 @@ describe('LoadOptionsContext', () => {
expect(credentials).toEqual({ secret: 'token' });
});
it("should decrypt with the entry point's execution context", async () => {
// Design-time loading has no `runExecutionData`, so without the fallback below
// `_getCredentials` would overwrite this with `undefined` and end-user credentials
// would resolve against static data instead of the requesting user's connection.
const executionContext: IExecutionContext = {
version: 1,
establishedAt: 1,
source: 'internal',
credentials: 'sealed-credential-context',
};
const additionalDataWithContext = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
// Assigned rather than passed to `mock`, which would deep-wrap it into a copy.
additionalDataWithContext.executionContext = executionContext;
const context = new LoadOptionsContext(workflow, node, additionalDataWithContext, path);
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);
await context.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
expect.objectContaining({ executionContext }),
expect.anything(),
testCredentialType,
'internal',
undefined,
false,
undefined,
);
});
});
describe('getCurrentNodeParameter', () => {
@ -102,8 +138,25 @@ describe('LoadOptionsContext', () => {
});
describe('getExecutionContext', () => {
it('should return undefined', () => {
it('should return undefined when the entry point set none', () => {
expect(loadOptionsContext.getExecutionContext()).toBeUndefined();
});
it("should return the entry point's context", () => {
const executionContext: IExecutionContext = {
version: 1,
establishedAt: 1,
source: 'internal',
credentials: 'sealed-credential-context',
};
const additionalDataWithContext = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
});
additionalDataWithContext.executionContext = executionContext;
const context = new LoadOptionsContext(workflow, node, additionalDataWithContext, path);
expect(context.getExecutionContext()).toBe(executionContext);
});
});
});

View File

@ -33,6 +33,16 @@ export class LoadOptionsContext extends NodeExecutionContext implements ILoadOpt
};
}
/**
* Design-time parameter loading has no `runExecutionData`, so the base implementation
* has no execution context to offer. Fall back to the one the entry point put on
* `additionalData` — without this, `_getCredentials` overwrites it with `undefined`
* right before decrypting, and end-user credentials silently fall back to static data.
*/
override getExecutionContext() {
return super.getExecutionContext() ?? this.additionalData.executionContext;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}

View File

@ -27,7 +27,6 @@ import type {
ITaskData,
ITaskDataConnections,
ITaskMetadata,
NodeApiError,
NodeOperationError,
Workflow,
IRunExecutionData,
@ -39,6 +38,7 @@ import type {
INodeIssues,
INodeType,
ITaskStartedData,
JsonObject,
AiAgentRequest,
IWorkflowExecutionDataProcess,
EngineRequest,
@ -55,6 +55,7 @@ import {
UnexpectedError,
UserError,
OperationalError,
NodeApiError,
TimeoutExecutionCancelledError,
ManualExecutionCancelledError,
createRunExecutionData,
@ -105,6 +106,14 @@ interface RunWorkflowOptions {
additionalRunFilterNodes?: string[];
}
function normalizeUnhandledAxiosError(error: unknown, node: INode): ExecutionBaseError {
if (isAxiosError(error)) {
return new NodeApiError(node, error as JsonObject);
}
return error as ExecutionBaseError;
}
export class WorkflowExecute {
private status: ExecutionStatus = 'new';
@ -2010,7 +2019,7 @@ export class WorkflowExecute {
});
}
const e = error as unknown as ExecutionBaseError;
const e = normalizeUnhandledAxiosError(error, executionNode);
executionError = { ...e, message: e.message, stack: e.stack };

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "1.35.0",
"version": "1.35.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/composables",
"type": "module",
"version": "1.26.0",
"version": "1.26.1",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/design-system",
"version": "2.35.0",
"version": "2.35.1",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/frontend-module-sdk",
"version": "0.6.0",
"version": "0.6.1",
"main": "src/index.ts",
"import": "src/index.ts",
"exports": {

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/i18n",
"type": "module",
"version": "2.36.0",
"version": "2.36.1",
"files": [
"dist"
],

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