chore: Bundle/1.x (#39936)

Co-authored-by: Matsu <matias.huhta@n8n.io>
This commit is contained in:
n8n-assistant[bot] 2026-09-30 07:44:17 +03:00 committed by GitHub
parent 205a47b0ad
commit ab0b5351ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 3221 additions and 427 deletions

View File

@ -195,8 +195,7 @@
"z-vue-scan": "patches/z-vue-scan.patch",
"@lezer/highlight": "patches/@lezer__highlight.patch",
"v-code-diff": "patches/v-code-diff.patch",
"lodash@4.18.0": "patches/lodash@4.18.0.patch",
"@n8n/tournament@1.9.1": "patches/@n8n__tournament@1.9.1.patch"
"lodash@4.18.0": "patches/lodash@4.18.0.patch"
}
}
}

View File

@ -5,8 +5,8 @@ import type { MemorySaver } from '@langchain/langgraph';
import { GraphRecursionError } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import type { INodeTypeDescription, IWorkflowBase } from 'n8n-workflow';
import { ApplicationError, jsonParse } from 'n8n-workflow';
jest.mock('@/tools/add-node.tool', () => ({
createAddNodeTool: jest.fn().mockReturnValue({ tool: { name: 'add_node' } }),
@ -69,6 +69,7 @@ import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
import { ValidationError } from '@/errors';
import { createMainAgentPrompt } from '@/tools/prompts/main-agent.prompt';
import type { StreamOutput } from '@/types/streaming';
import type { SimpleWorkflow } from '@/types/workflow';
import { createStreamProcessor } from '@/utils/stream-processor';
import {
WorkflowBuilderAgent,
@ -141,6 +142,73 @@ describe('WorkflowBuilderAgent', () => {
agent = new WorkflowBuilderAgent(config);
});
describe('workflow entry point', () => {
// getDefaultWorkflowJSON is the only producer of the workflowJSON the graph is started
// with, so it is the choke point where a stored workflow is made safe to key by node
// name. Exercised directly: the call sites below it sit behind the LangGraph stream.
function getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
return (
agent as unknown as {
getDefaultWorkflowJSON: (p: ChatPayload) => SimpleWorkflow;
}
).getDefaultWorkflowJSON(payload);
}
function payloadWith(currentWorkflow: unknown): ChatPayload {
return {
message: 'hi',
workflowContext: { currentWorkflow: currentWorkflow as IWorkflowBase },
};
}
it('should pass an ordinary workflow through unchanged', () => {
const currentWorkflow = {
name: 'w',
nodes: [{ id: '1', name: 'Manual' }],
connections: { Manual: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] } },
};
const result = getDefaultWorkflowJSON(payloadWith(currentWorkflow));
expect(result).toBe(currentWorkflow);
});
it('should return an empty workflow when no workflow is supplied', () => {
expect(getDefaultWorkflowJSON({ message: 'hi' })).toEqual({
nodes: [],
connections: {},
name: '',
});
});
it('should drop a node whose name cannot be an object key before the graph starts', () => {
const result = getDefaultWorkflowJSON(
payloadWith({
name: 'w',
nodes: [
{ id: '1', name: '__proto__' },
{ id: '2', name: 'Keep' },
],
connections: {},
}),
);
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
});
it('should drop a connection field that cannot be an object key before the graph starts', () => {
const result = getDefaultWorkflowJSON(
payloadWith(
jsonParse(
'{"name":"w","nodes":[],"connections":{"A":{"main":[[{"node":["__proto__"],"type":"main","index":0}]]}}}',
),
),
);
expect(result.connections.A?.main?.[0]).toEqual([]);
});
});
describe('chat method', () => {
let mockPayload: ChatPayload;

View File

@ -5,7 +5,7 @@ import type {
IConnection,
NodeConnectionType,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { isUsableObjectKey, NodeConnectionTypes } from 'n8n-workflow';
import type {
ConnectionValidationResult,
@ -186,6 +186,13 @@ export function createConnection(
sourceOutputIndex: number = 0,
targetInputIndex: number = 0,
): IConnections {
// Connection maps are keyed by node name and connection type. A key that names an
// inherited object property resolves to a slot that is not the map's own, so refuse
// it instead of writing through it.
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
return connections;
}
// Ensure source node exists in connections
if (!connections[sourceNodeName]) {
connections[sourceNodeName] = {};
@ -243,6 +250,10 @@ export function removeConnection(
sourceOutputIndex?: number,
targetInputIndex?: number,
): IConnections {
if (!isUsableObjectKey(sourceNodeName) || !isUsableObjectKey(connectionType)) {
return connections;
}
if (!connections[sourceNodeName]?.[connectionType]) {
return connections;
}

View File

@ -987,4 +987,74 @@ describe('connection.utils', () => {
expect(nodeHasOutputType(returnExprNode, 'ai_tool')).toBe(true);
});
});
describe('reserved connection keys', () => {
const plantedKeys = ['main', 'ai_tool'];
afterEach(() => {
for (const key of plantedKeys) {
delete (Object.prototype as Record<string, unknown>)[key];
}
});
it('createConnection should refuse a source name that is not a usable object key', () => {
const connections: IConnections = {};
const result = createConnection(connections, '__proto__', 'Sink', NodeConnectionTypes.Main);
expect(({} as Record<string, unknown>).main).toBeUndefined();
expect(Object.keys(result)).toEqual([]);
});
it('createConnection should refuse a connection type that is not a usable object key', () => {
const connections: IConnections = {};
const result = createConnection(
connections,
'Source',
'Sink',
'constructor' as NodeConnectionType,
);
expect(Object.keys(result)).toEqual([]);
});
it('createConnection should still create a connection for usable keys', () => {
const connections: IConnections = {};
const result = createConnection(connections, 'Source', 'Sink', NodeConnectionTypes.Main);
expect(result.Source?.main?.[0]).toEqual([{ node: 'Sink', type: 'main', index: 0 }]);
});
it('removeConnection should not write through a reserved source name to a shared slot', () => {
// Start from a shared slot that already holds a connection-shaped value, so the
// unguarded lookup finds something to read, rewrite and store back. One entry is
// kept by the filter, which keeps the unguarded path away from its delete branch.
const seeded = [[{ node: 'Other', type: 'ai_tool', index: 0 }]];
(Object.prototype as Record<string, unknown>).ai_tool = seeded;
const connections: IConnections = {};
const result = removeConnection(connections, '__proto__', 'Sink', 'ai_tool');
// Identity, not deep equality: the unguarded path stores back a filtered copy
// whose contents happen to match.
expect(({} as Record<string, unknown>).ai_tool).toBe(seeded);
expect(seeded).toEqual([[{ node: 'Other', type: 'ai_tool', index: 0 }]]);
expect(Object.keys(result)).toEqual([]);
});
it('removeConnection should refuse a connection type that resolves through the prototype', () => {
const connections: IConnections = {
Source: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
};
// 'constructor' reads back a function rather than undefined, so the unguarded
// path gets past its early return and then calls an array method on it.
expect(() => removeConnection(connections, 'Source', 'Sink', 'constructor')).not.toThrow();
expect(Object.keys(connections.Source)).toEqual(['main']);
});
});
});

View File

@ -1,4 +1,5 @@
import type { INode, IConnections } from 'n8n-workflow';
import { isUsableObjectKey } from 'n8n-workflow';
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
@ -36,10 +37,14 @@ function applyRemoveNodeOperation(
// Copy connections, excluding those from/to removed nodes
for (const [sourceId, nodeConnections] of Object.entries(workflow.connections)) {
if (!isUsableObjectKey(sourceId)) continue;
if (!nodesToRemove.has(sourceId)) {
cleanedConnections[sourceId] = {};
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
if (!isUsableObjectKey(connectionType)) continue;
if (Array.isArray(outputs)) {
cleanedConnections[sourceId][connectionType] = outputs.map((outputConnections) => {
if (Array.isArray(outputConnections)) {
@ -105,6 +110,36 @@ function applyUpdateNodeOperation(
};
}
/**
* Keep only the connection types that can be own keys of a plain object.
*/
function pickSafeConnectionTypes(nodeConnections: IConnections[string]): IConnections[string] {
return Object.fromEntries(
Object.entries(nodeConnections).filter(([connectionType]) => isUsableObjectKey(connectionType)),
);
}
/**
* Keep only the connection entries whose node-name and connection-type keys can be
* own keys of a plain object. Used where a whole connection map arrives from outside
* and is stored as-is.
*/
function pickSafeConnections(connections: IConnections): IConnections {
const safe: IConnections = {};
for (const [sourceName, nodeConnections] of Object.entries(connections)) {
if (!isUsableObjectKey(sourceName)) continue;
const safeTypes = pickSafeConnectionTypes(nodeConnections);
if (Object.keys(safeTypes).length > 0) {
safe[sourceName] = safeTypes;
}
}
return safe;
}
/**
* Handle 'setConnections' operation - replace all connections
*/
@ -116,7 +151,7 @@ function applySetConnectionsOperation(
return {
...workflow,
connections: operation.connections,
connections: pickSafeConnections(operation.connections),
};
}
@ -133,11 +168,17 @@ function applyMergeConnectionsOperation(
// Merge connections additively
for (const [sourceId, nodeConnections] of Object.entries(operation.connections)) {
// A node name that is not usable as an own key of the map cannot take part in a
// connection, so drop the entry rather than resolve it against the prototype chain.
if (!isUsableObjectKey(sourceId)) continue;
if (!connections[sourceId]) {
connections[sourceId] = nodeConnections;
connections[sourceId] = pickSafeConnectionTypes(nodeConnections);
} else {
// Merge connections for this source node
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
if (!isUsableObjectKey(connectionType)) continue;
if (!connections[sourceId][connectionType]) {
connections[sourceId][connectionType] = newOutputs;
} else {
@ -194,6 +235,10 @@ function applyRemoveConnectionOperation(
const { sourceNode, targetNode, connectionType, sourceOutputIndex, targetInputIndex } = operation;
if (!isUsableObjectKey(sourceNode) || !isUsableObjectKey(connectionType)) {
return workflow;
}
const connections = { ...workflow.connections };
// Check if source node has connections

View File

@ -1,4 +1,5 @@
import type { INode, IConnections } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { createNode, createWorkflow } from '../../../test/test-utils';
import type { SimpleWorkflow, WorkflowOperation } from '../../types/workflow';
@ -656,4 +657,196 @@ describe('operations-processor', () => {
expect(result.workflowOperations).toBeNull();
});
});
describe('reserved connection keys', () => {
const plantedKeys = ['main', 'shell', 'ai_tool'];
afterEach(() => {
for (const key of plantedKeys) {
delete (Object.prototype as Record<string, unknown>)[key];
}
});
it('should build a fixture whose reserved key is an own property', () => {
// Guards the tests below: a plain object literal would set the prototype instead
// of adding the key, which would make the cases vacuous.
const connections = { ['__proto__']: { main: [[]] } } as unknown as IConnections;
expect(Object.prototype.hasOwnProperty.call(connections, '__proto__')).toBe(true);
});
describe('mergeConnections', () => {
it('should ignore a source key that is not a usable object key', () => {
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Sink' })]);
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: {
['__proto__']: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
} as unknown as IConnections,
},
];
const result = applyOperations(workflow, operations);
expect(({} as Record<string, unknown>).main).toBeUndefined();
expect(Object.keys(result.connections)).toEqual([]);
});
it('should ignore a connection type that is not a usable object key', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Source' })],
connections: { Source: { main: [[]] } },
};
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: {
Source: { ['__proto__']: [[{ node: 'Sink', type: 'main', index: 0 }]] },
} as unknown as IConnections,
},
];
const result = applyOperations(workflow, operations);
// Object.keys alone cannot see this: the unguarded write lands on the entry's
// prototype, so assert the prototype itself is intact.
expect(Object.getPrototypeOf(result.connections.Source)).toBe(Object.prototype);
expect(Object.keys(result.connections.Source)).toEqual(['main']);
});
it('should still merge connections for usable keys', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Source' })],
connections: { Source: { main: [[{ node: 'A', type: 'main', index: 0 }]] } },
};
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: {
['__proto__']: { main: [[{ node: 'Ignored', type: 'main', index: 0 }]] },
Source: { main: [[{ node: 'B', type: 'main', index: 0 }]] },
} as unknown as IConnections,
},
];
const result = applyOperations(workflow, operations);
expect(result.connections.Source?.main?.[0]).toEqual([
{ node: 'A', type: 'main', index: 0 },
{ node: 'B', type: 'main', index: 0 },
]);
expect(result.connections).not.toHaveProperty('Ignored');
});
});
it('mergeConnections should filter types on a first-time source entry', () => {
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Source' })]);
const operations: WorkflowOperation[] = [
{
type: 'mergeConnections',
connections: jsonParse<IConnections>(
'{"Source":{"__proto__":[[]],"main":[[{"node":"Sink","type":"main","index":0}]]}}',
),
},
];
const result = applyOperations(workflow, operations);
expect(Object.keys(result.connections.Source)).toEqual(['main']);
expect(Object.getPrototypeOf(result.connections.Source)).toBe(Object.prototype);
});
describe('removeNode', () => {
it('should not reparent the rebuilt connections map', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Keep' }), createNode({ id: 'node2' })],
connections: jsonParse<IConnections>(
'{"__proto__":{"shell":[[{"node":"Keep","type":"main","index":0}]]},"Keep":{"main":[[]]}}',
),
};
const operations: WorkflowOperation[] = [{ type: 'removeNode', nodeIds: ['node2'] }];
const result = applyOperations(workflow, operations);
expect(Object.getPrototypeOf(result.connections)).toBe(Object.prototype);
expect(Object.keys(result.connections)).toEqual(['Keep']);
expect(({} as Record<string, unknown>).shell).toBeUndefined();
});
});
describe('removeConnection', () => {
it('should not write through a reserved source node to a shared slot', () => {
// Start from a shared slot that already holds a connection-shaped value, so the
// unguarded lookup finds something to rewrite and then delete.
(Object.prototype as Record<string, unknown>).main = [
[{ node: 'Sink', type: 'main', index: 0 }],
];
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Sink' })]);
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: '__proto__',
targetNode: 'Sink',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(workflow, operations);
expect(({} as Record<string, unknown>).main).toEqual([
[{ node: 'Sink', type: 'main', index: 0 }],
]);
expect(result.connections).toEqual({});
});
it('should leave the workflow unchanged for a reserved connection type', () => {
const workflow: SimpleWorkflow = {
name: 'Test Workflow',
nodes: [createNode({ id: 'node1', name: 'Source' })],
connections: { Source: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] } },
};
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'Source',
targetNode: 'Sink',
connectionType: 'constructor',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(workflow, operations);
expect(result.connections.Source?.main?.[0]).toHaveLength(1);
});
});
describe('setConnections', () => {
it('should keep only entries whose keys are usable object keys', () => {
const workflow = createWorkflow([createNode({ id: 'node1', name: 'Source' })]);
const operations: WorkflowOperation[] = [
{
type: 'setConnections',
connections: jsonParse<IConnections>(
'{"__proto__":{"ai_tool":[[]]},"Source":{"main":[[{"node":"Sink","type":"main","index":0}]],"constructor":[[]]}}',
),
},
];
const result = applyOperations(workflow, operations);
expect(Object.keys(result.connections)).toEqual(['Source']);
expect(Object.keys(result.connections.Source)).toEqual(['main']);
expect(({} as Record<string, unknown>).ai_tool).toBeUndefined();
});
});
});
});

View File

@ -0,0 +1,215 @@
import type { IConnection, IConnections, INode, NodeConnectionType } from 'n8n-workflow';
import { jsonParse, mapConnectionsByDestination } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types/workflow';
import { sanitizeWorkflowForBuilder } from '../workflow-sanitization';
/**
* A persisted workflow reaches the builder as parsed JSON, so a reserved key arrives as an
* own property of the connections map. An object literal cannot express that (`__proto__`
* in a literal sets the prototype instead), so build these fixtures with JSON.parse.
*/
function parseConnections(json: string): IConnections {
return jsonParse<IConnections>(json);
}
function createNode(name: string): INode {
return {
id: `id-${name}`,
name,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
}
function createWorkflow(nodes: INode[], connections: IConnections = {}): SimpleWorkflow {
return { name: 'Test Workflow', nodes, connections };
}
describe('sanitizeWorkflowForBuilder', () => {
const reservedKeys = ['main', 'ai_languageModel', 'shell'];
afterEach(() => {
for (const key of reservedKeys) {
delete (Object.prototype as Record<string, unknown>)[key];
}
});
it('should return the same workflow when every name and connection key is usable', () => {
const workflow = createWorkflow([createNode('Manual'), createNode('Sink')], {
Manual: { main: [[{ node: 'Sink', type: 'main', index: 0 }]] },
});
const result = sanitizeWorkflowForBuilder(workflow);
// Unchanged input is returned as-is, so the common case allocates nothing.
expect(result).toBe(workflow);
});
it('should drop a node whose name is not a usable object key', () => {
const workflow = createWorkflow([createNode('__proto__'), createNode('Sink')]);
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.nodes.map((n) => n.name)).toEqual(['Sink']);
});
it('should drop connections that start from a node with an unusable name', () => {
const workflow = createWorkflow(
[createNode('__proto__'), createNode('Sink')],
parseConnections('{"__proto__":{"main":[[{"node":"Sink","type":"main","index":0}]]}}'),
);
const result = sanitizeWorkflowForBuilder(workflow);
expect(Object.keys(result.connections)).toEqual([]);
});
it('should drop connections that point at a node with an unusable name', () => {
const workflow = createWorkflow([createNode('Manual'), createNode('__proto__')], {
Manual: {
main: [
[
{ node: '__proto__', type: 'main', index: 0 },
{ node: 'Sink', type: 'main', index: 0 },
],
],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([
{ node: 'Sink', type: 'main', index: 0 },
]);
});
it('should drop a connection type that is not a usable object key', () => {
const workflow = createWorkflow(
[createNode('Manual'), createNode('Sink')],
parseConnections(
'{"Manual":{"__proto__":[[{"node":"Sink","type":"main","index":0}]],"main":[[{"node":"Sink","type":"main","index":0}]]}}',
),
);
const result = sanitizeWorkflowForBuilder(workflow);
expect(Object.keys(result.connections.Manual)).toEqual(['main']);
});
it('should leave the shared object prototype untouched for a reserved source key', () => {
const workflow = createWorkflow(
[createNode('Sink')],
parseConnections('{"__proto__":{"shell":[[{"node":"Sink","type":"main","index":0}]]}}'),
);
sanitizeWorkflowForBuilder(workflow);
expect(({} as Record<string, unknown>).shell).toBeUndefined();
});
it('should drop a connection whose target name has no node entry', () => {
// A stored payload can name a target that is absent from `nodes`, so filtering by the
// set of known unsafe node names is not enough on its own.
const workflow = createWorkflow([createNode('Manual')], {
Manual: {
main: [
[
{ node: '__proto__', type: 'main', index: 0 },
{ node: 'Sink', type: 'main', index: 0 },
],
],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([
{ node: 'Sink', type: 'main', index: 0 },
]);
});
it('should drop a connection whose type field is not a usable object key', () => {
// Traversal helpers key the inverted graph by this field as well as by `node`.
const workflow = createWorkflow([createNode('Manual'), createNode('Sink')], {
Manual: {
main: [
[
{ node: 'Sink', type: '__proto__' as NodeConnectionType, index: 0 },
{ node: 'Sink', type: 'main', index: 0 },
],
],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([
{ node: 'Sink', type: 'main', index: 0 },
]);
});
// A request body is JSON, so these fields are only strings by convention. The key check
// has to reject a non-string outright: bracket access would coerce it back to the very
// name being guarded against ( obj[['__proto__']] writes to obj['__proto__'] ).
const coercibleKeys: Array<[string, unknown]> = [
['a string', '__proto__'],
['an array', ['__proto__']],
['a nested array', [['__proto__']]],
['an object with toString', { toString: (): string => '__proto__' }],
];
it.each(coercibleKeys)('should drop a connection whose target is %s', (_label, node) => {
const workflow = createWorkflow([createNode('Manual')], {
Manual: {
main: [[{ node, type: 'main', index: 0 } as unknown as IConnection]],
},
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.Manual?.main?.[0]).toEqual([]);
expect(({} as Record<string, unknown>).main).toBeUndefined();
});
it.each(coercibleKeys)('should drop a node whose name is %s', (_label, name) => {
const workflow = createWorkflow([
{ ...createNode('placeholder'), name } as unknown as INode,
createNode('Keep'),
]);
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
});
it('should leave the inverted graph intact for a non-string target', () => {
// End to end on the choke-point property: whatever the sanitizer returns must be
// safe to invert, which is where a surviving field would be used as a key.
const workflow = jsonParse<SimpleWorkflow>(
'{"name":"w","nodes":[],"connections":{"A":{"main":[[{"node":["__proto__"],"type":"main","index":0}]]}}}',
);
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.connections.A?.main?.[0]).toEqual([]);
expect(Object.getPrototypeOf(mapConnectionsByDestination(result.connections))).toBe(
Object.prototype,
);
});
it('should keep the workflow name and unrelated nodes', () => {
const workflow = createWorkflow([createNode('__proto__'), createNode('Keep')], {
Keep: { main: [[]] },
});
const result = sanitizeWorkflowForBuilder(workflow);
expect(result.name).toBe('Test Workflow');
expect(result.nodes.map((n) => n.name)).toEqual(['Keep']);
expect(result.connections.Keep).toBeDefined();
});
});

View File

@ -0,0 +1,72 @@
import type { IConnection, IConnections } from 'n8n-workflow';
import { isUsableObjectKey } from 'n8n-workflow';
import type { SimpleWorkflow } from '../types/workflow';
/**
* A connection can only be kept if both of its node-name and connection-type fields can be
* own keys of an object. Traversal helpers invert the graph and key the result by these two
* fields, so an unusable value there is as bad as an unusable key in the stored map — and a
* stored payload can name a target that has no node entry at all.
*/
function isUsableConnection(connection: IConnection): boolean {
return isUsableObjectKey(connection.node) && isUsableObjectKey(connection.type);
}
/**
* Remove nodes whose name cannot be an own key of a plain object, together with every
* connection that references them or that is itself keyed by such a name.
*
* The builder keys its connection maps by node name. A name such as `__proto__` resolves
* to an inherited slot instead of an own key, so a node with that name can never take part
* in a valid graph. The write paths guard their own keys; this runs once when a stored
* workflow enters builder state, so no later step has to reason about the name at all.
*/
export function sanitizeWorkflowForBuilder(workflow: SimpleWorkflow): SimpleWorkflow {
const nodes = workflow.nodes ?? [];
// Set when anything is dropped, so an untouched workflow is returned as-is.
let changed = false;
const keptNodes = nodes.filter((node) => {
if (isUsableObjectKey(node.name)) return true;
changed = true;
return false;
});
const connections: IConnections = {};
for (const [sourceName, nodeConnections] of Object.entries(workflow.connections ?? {})) {
if (!isUsableObjectKey(sourceName)) {
changed = true;
continue;
}
const keptTypes: IConnections[string] = {};
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
if (!isUsableObjectKey(connectionType) || !Array.isArray(outputs)) {
changed = true;
continue;
}
keptTypes[connectionType] = outputs.map((outputConnections) => {
if (!Array.isArray(outputConnections)) return outputConnections;
const kept = outputConnections.filter(isUsableConnection);
if (kept.length !== outputConnections.length) changed = true;
return kept;
});
}
connections[sourceName] = keptTypes;
}
if (!changed) return workflow;
return {
...workflow,
nodes: keptNodes,
connections,
};
}

View File

@ -43,6 +43,7 @@ import {
} from './utils/stream-processor';
import { estimateTokenCountFromMessages } from './utils/token-usage';
import { executeToolsInParallel } from './utils/tool-executor';
import { sanitizeWorkflowForBuilder } from './utils/workflow-sanitization';
import { WorkflowState } from './workflow-state';
/**
@ -459,12 +460,13 @@ export class WorkflowBuilderAgent {
}
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
return (
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
nodes: [],
connections: {},
}
);
const currentWorkflow = payload.workflowContext?.currentWorkflow as SimpleWorkflow | undefined;
if (!currentWorkflow) {
return { nodes: [], connections: {}, name: '' };
}
return sanitizeWorkflowForBuilder(currentWorkflow);
}
async *chat(payload: ChatPayload, userId?: string, abortSignal?: AbortSignal) {

View File

@ -32,7 +32,7 @@
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@n8n/errors": "workspace:*",
"@n8n/tournament": "1.9.1",
"@n8n/tournament": "1.11.1",
"isolated-vm": "catalog:",
"jmespath": "0.16.0",
"js-base64": "catalog:",

View File

@ -0,0 +1,188 @@
import { JSDOM } from 'jsdom';
import { createPage } from '../templates';
/**
* The custom-CSS sink, tested through a real HTML parser and a real JS engine.
*
* The page ships an empty `<style>` element and a classic script that assigns the CSS to
* its `textContent`. Two boundaries decide whether that holds: the HTML parser, which
* must never see the value, and the JS string literal the value travels in. A test that
* matches the rendered page with a regular expression asserts against *our* model of
* those two parsers, and the spellings that matter here (`</STYLE`, `</style/`,
* `<!--</style>`) are exactly where such a model is most likely to be wrong. JSDOM
* replaces the model with the parsers themselves.
*/
const LINE_SEPARATOR = String.fromCharCode(0x2028);
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
/** Ties the assertions to the element `templates.ts` fills. */
const CUSTOM_CSS_ELEMENT_ID = 'n8n-chat-custom-css';
// Inputs a browser resolves as closing a style element, including the spellings an HTML
// parser disagrees on. The `<<` and `</st<` shapes cover the case that matters most for
// this sink: a filter that removes an inner `</style` splices the text around it into a
// closing tag it has already checked, so removing a match can create one.
const STYLE_CLOSING_INPUTS = [
'<</style/style><script>alert(document.domain)</script>',
'</style><script>alert(1)</script>',
'</style/><script>alert(1)</script>',
'</style//><img src=x onerror=alert(1)>',
'</style ><script>alert(1)</script>',
'</style\t><script>alert(1)</script>',
'</STYLE><script>alert(1)</script>',
'</sTyLe><svg onload=alert(1)>',
'</st<</style/yle><script>alert(1)</script>',
'</style',
'<!--</style><script>alert(1)</script>-->',
];
// Inputs aimed at the assignment the CSS travels in rather than at the style element:
// closing the JS string literal, closing the script element, and the two line
// terminators that a JSON string may hold but a pre-ES2019 string literal could not.
const SCRIPT_CONTEXT_INPUTS = [
'"; alert("foobar"); //',
'"; alert(1); "',
'\\"; alert(1); //',
'\\\\"; alert(1); //',
'"}); alert(1); ({"',
'</script><script>alert(1)</script>',
'</SCRIPT ><script>alert(1)</script>',
'<!--</script>--><script>alert(1)</script>',
`";${LINE_SEPARATOR}alert(1);//`,
`";${PARAGRAPH_SEPARATOR}alert(1);//`,
'\n"; alert(1); //',
'\u0000"; alert(1); //',
];
// Legal CSS that a filter would damage. The range syntax of Media Queries Level 4 holds
// a bare `<`, so any rule that removes or escapes `<` in the markup drops it silently.
// These are the reason the CSS travels as a string instead.
const LEGAL_CSS = [
['plain rules', '.chat { color: red; font-size: 14px; }'],
['combinators', 'div > span + p ~ .class:hover { background: #fff; }'],
['media query range', '@media (width < 600px) { .chat { display: none; } }'],
['container query range', '@container (width < 400px) { .chat { gap: 0; } }'],
['a literal < in a value', '.chat::after { content: "<3"; }'],
['an escaped quote', ".chat::after { content: '\\\\'; }"],
['a line separator', `.chat { color: red; }${LINE_SEPARATOR}`],
] as const;
const params = {
instanceId: 'test-instance',
webhookUrl: 'http://test.com/webhook',
showWelcomeScreen: false,
loadPreviousSession: 'notSupported' as const,
i18n: { en: {} },
mode: 'test' as const,
authentication: 'none' as const,
allowFileUploads: false,
allowedFilesMimeTypes: '',
customCss: '',
enableStreaming: false,
initialMessages: '',
};
/**
* Parses the rendered page the way a browser does, with its classic scripts running.
*
* `beforeParse` is where the recorders go: JSDOM runs scripts inside the constructor, so
* anything assigned to `dom.window` afterwards arrives too late and the test silently
* stops watching for execution. The widget bootstrap is a module script, which JSDOM
* skips, and `resources` defaults to loading nothing, so no test reaches the CDN.
*/
function renderPage(customCss: string) {
const executed: string[] = [];
const dom = new JSDOM(createPage({ ...params, customCss }), {
runScripts: 'dangerously',
url: 'http://test.local/chat',
beforeParse(window) {
window.alert = (message?: string) => executed.push(`alert(${String(message)})`);
window.addEventListener('error', (event) =>
executed.push(`error(${(event as ErrorEvent).message})`),
);
},
});
return { document: dom.window.document, executed };
}
/** The classic scripts in the page. The module bootstrap never runs under JSDOM. */
function executableScripts(document: Document) {
return [...document.querySelectorAll('script')].filter((script) => {
const type = script.getAttribute('type');
return type === null || type === 'text/javascript';
});
}
/**
* The whole contract, asserted against the parsed page. Every input goes through this:
* a hostile one must not behave differently from a benign one, which is the point of
* moving the value out of the markup.
*/
function expectDeliveredAsCssText(customCss: string) {
const { document, executed } = renderPage(customCss);
// Execution comes first. A payload that ran is the finding; asserting the delivered
// text before this would report a mismatched string and hide it.
expect(executed).toEqual([]);
// The input contributed no tag - neither one it spelled out nor one spliced together
// from the text on either side of a removed match.
expect(document.querySelectorAll('img, svg, iframe, object, embed')).toHaveLength(0);
// The page's own base block and the custom one. A third means the input opened it.
expect(document.querySelectorAll('style')).toHaveLength(2);
// The injector is the only script the page can ever execute, and only when there is
// CSS to apply.
expect(executableScripts(document)).toHaveLength(customCss ? 1 : 0);
const element = document.getElementById(CUSTOM_CSS_ELEMENT_ID);
expect(element?.tagName).toBe('STYLE');
// The CSS arrives byte for byte. Nothing is removed and nothing is escaped, which is
// what no HTML context can offer: reaching this element through one would cost the
// legal CSS below, and a NUL would arrive as U+FFFD.
expect(element?.textContent).toBe(customCss);
}
describe('ChatTrigger custom CSS', () => {
it.each(STYLE_CLOSING_INPUTS)('does not let %j close the style element', (customCss) => {
expectDeliveredAsCssText(customCss);
});
it.each(SCRIPT_CONTEXT_INPUTS)('does not let %j escape the assignment', (customCss) => {
expectDeliveredAsCssText(customCss);
});
it.each(LEGAL_CSS)('delivers %s unchanged', (_label, customCss) => {
expectDeliveredAsCssText(customCss);
});
it('leaves the element empty and adds no script when there is no custom CSS', () => {
expectDeliveredAsCssText('');
});
// The one property the parsed DOM cannot state: that the value reached a single place
// in the page. The DOM shows where it landed, not that it landed nowhere else.
it('writes the CSS to exactly one place in the page', () => {
// Anchored at both ends, so the only part that may vary between two pages is the
// string literal. An input that added a statement, or escaped the literal or the
// script element, cannot still match.
const injector = new RegExp(
'\\s*<script>\\s*' +
`document\\.getElementById\\("${CUSTOM_CSS_ELEMENT_ID}"\\)\\.textContent =\\s*` +
'("(?:[^"\\\\]|\\\\.)*");' +
'\\s*</script>',
);
const pageWithoutCustomCss = createPage({ ...params, customCss: '' });
for (const customCss of [...STYLE_CLOSING_INPUTS, ...SCRIPT_CONTEXT_INPUTS]) {
const page = createPage({ ...params, customCss });
expect(page).toMatch(injector);
// Everything outside the literal is fixed text, so dropping the injector must
// give back the page built with no custom CSS at all.
expect(page.replace(injector, '')).toBe(pageWithoutCustomCss);
}
});
});

View File

@ -1,7 +1,6 @@
import {
createPage,
escapeForScriptContext,
getSanitizedCustomCss,
getSanitizedInitialMessages,
getSanitizedI18nConfig,
} from '../templates';
@ -126,76 +125,6 @@ describe('ChatTrigger Templates Security', () => {
});
});
describe('XSS Prevention in customCss', () => {
it('should strip </style to prevent breakout with onload', () => {
const result = createPage({
...defaultParams,
customCss: '</style><style onload=alert(origin)>',
});
// The </style sequence is stripped, so the payload stays trapped as CSS text
expect(result).not.toContain('</style><style');
});
it('should strip </style to prevent breakout with script injection', () => {
const result = createPage({
...defaultParams,
customCss: '</style><script>alert(1)</script>',
});
expect(result).not.toContain('</style><script');
});
it('should strip </style/> to prevent parser differential XSS', () => {
const result = createPage({
...defaultParams,
customCss: '</style/><script>alert(1)</script>',
});
// </style/> is recognized as a closing tag by browsers but not sanitize-html
expect(result).not.toContain('</style/>');
expect(result).not.toContain('</style/');
});
it('should strip </style//> variant', () => {
const result = createPage({
...defaultParams,
customCss: '</style//><img src=x onerror=alert(1)>',
});
expect(result).not.toContain('</style/');
});
it('should strip </style case-insensitively', () => {
const result = createPage({
...defaultParams,
customCss: '</STYLE><script>alert(1)</script>',
});
expect(result).not.toContain('</STYLE>');
});
it('should preserve legitimate CSS', () => {
const css = '.chat { color: red; font-size: 14px; }';
const result = createPage({
...defaultParams,
customCss: css,
});
expect(result).toContain(css);
});
it('should preserve CSS with special characters', () => {
const css = 'div > span + p ~ .class:hover { background: #fff; }';
const result = createPage({
...defaultParams,
customCss: css,
});
expect(result).toContain(css);
});
});
describe('General Security', () => {
it('should not expose raw user input in HTML comments or other locations', () => {
const maliciousInput = '</script><script>alert("XSS")</script>';
@ -456,43 +385,6 @@ describe('ChatTrigger Templates Security', () => {
});
});
describe('getSanitizedCustomCss function', () => {
it('should strip </style to prevent breakout', () => {
expect(getSanitizedCustomCss('</style><script>alert(1)</script>')).toBe(
'><script>alert(1)</script>',
);
});
it('should strip </style/> parser differential variant', () => {
expect(getSanitizedCustomCss('</style/><script>alert(1)</script>')).toBe(
'/><script>alert(1)</script>',
);
});
it('should strip </style case-insensitively', () => {
expect(getSanitizedCustomCss('</STYLE>')).toBe('>');
expect(getSanitizedCustomCss('</Style>')).toBe('>');
expect(getSanitizedCustomCss('</sTyLe>')).toBe('>');
});
it('should strip multiple </style occurrences', () => {
expect(getSanitizedCustomCss('</style>x</style>')).toBe('>x>');
});
it('should strip partial </style without closing >', () => {
expect(getSanitizedCustomCss('</style')).toBe('');
});
it('should handle empty string', () => {
expect(getSanitizedCustomCss('')).toBe('');
});
it('should preserve legitimate CSS', () => {
const css = '.chat { color: red; } div > span + p ~ .class:hover { background: #fff; }';
expect(getSanitizedCustomCss(css)).toBe(css);
});
});
describe('getSanitizedInitialMessages function', () => {
it('should sanitize XSS payloads', () => {
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';

View File

@ -52,11 +52,31 @@ export function getSanitizedI18nConfig(config: Record<string, string>): Record<s
return sanitized;
}
export function getSanitizedCustomCss(customCss: string): string {
// Strip any sequence that could close the <style> context.
// Browsers treat </style followed by /, space, tab, or > as a closing tag,
// so we remove all </style variants (case-insensitive) to prevent breakout.
return customCss.replace(/<\/style/gi, '');
/** Ties the empty element the page ships to the script that fills it. */
const CUSTOM_CSS_ELEMENT_ID = 'n8n-chat-custom-css';
/**
* Applies the user's custom CSS without ever placing it in an HTML context.
*
* Interpolating it into `<style>...</style>` put it one `</style` away from the HTML
* parser, and no amount of stripping closes that: removing an inner `</style` splices
* the text around it into a real closing tag, so the filter builds the sequence it is
* meant to remove. Assigning `textContent` parses no markup at all, so the CSS reaches
* the CSS parser byte for byte and cannot reach the HTML one.
*
* Escaping is the same `escapeForScriptContext` the rest of this page's values use. A
* classic script, so it runs before the body is parsed and the styles are never applied
* late. The element stays where the old `<style>` was, which keeps the cascade order.
*/
function buildCustomCssScript(customCss: string): string {
if (!customCss) return '';
return `
<script>
document.getElementById(${escapeForScriptContext(CUSTOM_CSS_ELEMENT_ID)}).textContent =
${escapeForScriptContext(customCss)};
</script>`;
}
export function createPage({
@ -104,7 +124,7 @@ export function createPage({
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
const sanitizedAllowFileUploads = !!allowFileUploads;
const sanitizedAllowedFilesMimeTypes = sanitizeUserInput(allowedFilesMimeTypes?.toString() ?? '');
const sanitizedCustomCss = getSanitizedCustomCss(customCss?.toString() ?? '');
const customCssScript = buildCustomCssScript(customCss?.toString() ?? '');
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
loadPreviousSession as LoadPreviousSessionChatOption,
@ -131,7 +151,7 @@ export function createPage({
height: 100%;
}
</style>
<style>${sanitizedCustomCss}</style>
<style id="${CUSTOM_CSS_ELEMENT_ID}"></style>${customCssScript}
</head>
<body>
<script type="module">

View File

@ -1,12 +1,32 @@
import { ApplicationError } from '@n8n/errors';
import { createServer } from 'node:http';
/** Methods that Node's HTTP listener replaces on every inbound socket. */
const METHODS = ['prependListener', 'prependOnceListener'] as const;
export class HealthCheckServer {
private server = createServer((_, res) => {
res.writeHead(200);
res.end('OK');
});
constructor() {
// Run before Node's HTTP connection listener so its assignments use writable
// properties on this socket instead of the locked EventEmitter prototype.
this.server.prependListener('connection', (socket) => {
for (const method of METHODS) {
// Keep the exception local to health-check sockets rather than weakening
// the shared stream prototypes for sandboxed code.
Object.defineProperty(socket, method, {
value: socket[method],
writable: true,
configurable: true,
enumerable: true,
});
}
});
}
async start(host: string, port: number) {
return await new Promise<void>((resolve, reject) => {
const portInUseErrorHandler = (error: NodeJS.ErrnoException) => {

View File

@ -0,0 +1,224 @@
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const CHILD_PROCESS_TIMEOUT_MS = 20_000;
const TEST_TIMEOUT_MS = 25_000;
const HARDENING_MODULE = path.resolve(__dirname, '../prototype-hardening.ts');
const HEALTH_CHECK_MODULE = path.resolve(__dirname, '../../health-check-server.ts');
/**
* Run a script in a throwaway process, because `freezeGlobals` mutates
* process-wide prototypes and would wreck the Vitest worker. Node's type
* stripping loads the hardening module straight from source.
*/
const runInChildProcess = async (body: string, harden: boolean) => {
const script = `
import { EventEmitter } from 'node:events';
import { registerHooks } from 'node:module';
import { Stream } from 'node:stream';
import { Socket } from 'node:net';
import { get } from 'node:http';
${harden ? `import { freezeGlobals } from ${JSON.stringify(HARDENING_MODULE)};\nfreezeGlobals();` : ''}
const evil = function evil() { return 'pwned'; };
${body}
`;
const dir = await mkdtemp(path.join(tmpdir(), 'n8n-hardening-'));
const scriptPath = path.join(dir, 'hardening-probe.mts');
try {
await writeFile(scriptPath, script);
const { stdout } = await execFileAsync(process.execPath, ['--no-warnings', scriptPath], {
timeout: CHILD_PROCESS_TIMEOUT_MS,
});
return stdout;
} finally {
await rm(dir, { recursive: true, force: true });
}
};
/** Serve a single request through the runner's real health check server. */
const SERVE_ONE_REQUEST = `
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@n8n/errors') {
return {
shortCircuit: true,
url: 'data:text/javascript,export class ApplicationError extends Error {}',
};
}
return nextResolve(specifier, context);
},
});
const { HealthCheckServer } = await import(${JSON.stringify(HEALTH_CHECK_MODULE)});
const server = new HealthCheckServer();
await server.start('127.0.0.1', 0);
const address = server.server.address();
const status = await new Promise((resolve, reject) => {
get('http://127.0.0.1:' + address.port + '/healthz', (res) => {
res.resume();
res.on('end', () => resolve(res.statusCode));
}).on('error', reject);
});
console.log('status:' + status);
await server.stop();
`;
/**
* Each probe throws when the write is refused outright and returns `false`
* when it is silently dropped; either way the prototype stayed intact.
*/
const PROBES = `
const probe = (label, attempt) => {
try {
console.log(label + ':' + (attempt() ? 'allowed' : 'blocked'));
} catch {
console.log(label + ':blocked');
}
};
probe('ee-emit', () => { EventEmitter.prototype.emit = evil; return EventEmitter.prototype.emit === evil; });
probe('ee-prepend', () => { EventEmitter.prototype.prependListener = evil; return EventEmitter.prototype.prependListener === evil; });
probe('stream-prepend', () => { Stream.prototype.prependListener = evil; return Stream.prototype.prependListener === evil; });
probe('socket-prepend', () => { Socket.prototype.prependListener = evil; return Socket.prototype.prependListener === evil; });
probe('ee-emit-delete', () => { delete EventEmitter.prototype.emit; return EventEmitter.prototype.emit !== evil && !Object.hasOwn(EventEmitter.prototype, 'emit'); });
probe('process-emit', () => {
const original = process.emit;
process.emit = evil;
const shadowed = process.emit === evil;
if (shadowed) Object.defineProperty(process, 'emit', { value: original, writable: true, configurable: true, enumerable: true });
return shadowed;
});
const replacement = function replacement() {};
const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
probe('typedarray-set', () => { typedArrayPrototype.set = replacement; return typedArrayPrototype.set === replacement; });
probe('typedarray-length', () => {
Object.defineProperty(typedArrayPrototype, 'length', { get: replacement, configurable: true });
return Object.getOwnPropertyDescriptor(typedArrayPrototype, 'length').get === replacement;
});
const timer = setTimeout(() => {}, 1_000);
clearTimeout(timer);
const timeoutPrototype = Object.getPrototypeOf(timer);
probe('timeout-refresh', () => { timeoutPrototype.refresh = replacement; return timeoutPrototype.refresh === replacement; });
`;
const REACHABILITY_ASSERTION = `
const collected = new Set();
const isFreezable = (value) => value !== null && (typeof value === 'object' || typeof value === 'function');
const collect = (value) => {
let current = value;
while (isFreezable(current) && !collected.has(current)) {
collected.add(current);
current = Object.getPrototypeOf(current);
}
};
const timeout = setTimeout(() => {}, 0);
clearTimeout(timeout);
const immediate = setImmediate(() => {});
clearImmediate(immediate);
function* generatorFunction() {}
async function* asyncGeneratorFunction() {}
async function asyncFunction() {}
const globalFunctions = Object.getOwnPropertyNames(globalThis)
.map((name) => Reflect.get(globalThis, name))
.filter((value) => typeof value === 'function');
for (const globalFunction of globalFunctions) {
collect(Object.getPrototypeOf(globalFunction));
collect(globalFunction.prototype);
}
const samples = [
timeout, immediate,
generatorFunction, asyncGeneratorFunction, asyncFunction,
[][Symbol.iterator](), [].values().map((value) => value),
''[Symbol.iterator](),
new Map()[Symbol.iterator](), new Set()[Symbol.iterator](),
new FormData().entries(),
];
for (const sample of samples) {
const prototype = Object.getPrototypeOf(sample);
collect(prototype);
if (isFreezable(prototype)) collect(prototype.prototype);
}
collected.delete(EventEmitter.prototype);
const extensible = [...collected].filter((value) => Object.isExtensible(value));
console.log('extensible:' + extensible.map((value) => value?.constructor?.name ?? 'anonymous').join(','));
const eventEmitterLocked = ['emit', 'on', 'once', 'addListener', 'prependListener', 'prependOnceListener']
.every((method) => Object.getOwnPropertyDescriptor(EventEmitter.prototype, method).writable === false);
console.log('ee-locked:' + eventEmitterLocked);
`;
describe('freezeGlobals', () => {
let probeOutput: string;
beforeAll(async () => {
probeOutput = await runInChildProcess(PROBES, true);
}, TEST_TIMEOUT_MS);
it(
'serves an HTTP request in a process that has not been hardened',
async () => {
const stdout = await runInChildProcess(SERVE_ONE_REQUEST, false);
expect(stdout).toContain('status:200');
},
TEST_TIMEOUT_MS,
);
// CAT-3995: Node's HTTP server assigns `prependListener` on every inbound
// socket, which used to hit the locked `EventEmitter.prototype` property and
// crash the runner on every health check probe.
it(
'serves an HTTP request in a hardened process',
async () => {
const stdout = await runInChildProcess(SERVE_ONE_REQUEST, true);
expect(stdout).toContain('status:200');
},
TEST_TIMEOUT_MS,
);
it('keeps the shared prototypes sealed against sandboxed code', () => {
expect(probeOutput).toContain('ee-emit:blocked');
expect(probeOutput).toContain('ee-prepend:blocked');
expect(probeOutput).toContain('ee-emit-delete:blocked');
expect(probeOutput).toContain('process-emit:blocked');
expect(probeOutput).toContain('stream-prepend:blocked');
expect(probeOutput).toContain('socket-prepend:blocked');
});
it('keeps prototypes with no globalThis binding sealed against sandboxed code', () => {
expect(probeOutput).toContain('typedarray-set:blocked');
expect(probeOutput).toContain('typedarray-length:blocked');
expect(probeOutput).toContain('timeout-refresh:blocked');
});
it(
'leaves no prototype reachable from a global extensible, apart from the method-locked one',
async () => {
const stdout = await runInChildProcess(REACHABILITY_ASSERTION, true);
expect(stdout).toContain('extensible:\n');
expect(stdout).toContain('ee-locked:true');
},
TEST_TIMEOUT_MS,
);
});

View File

@ -1,25 +1,11 @@
import { EventEmitter } from 'node:events';
type FunctionWithPrototype = ((...args: unknown[]) => unknown) & {
prototype?: object;
};
const isFreezable = (value: unknown): value is object =>
typeof value === 'object' || typeof value === 'function';
/** Freeze a function and its prototype. */
const freezeFunctionAndPrototype = (fn: FunctionWithPrototype) => {
if (isFreezable(fn.prototype)) {
Object.freeze(fn.prototype);
}
Object.freeze(fn);
};
value !== null && (typeof value === 'object' || typeof value === 'function');
/**
* `EventEmitter.prototype` members that user code must not override.
* `process` inherits these from `EventEmitter.prototype`,
* which lives in an internal module and never appears on `globalThis`,
* so the global freeze misses it.
* `process` inherits these from `EventEmitter.prototype`.
*/
const PROTECTED_EVENT_EMITTER_METHODS = [
'emit',
@ -49,16 +35,94 @@ const lockEventEmitterMethods = () => {
}
};
/**
* Prototypes the walk leaves extensible, guarded by {@link lockEventEmitterMethods} instead.
* Node copies these method names onto stream subclass prototypes, so a freeze breaks those assignments.
*/
const METHOD_LOCKED_PROTOTYPES: readonly object[] = [EventEmitter.prototype];
type IteratorWithHelpers = { map: (fn: (value: unknown) => unknown) => object };
const hasIteratorHelpers = (value: unknown): value is IteratorWithHelpers =>
isFreezable(value) && typeof Reflect.get(value, 'map') === 'function';
type WithEntries = { entries: () => object };
const hasEntries = (value: unknown): value is WithEntries =>
isFreezable(value) && typeof Reflect.get(value, 'entries') === 'function';
/** Prototypes no `globalThis` name leads to: only calling a global produces a value that carries them. */
const unreachablePrototypes = () => {
const timeout = setTimeout(() => {}, 0);
clearTimeout(timeout);
const immediate = setImmediate(() => {});
clearImmediate(immediate);
function* generatorFunction() {}
async function* asyncGeneratorFunction() {}
async function asyncFunction() {}
// Guarded rather than called directly, because the TypeScript lib in use does not type iterator helpers.
const arrayIterator: unknown = [].values();
const iteratorHelper = hasIteratorHelpers(arrayIterator)
? [arrayIterator.map((value) => value)]
: [];
// Guarded too, because the TypeScript lib in use does not type `FormData.entries`.
const formData: unknown = new FormData();
const formDataEntries = hasEntries(formData) ? [formData.entries()] : [];
return [
timeout,
immediate,
generatorFunction,
asyncGeneratorFunction,
asyncFunction,
[][Symbol.iterator](),
''[Symbol.iterator](),
new Map()[Symbol.iterator](),
new Set()[Symbol.iterator](),
...formDataEntries,
...iteratorHelper,
].map((value) => Object.getPrototypeOf(value) as unknown);
};
const collectPrototypeChain = (value: unknown, collected: Set<object>) => {
let current: unknown = value;
while (isFreezable(current) && !collected.has(current)) {
collected.add(current);
current = Object.getPrototypeOf(current);
}
};
/**
* Freeze the globals and shared prototypes reachable from sandboxed code so it
* cannot mutate them to escape secure mode.
* Walks the prototype chain of each root, so intrinsics with no binding of
* their own, such as `%TypedArray%.prototype`, are covered too.
* Skip in tests, where Vitest needs to mutate prototypes.
*/
export const freezeGlobals = () => {
Object.getOwnPropertyNames(globalThis)
const globalFunctions: unknown[] = Object.getOwnPropertyNames(globalThis)
.map((name) => Reflect.get(globalThis, name) as unknown)
.filter((value): value is FunctionWithPrototype => typeof value === 'function')
.forEach(freezeFunctionAndPrototype);
.filter((value) => typeof value === 'function');
const roots = [...globalFunctions, ...unreachablePrototypes()];
const collected = new Set<object>();
for (const root of roots) {
collectPrototypeChain(root, collected);
if (isFreezable(root)) collectPrototypeChain(Reflect.get(root, 'prototype'), collected);
}
for (const prototype of METHOD_LOCKED_PROTOTYPES) {
collected.delete(prototype);
}
collected.forEach(Object.freeze);
[Reflect, JSON, Math].forEach(Object.freeze);

View File

@ -2,6 +2,7 @@ import { CredentialsEntity, type CredentialsRepository } from '@n8n/db';
import { EntityNotFoundError } from '@n8n/typeorm';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { WekanApi } from 'n8n-nodes-base/credentials/WekanApi.credentials';
import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
@ -12,6 +13,7 @@ import type {
INodeTypes,
INodeCredentialsDetails,
IWorkflowExecuteAdditionalData,
IHttpRequestHelper,
} from 'n8n-workflow';
import { deepCopy, Workflow } from 'n8n-workflow';
import { type InstanceSettings, Cipher } from 'n8n-core';
@ -600,4 +602,188 @@ describe('CredentialsHelper', () => {
expect(parsedUpdatedData.oauthTokenData.token_type).toBe('Bearer');
});
});
describe('preAuthentication domain restrictions', () => {
// This layer attaches the policy; the outbound client enforces it.
const wekan = new WekanApi();
const httpRequest = jest.fn();
const helpers = mock<IHttpRequestHelper>({ helpers: { httpRequest } });
const expirableToken: INodeProperties = {
displayName: 'Session Token',
name: 'token',
type: 'hidden',
typeOptions: { expirable: true },
default: '',
};
const selfPolicingApi: ICredentialType = {
name: 'selfPolicingApi',
displayName: 'Self Policing API',
properties: [expirableToken],
async preAuthentication(this: IHttpRequestHelper) {
await this.helpers.httpRequest({
method: 'POST',
url: 'https://other.example/login',
allowedDomains: 'other.example',
});
return { token: 'SESSION_TOKEN' };
},
};
let updateSpy: jest.SpyInstance;
const nodeFor = (type: string): INode => ({
id: 'uuid-1',
name: 'Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: { [type]: { id: 'cred-1', name: 'Credential' } },
});
const wekanCredentials = (overrides: ICredentialDataDecryptedObject = {}) => ({
url: 'https://wekan.example.com',
username: 'admin',
password: 'secret',
token: '',
...overrides,
});
const exchange = async (
overrides: ICredentialDataDecryptedObject = {},
typeName = 'wekanApi',
) =>
await credentialsHelper.preAuthentication(
helpers,
typeName === 'wekanApi' ? wekanCredentials(overrides) : { token: '', ...overrides },
typeName,
nodeFor(typeName),
false,
);
beforeEach(() => {
jest.clearAllMocks();
mockNodesAndCredentials.getCredential
.calledWith('wekanApi')
.mockReturnValue({ type: wekan, sourcePath: '' });
mockNodesAndCredentials.getCredential
.calledWith('selfPolicingApi')
.mockReturnValue({ type: selfPolicingApi, sourcePath: '' });
httpRequest.mockResolvedValue({ token: 'SESSION_TOKEN' });
updateSpy = jest.spyOn(credentialsHelper, 'updateCredentials').mockResolvedValue();
});
afterEach(() => {
updateSpy.mockRestore();
});
test('binds the exchange to the allowlist the credential declares', async () => {
const result = await exchange({
allowedHttpRequestDomains: 'domains',
allowedDomains: 'wekan.example.com',
});
expect(result).toMatchObject({ token: 'SESSION_TOKEN' });
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://wekan.example.com/users/login',
allowedDomains: 'wekan.example.com',
}),
);
});
test('binds the exchange for any host the credential names', async () => {
await exchange({
url: 'https://other.example',
allowedHttpRequestDomains: 'domains',
allowedDomains: 'wekan.example.com',
});
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://other.example/users/login',
allowedDomains: 'wekan.example.com',
}),
);
});
test('leaves the request unrestricted when the credential declares no allowlist', async () => {
await exchange();
expect(httpRequest).toHaveBeenCalledWith({
method: 'POST',
url: 'https://wekan.example.com/users/login',
body: { username: 'admin', password: 'secret' },
});
});
test('still authenticates when the credential is scoped out of HTTP Request nodes', async () => {
const result = await exchange({ allowedHttpRequestDomains: 'none' });
expect(result).toMatchObject({ token: 'SESSION_TOKEN' });
expect(httpRequest).toHaveBeenCalledWith(
expect.not.objectContaining({ allowedDomains: expect.anything() }),
);
});
test('refuses the exchange when the allowlist was left empty', async () => {
await expect(
exchange({ allowedHttpRequestDomains: 'domains', allowedDomains: ' ' }),
).rejects.toThrow('No allowed domains specified');
expect(httpRequest).not.toHaveBeenCalled();
expect(updateSpy).not.toHaveBeenCalled();
});
test('refuses to combine the credential allowlist with one the hook set itself', async () => {
await expect(
exchange(
{ allowedHttpRequestDomains: 'domains', allowedDomains: 'wekan.example.com' },
'selfPolicingApi',
),
).rejects.toThrow(
'The "Self Policing API" credential is restricted to specific domains, which cannot be combined',
);
});
test('leaves that allowlist alone when the credential declares none', async () => {
await exchange({}, 'selfPolicingApi');
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({ allowedDomains: 'other.example' }),
);
});
test('hides request methods it does not wrap', async () => {
const unwrapped = jest.fn();
let reachable: unknown;
const probingApi: ICredentialType = {
name: 'probingApi',
displayName: 'Probing API',
properties: [expirableToken],
// eslint-disable-next-line @typescript-eslint/require-await
async preAuthentication(this: IHttpRequestHelper) {
reachable = (this.helpers as unknown as Record<string, unknown>).request;
return { token: 'SESSION_TOKEN' };
},
};
mockNodesAndCredentials.getCredential
.calledWith('probingApi')
.mockReturnValue({ type: probingApi, sourcePath: '' });
await credentialsHelper.preAuthentication(
{ helpers: { httpRequest, request: unwrapped } } as unknown as IHttpRequestHelper,
{ token: '', allowedHttpRequestDomains: 'domains', allowedDomains: 'wekan.example.com' },
'probingApi',
nodeFor('probingApi'),
false,
);
expect(reachable).toBeUndefined();
expect(unwrapped).not.toHaveBeenCalled();
});
});
});

View File

@ -13,6 +13,7 @@ import type {
IRun,
INodeExecutionData,
INode,
INodeType,
} from 'n8n-workflow';
import { createRunExecutionData } from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
@ -27,6 +28,7 @@ import {
} from '@/executions/pre-execution-checks';
import { ExternalHooks } from '@/external-hooks';
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
import { NodeTypes } from '@/node-types';
import { UrlService } from '@/services/url.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { Telemetry } from '@/telemetry';
@ -37,6 +39,7 @@ import {
getWorkflowData,
} from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
const EXECUTION_ID = '123';
const LAST_NODE_EXECUTED = 'Last node executed';
@ -230,17 +233,31 @@ describe('WorkflowExecuteAdditionalData', () => {
});
describe('credential permission check routing', () => {
const nodeTypes = mockInstance(NodeTypes);
const workflowStaticDataService = mockInstance(WorkflowStaticDataService);
const nodeType = mock<INodeType>({ description: { properties: [] } });
const nodes: INode[] = [
{
id: 'trigger',
name: 'Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
},
];
const subWorkflowData = () =>
mock<IWorkflowBase>({
id: 'sub-id',
name: 'Sub Workflow',
nodes: [],
nodes,
connections: {},
staticData: {},
settings: {},
});
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
jest.mocked(credentialsPermissionChecker.check).mockClear();
jest.mocked(credentialsPermissionChecker.checkForUser).mockClear();
jest.mocked(WorkflowExecute).mockClear();
@ -250,11 +267,7 @@ describe('WorkflowExecuteAdditionalData', () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
{ parentWorkflowId: 'parent-1' },
);
expect(credentialsPermissionChecker.checkForUser).toHaveBeenCalledTimes(1);
@ -282,15 +295,55 @@ describe('WorkflowExecuteAdditionalData', () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
{ parentWorkflowId: 'parent-1' },
);
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
expect(credentialsPermissionChecker.check).toHaveBeenCalledWith('parent-1', nodes);
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
expect(activeExecutions.add.mock.lastCall?.[0].workflowData.id).toBe('parent-1');
expect(processRunExecutionData.mock.lastCall?.[0].id).toBe('parent-1');
});
it('keeps the parent identity for nested inline sub-workflows', async () => {
await executeWorkflow(
{ code: subWorkflowData() },
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
{ parentWorkflowId: 'parent-1' },
);
const [integratedAdditionalData] = jest.mocked(WorkflowExecute).mock.calls[0];
const parentWorkflowId = processRunExecutionData.mock.lastCall?.[0].id;
if (typeof parentWorkflowId !== 'string') throw new Error('Expected a parent workflow ID');
await executeWorkflow({ code: subWorkflowData() }, integratedAdditionalData, {
parentWorkflowId,
});
expect(credentialsPermissionChecker.check).toHaveBeenNthCalledWith(1, 'parent-1', nodes);
expect(credentialsPermissionChecker.check).toHaveBeenNthCalledWith(2, 'parent-1', nodes);
});
it('preserves parent static-data persistence for inline definitions without an id', async () => {
await executeWorkflow(
{ code: mock<IWorkflowBase>({ ...subWorkflowData(), id: undefined }) },
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
{ parentWorkflowId: 'parent-1' },
);
const [integratedAdditionalData, , runExecutionData] =
jest.mocked(WorkflowExecute).mock.calls[0];
if (!integratedAdditionalData.hooks || !runExecutionData) {
throw new Error('Expected sub-workflow hooks and run data');
}
const staticData = { global: { lastProcessedId: 123 } };
await integratedAdditionalData.hooks.runHook('workflowExecuteAfter', [
{ ...runWithData, data: runExecutionData, mode: 'integrated' },
staticData,
]);
expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith(
'parent-1',
staticData,
);
});
it('preserves the triggering user in the sub-workflow additional data for inline sub-workflows so nested inline calls stay scoped to that user', async () => {
@ -414,6 +467,17 @@ describe('WorkflowExecuteAdditionalData', () => {
});
describe('getWorkflowData', () => {
it.each(['embedded-id', undefined])(
'uses the parent identity with inline id %s',
async (id) => {
const workflowCode = mock<IWorkflowBase>({ id, nodes: [], connections: {} });
const result = await getWorkflowData({ code: workflowCode }, 'parent-workflow-id');
expect(result.id).toBe('parent-workflow-id');
},
);
beforeEach(() => {
workflowRepository.get.mockClear();
});
@ -556,7 +620,8 @@ describe('WorkflowExecuteAdditionalData', () => {
const result = await getWorkflowData({ code: workflowCode }, 'parent-workflow-id');
expect(result).toEqual(workflowCode);
expect(result.id).toBe('parent-workflow-id');
expect(result.nodes).toEqual(workflowCode.nodes);
expect(workflowRepository.get).not.toHaveBeenCalled();
});

View File

@ -507,6 +507,210 @@ describe('AuthService', () => {
});
});
describe('createAssetAuthMiddleware', () => {
const mockReq = () =>
mock<AuthenticatedRequest>({
cookies: {},
user: undefined,
browserId,
});
const res = mock<Response>();
const next = jest.fn() as NextFunction;
const tokenWithPayload = (payload: object, options: jwt.SignOptions = { expiresIn: '1h' }) =>
jwtService.sign(payload, options);
it('should 404 if no cookie is set', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = undefined;
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
expect(res.json).not.toHaveBeenCalled();
});
it('should 404 if the token signature does not verify', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = `${validToken}tampered`;
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('should 404 if the token has expired', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken;
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should 404 for a token with an empty payload', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({});
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should 404 for a token whose payload has an id but no hash', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({ id: '123' });
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should 404 for a token whose payload has a hash but no id', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({ hash: 'mJAYx4Wb7k' });
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should 404 for a token whose payload id is not a string', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({ id: 123, hash: 'mJAYx4Wb7k' });
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should 404 for a token whose payload hash is not a string', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({ id: '123', hash: { value: 'x' } });
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should 404 for a token without an expiry whose payload carries no id or hash', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({ sub: '123', scope: 'mcp' }, {});
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
});
it('should call next for a payload carrying a string id and hash', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = tokenWithPayload({ id: '123', hash: 'mJAYx4Wb7k' });
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.sendStatus).not.toHaveBeenCalled();
});
it('should call next for a valid token without any database query', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken;
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.sendStatus).not.toHaveBeenCalled();
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
expect(userRepository.findOne).not.toHaveBeenCalled();
});
it('should call next for a token whose user is gone or whose session was invalidated', () => {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = validToken;
userRepository.findOne.mockResolvedValue(null);
invalidAuthTokenRepository.existsBy.mockResolvedValue(true);
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.sendStatus).not.toHaveBeenCalled();
});
it('should call next with no cookie when preview mode is enabled', () => {
const originalPreviewMode = process.env.N8N_PREVIEW_MODE;
process.env.N8N_PREVIEW_MODE = 'true';
try {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = undefined;
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.sendStatus).not.toHaveBeenCalled();
} finally {
if (originalPreviewMode === undefined) {
delete process.env.N8N_PREVIEW_MODE;
} else {
process.env.N8N_PREVIEW_MODE = originalPreviewMode;
}
}
});
it('should call next with a cookie that fails the check when preview mode is enabled', () => {
const originalPreviewMode = process.env.N8N_PREVIEW_MODE;
process.env.N8N_PREVIEW_MODE = 'true';
try {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = `${validToken}tampered`;
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.sendStatus).not.toHaveBeenCalled();
} finally {
if (originalPreviewMode === undefined) {
delete process.env.N8N_PREVIEW_MODE;
} else {
process.env.N8N_PREVIEW_MODE = originalPreviewMode;
}
}
});
it('should 404 with no cookie when preview mode is unset', () => {
const originalPreviewMode = process.env.N8N_PREVIEW_MODE;
delete process.env.N8N_PREVIEW_MODE;
try {
const req = mockReq();
req.cookies[AUTH_COOKIE_NAME] = undefined;
authService.createAssetAuthMiddleware()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.sendStatus).toHaveBeenCalledWith(404);
} finally {
if (originalPreviewMode === undefined) {
delete process.env.N8N_PREVIEW_MODE;
} else {
process.env.N8N_PREVIEW_MODE = originalPreviewMode;
}
}
});
});
describe('issueCookie', () => {
const res = mock<Response>();
it('should issue a cookie with the correct options', () => {

View File

@ -6,7 +6,7 @@ import type { AuthenticatedRequest, User } from '@n8n/db';
import { GLOBAL_OWNER_ROLE, InvalidAuthTokenRepository, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { createHash } from 'crypto';
import type { NextFunction, Response } from 'express';
import type { NextFunction, Request, Response } from 'express';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { StringValue as TimeUnitValue } from 'ms';
@ -148,6 +148,55 @@ export class AuthService {
};
}
/**
* Gates a route on the auth cookie's signature and expiry, without the revocation
* or user lookups, so a request costs no database query.
*/
createAssetAuthMiddleware() {
return (req: Request, res: Response, next: NextFunction) => {
const token = this.getAssetAuthToken(req);
if (token) {
try {
const payload = this.jwtService.verify<unknown>(token, { algorithms: ['HS256'] });
if (this.isAuthJwtPayload(payload)) {
next();
return;
}
} catch {}
}
if (process.env.N8N_PREVIEW_MODE === 'true') {
next();
return;
}
res.sendStatus(404);
};
}
/**
* Every JWT this instance signs shares one secret, so a valid signature alone does
* not make a token an auth cookie. Only that cookie carries both `id` and `hash`.
*/
private isAuthJwtPayload(payload: unknown): payload is AuthJwtPayload {
return (
typeof payload === 'object' &&
payload !== null &&
typeof (payload as AuthJwtPayload).id === 'string' &&
typeof (payload as AuthJwtPayload).hash === 'string'
);
}
private getAssetAuthToken(req: Request) {
// This models the behavior of an AuthenticatedRequest type having an optional cookies property of type Record<string, string>
if (typeof req.cookies === 'object' && req.cookies !== null) {
const cookies = req.cookies as Record<string, string | undefined>;
return cookies[AUTH_COOKIE_NAME];
}
return undefined;
}
clearCookie(res: Response) {
res.clearCookie(AUTH_COOKIE_NAME);
}

View File

@ -39,6 +39,8 @@ import {
NodeHelpers,
Workflow,
UnexpectedError,
UserError,
getCredentialAllowedDomains,
isExpression,
} from 'n8n-workflow';
@ -59,6 +61,51 @@ const MANAGED_OAUTH_PINNED_FIELDS = [
'signatureMethod', // OAuth1
] as const;
/**
* Applies the credential's allowlist to the requests its `preAuthentication` hook issues.
*
* Only an explicit `'domains'` list applies. `'none'` passes through: that option means
* "block all requests when used in the HTTP Request node" (see the field definition in
* `injectDomainRestrictionFields`), and a credential fetching a token from the service it
* is configured for is not that — refusing here would leave it unable to authenticate at
* all. The sibling call sites in `routing-node` and the HTTP Request node do refuse it,
* because there the request *is* the surface the option names.
*
* Read per request rather than up front so a hook that only transforms token data in
* memory is never failed by a list it never uses.
*/
function restrictToCredentialDomains(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
credentialTypeName: string,
): IHttpRequestHelper {
return {
helpers: {
httpRequest: async (requestOptions: IHttpRequestOptions): Promise<unknown> => {
const allowedDomains = getCredentialAllowedDomains(credentials);
if (allowedDomains === undefined) {
if (credentials.allowedHttpRequestDomains === 'domains') {
throw new UserError(
'No allowed domains specified. Configure allowed domains or change restriction setting.',
);
}
return await helpers.helpers.httpRequest(requestOptions);
}
// A request carries one allowlist, and honouring either side alone could widen
// what the other permits, so refuse rather than pick.
if (requestOptions.allowedDomains !== undefined) {
throw new UserError(
`The "${credentialTypeName}" credential is restricted to specific domains, which cannot be combined with the domains its authentication step requires. Set "Allowed HTTP Request Domains" to "All" on this credential to use it.`,
);
}
return await helpers.helpers.httpRequest({ ...requestOptions, allowedDomains });
},
},
};
}
const mockNode = {
name: '',
typeVersion: 1,
@ -198,7 +245,10 @@ export class CredentialsHelper extends ICredentialsHelper {
credentialsExpired ||
isTestingCredentials
) {
const output = await credentialType.preAuthentication.call(helpers, credentials);
const output = await credentialType.preAuthentication.call(
restrictToCredentialDomains(helpers, credentials, credentialType.displayName),
credentials,
);
// if there is data in the output, make sure the returned
// property is the expirable property

View File

@ -0,0 +1,146 @@
import type { Logger } from '@n8n/backend-common';
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { mock } from 'jest-mock-extended';
import { simpleGit, type SimpleGit } from 'simple-git';
import { SourceControlGitService } from '../source-control-git.service.ee';
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
describe('SourceControlGitService Git synchronization', () => {
let testFolder: string;
let publisherFolder: string;
let worktreeFolder: string;
let publisherGit: SimpleGit;
let gitService: SourceControlGitService;
const tagsPath = () => path.join(publisherFolder, 'tags.json');
const publish = async (message: string) => {
await publisherGit.add(['-A']);
await publisherGit.commit(message);
await publisherGit.push('origin', 'main');
};
beforeEach(async () => {
testFolder = await mkdtemp(path.join(tmpdir(), 'n8n-source-control-git-'));
const remoteFolder = path.join(testFolder, 'remote.git');
publisherFolder = path.join(testFolder, 'publisher');
worktreeFolder = path.join(testFolder, 'worktree');
await mkdir(remoteFolder);
await simpleGit(remoteFolder).init(true);
await mkdir(publisherFolder);
publisherGit = simpleGit(publisherFolder);
await publisherGit.init();
await publisherGit.addConfig('user.name', 'n8n test');
await publisherGit.addConfig('user.email', 'n8n@example.com');
await publisherGit.addConfig('core.symlinks', 'true');
await writeFile(tagsPath(), '{"version":1}\n');
await publisherGit.add(['tags.json']);
await publisherGit.commit('Initial fixture');
await publisherGit.branch(['-M', 'main']);
await publisherGit.addRemote('origin', remoteFolder);
await publisherGit.push('origin', 'main', ['--set-upstream']);
await simpleGit().clone(remoteFolder, worktreeFolder, ['--branch', 'main']);
gitService = new SourceControlGitService(mock<Logger>(), mock(), mock());
gitService.git = simpleGit({
baseDir: worktreeFolder,
config: ['core.symlinks=false'],
trimmed: false,
});
jest.spyOn(gitService, 'fetch').mockImplementation(async () => await gitService.git!.fetch());
});
afterEach(async () => {
await rm(testFolder, { force: true, recursive: true, maxRetries: 3, retryDelay: 100 });
});
it('should update only after the managed tree passes validation', async () => {
await writeFile(tagsPath(), '{"version":2}\n');
await publish('Update regular entry');
await gitService.pull();
await expect(readFile(path.join(worktreeFolder, 'tags.json'), 'utf8')).resolves.toBe(
'{"version":2}\n',
);
const validatedHead = await gitService.git!.revparse('HEAD');
await rm(tagsPath());
await symlink('../outside.json', tagsPath());
await publish('Update managed entry');
await expect(gitService.pull()).rejects.toThrow(
'The remote repository contains an unsupported source control entry.',
);
await expect(gitService.git!.revparse('HEAD')).resolves.toBe(validatedHead);
expect((await lstat(path.join(worktreeFolder, 'tags.json'))).isFile()).toBe(true);
await expect(readFile(path.join(worktreeFolder, 'tags.json'), 'utf8')).resolves.toBe(
'{"version":2}\n',
);
await rm(tagsPath());
await writeFile(tagsPath(), '{"version":3}\n');
await publish('Restore regular entry');
await gitService.pull();
await expect(readFile(path.join(worktreeFolder, 'tags.json'), 'utf8')).resolves.toBe(
'{"version":3}\n',
);
});
it('should reject a directory placed at a managed file path', async () => {
await writeFile(tagsPath(), '{"version":2}\n');
await publish('Seed regular entry');
await gitService.pull();
const validatedHead = await gitService.git!.revparse('HEAD');
await rm(tagsPath());
await mkdir(tagsPath());
await writeFile(path.join(tagsPath(), 'inner.json'), '{"version":3}\n');
await publish('Replace managed file with directory');
await expect(gitService.pull()).rejects.toThrow(
'The remote repository contains an unsupported source control entry.',
);
await expect(gitService.git!.revparse('HEAD')).resolves.toBe(validatedHead);
expect((await lstat(path.join(worktreeFolder, 'tags.json'))).isFile()).toBe(true);
await expect(readFile(path.join(worktreeFolder, 'tags.json'), 'utf8')).resolves.toBe(
'{"version":2}\n',
);
});
it('should reject a file placed at a managed directory path', async () => {
await writeFile(tagsPath(), '{"version":2}\n');
await publish('Seed regular entry');
await gitService.pull();
const validatedHead = await gitService.git!.revparse('HEAD');
await writeFile(path.join(publisherFolder, 'workflows'), 'not a directory\n');
await publish('Add file at managed directory path');
await expect(gitService.pull()).rejects.toThrow(
'The remote repository contains an unsupported source control entry.',
);
await expect(gitService.git!.revparse('HEAD')).resolves.toBe(validatedHead);
});
it('should ignore an unsupported entry outside managed paths', async () => {
const docsFolder = path.join(publisherFolder, 'docs');
await mkdir(docsFolder);
await symlink('../outside.txt', path.join(docsFolder, 'reference'));
await writeFile(tagsPath(), '{"version":2}\n');
await publish('Update repository entries');
await gitService.pull();
await expect(readFile(path.join(worktreeFolder, 'tags.json'), 'utf8')).resolves.toBe(
'{"version":2}\n',
);
});
});

View File

@ -3,6 +3,7 @@ import { mock } from 'jest-mock-extended';
import { simpleGit } from 'simple-git';
import type { SimpleGit } from 'simple-git';
import { SOURCE_CONTROL_MANAGED_DIRECTORIES, SOURCE_CONTROL_MANAGED_PATHS } from '../constants';
import { SourceControlGitService } from '../source-control-git.service.ee';
import type { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
import type { SourceControlPreferences } from '../types/source-control-preferences';
@ -48,6 +49,190 @@ describe('SourceControlGitService', () => {
});
});
const createSynchronizationService = (
outputs: { shape?: string; contents?: string },
logger = mock<ConstructorParameters<typeof SourceControlGitService>[0]>(),
) => {
const gitService = new SourceControlGitService(logger, mock(), mock());
const git = mock<SimpleGit>();
git.branch.mockResolvedValue(MOCK_BRANCHES as never);
git.raw
.mockResolvedValueOnce('abc123\n')
.mockResolvedValueOnce(outputs.shape ?? '')
.mockResolvedValueOnce(outputs.contents ?? '')
.mockResolvedValue('updated');
git.merge.mockResolvedValue(mock());
gitService.git = git;
jest.spyOn(gitService, 'fetch').mockResolvedValue(mock());
return { gitService, git };
};
describe('managed tree validation', () => {
it.each(['100644', '100755'])('should accept a regular file with mode %s', async (mode) => {
const { gitService, git } = createSynchronizationService({
shape: '040000 tree abc123\tworkflows\0',
contents: `${mode} blob abc123\tworkflows/example.json\0`,
});
await expect(gitService.pull()).resolves.toBeDefined();
expect(git.raw).toHaveBeenNthCalledWith(2, [
'ls-tree',
'-z',
'--full-tree',
'abc123',
'--',
...SOURCE_CONTROL_MANAGED_PATHS,
]);
expect(git.raw).toHaveBeenNthCalledWith(3, [
'ls-tree',
'-r',
'-z',
'--full-tree',
'abc123',
'--',
...SOURCE_CONTROL_MANAGED_DIRECTORIES,
]);
});
it.each([
{
outputs: { shape: '120000 blob abc123\ttags.json\0' },
reason: 'Git file mode 120000 is not supported',
rawCalls: 2,
},
{
outputs: { shape: '040000 tree abc123\ttags.json\0' },
reason: 'Git object type tree is not supported',
rawCalls: 2,
},
{
outputs: { shape: '100644 blob abc123\tworkflows\0' },
reason: 'Managed path workflows must be a directory',
rawCalls: 2,
},
{
outputs: {
shape: '040000 tree abc123\tprojects\0',
contents: '160000 commit abc123\tprojects/module\0',
},
reason: 'Git object type commit is not supported',
rawCalls: 3,
},
{
outputs: {
shape: '040000 tree abc123\tworkflows\0',
contents: '100664 blob abc123\tworkflows/example.json\0',
},
reason: 'Git file mode 100664 is not supported',
rawCalls: 3,
},
])(
'should reject an unsupported managed entry ($reason)',
async ({ outputs, reason, rawCalls }) => {
const logger = mock<ConstructorParameters<typeof SourceControlGitService>[0]>();
const { gitService, git } = createSynchronizationService(outputs, logger);
await expect(gitService.pull()).rejects.toThrow(
'The remote repository contains an unsupported source control entry. Update the repository and try again.',
);
expect(git.raw).toHaveBeenCalledTimes(rawCalls);
expect(git.merge).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
'Remote source control tree contains an unsupported managed entry',
{
filePath: expect.any(String),
reason,
},
);
},
);
it('should parse managed paths that contain whitespace', async () => {
const { gitService } = createSynchronizationService({
shape: '040000 tree abc123\tworkflows\0' + '040000 tree def456\tprojects\0',
contents:
'100644 blob abc123\tworkflows/space tab\tline\nbreak.json\0' +
'100755 blob def456\tprojects/project name.json\0',
});
await expect(gitService.pull()).resolves.toBeDefined();
});
it('should ignore entries outside managed paths', async () => {
const { gitService, git } = createSynchronizationService({});
await expect(gitService.pull()).resolves.toBeDefined();
expect(git.raw).toHaveBeenNthCalledWith(2, expect.not.arrayContaining(['docs']));
expect(git.raw).toHaveBeenNthCalledWith(3, expect.not.arrayContaining(['docs']));
});
});
describe('remote updates', () => {
it('should validate before merging the current branch', async () => {
const { gitService, git } = createSynchronizationService({
shape: '100644 blob abc123\ttags.json\0',
});
await gitService.pull();
expect(git.raw).toHaveBeenNthCalledWith(1, [
'rev-parse',
'--verify',
'refs/remotes/origin/master^{commit}',
]);
expect(git.raw).toHaveBeenCalledTimes(3);
expect(git.merge).toHaveBeenCalledWith(['--ff-only', 'abc123']);
});
it('should validate before checking out a branch', async () => {
const { gitService, git } = createSynchronizationService({
shape: '100644 blob abc123\ttags.json\0',
});
await gitService.setBranch('main');
expect(git.raw).toHaveBeenNthCalledWith(4, ['checkout', '-B', 'main', 'abc123']);
expect(git.branch).toHaveBeenCalledWith(['--set-upstream-to=origin/main', 'main']);
});
it('should not check out a branch when validation fails', async () => {
const { gitService, git } = createSynchronizationService({
shape: '120000 blob abc123\ttags.json\0',
});
await expect(gitService.setBranch('main')).rejects.toThrow(
'The remote repository contains an unsupported source control entry.',
);
expect(git.raw).toHaveBeenCalledTimes(2);
expect(git.branch).not.toHaveBeenCalled();
});
it('should validate before resetting to a remote branch', async () => {
const { gitService, git } = createSynchronizationService({
shape: '100644 blob abc123\ttags.json\0',
});
await gitService.resetBranch({ hard: true, target: 'origin/main' });
expect(git.raw).toHaveBeenNthCalledWith(4, ['reset', '--hard', 'abc123']);
});
it('should preserve local hard and soft reset behavior', async () => {
const gitService = new SourceControlGitService(mock(), mock(), mock());
const git = mock<SimpleGit>();
git.raw.mockResolvedValue('reset');
gitService.git = git;
const fetchSpy = jest.spyOn(gitService, 'fetch');
await gitService.resetBranch();
await gitService.resetBranch({ hard: false, target: 'HEAD~1' });
expect(fetchSpy).not.toHaveBeenCalled();
expect(git.raw).toHaveBeenNthCalledWith(1, ['reset', '--hard', 'HEAD']);
expect(git.raw).toHaveBeenNthCalledWith(2, ['reset', 'HEAD~1']);
});
});
describe('initRepository', () => {
describe('when local repo is set up after remote is ready', () => {
it('should track remote', async () => {
@ -58,13 +243,15 @@ describe('SourceControlGitService', () => {
const prefs = mock<SourceControlPreferences>({ branchName: 'main' });
const user = mock<User>();
const git = mock<SimpleGit>();
const checkoutSpy = jest.spyOn(git, 'checkout');
const branchSpy = jest.spyOn(git, 'branch');
gitService.git = git;
jest.spyOn(gitService, 'setGitCommand').mockResolvedValue();
jest
.spyOn(gitService, 'getBranches')
.mockResolvedValue({ currentBranch: '', branches: ['main'] });
const setBranchSpy = jest.spyOn(gitService, 'setBranch').mockResolvedValue({
currentBranch: 'main',
branches: ['main'],
});
/**
* Act
@ -74,8 +261,7 @@ describe('SourceControlGitService', () => {
/**
* Assert
*/
expect(checkoutSpy).toHaveBeenCalledWith('main');
expect(branchSpy).toHaveBeenCalledWith(['--set-upstream-to=origin/main', 'main']);
expect(setBranchSpy).toHaveBeenCalledWith('main');
});
});
@ -192,11 +378,14 @@ describe('SourceControlGitService', () => {
currentBranch: 'master',
branches: ['main', 'develop'],
});
const setBranchSpy = jest.spyOn(gitService, 'setBranch').mockResolvedValue({
currentBranch: 'main',
branches: ['main', 'develop'],
});
await (gitService as any).ensureBranchSetup('main');
expect(git.checkout).toHaveBeenCalledWith('main');
expect(git.branch).toHaveBeenCalledWith(['--set-upstream-to=origin/main', 'main']);
expect(setBranchSpy).toHaveBeenCalledWith('main');
});
it('should not checkout if target branch does not exist on remote', async () => {
@ -245,7 +434,6 @@ describe('SourceControlGitService', () => {
const gitService = new SourceControlGitService(mockLogger, mock(), mock());
const git = mock<SimpleGit>();
git.branch.mockResolvedValue({ current: 'master' } as never);
git.checkout.mockRejectedValue(new Error('Checkout failed'));
gitService.git = git;
jest.spyOn(gitService, 'fetch').mockResolvedValue({} as never);
@ -253,6 +441,7 @@ describe('SourceControlGitService', () => {
currentBranch: 'master',
branches: ['main'],
});
jest.spyOn(gitService, 'setBranch').mockRejectedValue(new Error('Checkout failed'));
// Should not throw
await (gitService as any).ensureBranchSetup('main');
@ -288,7 +477,11 @@ describe('SourceControlGitService', () => {
binary: 'git',
maxConcurrentProcesses: 6,
trimmed: false,
config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'],
config: [
'core.symlinks=false',
`credential.helper=${expectedCredentialScript}`,
'credential.useHttpPath=true',
],
unsafe: { allowUnsafeCredentialHelper: true },
}),
);
@ -315,7 +508,11 @@ describe('SourceControlGitService', () => {
"!f() { echo username='user'\"'\"'; rm -rf /'; echo password='pass'\"'\"'; rm -rf /'; }; f";
expect(simpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'],
config: [
'core.symlinks=false',
`credential.helper=${expectedCredentialScript}`,
'credential.useHttpPath=true',
],
}),
);
});
@ -333,6 +530,7 @@ describe('SourceControlGitService', () => {
expect(simpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['core.symlinks=false'],
unsafe: { allowUnsafeSshCommand: true },
}),
);

View File

@ -108,6 +108,11 @@ describe('SourceControlImportService', () => {
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
expect(globMock).toHaveBeenCalledWith('*.json', {
cwd: '/mock/n8n/git/workflows',
absolute: true,
followSymbolicLinks: false,
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
@ -641,6 +646,11 @@ describe('SourceControlImportService', () => {
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData));
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
expect(globMock).toHaveBeenCalledWith('*.json', {
cwd: '/mock/n8n/git/credential_stubs',
absolute: true,
followSymbolicLinks: false,
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
@ -752,6 +762,11 @@ describe('SourceControlImportService', () => {
const result = await service.getRemoteVariablesFromFile();
expect(result).toEqual(mockVariablesData);
expect(globMock).toHaveBeenCalledWith('variable_stubs.json', {
cwd: '/mock/n8n/git',
absolute: true,
followSymbolicLinks: false,
});
});
it('should return empty array if no variables file found', async () => {
@ -778,6 +793,11 @@ describe('SourceControlImportService', () => {
expect(result.tags).toEqual(mockTagsData.tags);
expect(result.mappings).toEqual(mockTagsData.mappings);
expect(globMock).toHaveBeenCalledWith('tags.json', {
cwd: '/mock/n8n/git',
absolute: true,
followSymbolicLinks: false,
});
});
it('should return empty tags and mappings if no file found', async () => {
@ -866,6 +886,11 @@ describe('SourceControlImportService', () => {
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toEqual(mockFoldersData.folders);
expect(globMock).toHaveBeenCalledWith('folders.json', {
cwd: '/mock/n8n/git',
absolute: true,
followSymbolicLinks: false,
});
});
it('should return empty folders and mappings if no file found', async () => {
@ -1238,6 +1263,11 @@ describe('SourceControlImportService', () => {
const result = await service.getRemoteProjectsFromFiles(globalAdminContext);
// ASSERT
expect(globMock).toHaveBeenCalledWith('*.json', {
cwd: '/mock/n8n/git/projects',
absolute: true,
followSymbolicLinks: false,
});
expect(fsReadFile).toHaveBeenCalledTimes(2);
expect(fsReadFile).toHaveBeenCalledWith(`${mockProjectData1.id}.json`, {
encoding: 'utf8',

View File

@ -7,6 +7,22 @@ export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs';
export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json';
export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json';
export const SOURCE_CONTROL_FOLDERS_EXPORT_FILE = 'folders.json';
// Managed paths that a remote commit must materialize as directories.
export const SOURCE_CONTROL_MANAGED_DIRECTORIES = [
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_PROJECT_EXPORT_FOLDER,
] as const;
// Managed paths that a remote commit must materialize as regular files.
export const SOURCE_CONTROL_MANAGED_FILES = [
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
] as const;
export const SOURCE_CONTROL_MANAGED_PATHS = [
...SOURCE_CONTROL_MANAGED_DIRECTORIES,
...SOURCE_CONTROL_MANAGED_FILES,
] as const;
export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'workflow_owners.json';
export const SOURCE_CONTROL_SSH_FOLDER = 'ssh';
export const SOURCE_CONTROL_SSH_KEY_NAME = 'key';

View File

@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { execSync } from 'child_process';
import { UnexpectedError } from 'n8n-workflow';
import { UnexpectedError, UserError } from 'n8n-workflow';
import path from 'path';
import type {
CommitResult,
@ -21,6 +21,8 @@ import {
SOURCE_CONTROL_DEFAULT_BRANCH,
SOURCE_CONTROL_DEFAULT_EMAIL,
SOURCE_CONTROL_DEFAULT_NAME,
SOURCE_CONTROL_MANAGED_DIRECTORIES,
SOURCE_CONTROL_MANAGED_PATHS,
SOURCE_CONTROL_ORIGIN,
SOURCE_CONTROL_PROJECT_EXPORT_FOLDER,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
@ -121,6 +123,7 @@ export class SourceControlGitService {
this.gitOptions = {
baseDir: gitFolder,
binary: 'git',
config: ['core.symlinks=false'],
maxConcurrentProcesses: 6,
trimmed: false,
};
@ -135,6 +138,7 @@ export class SourceControlGitService {
const httpsGitOptions = {
...this.gitOptions,
config: [
...(this.gitOptions.config ?? []),
'credential.helper=' + credentialScript,
// ensures that the credentials are only used for the configured repositoryUrl of the environment
'credential.useHttpPath=true',
@ -274,13 +278,8 @@ export class SourceControlGitService {
const { currentBranch, branches: remoteBranches } = await this.getBranches();
if (!currentBranch && remoteBranches.some((b) => b === targetBranch)) {
await this.git.checkout(targetBranch);
const upstream = [SOURCE_CONTROL_ORIGIN, targetBranch].join('/');
await this.git.branch([`--set-upstream-to=${upstream}`, targetBranch]);
this.logger.info('Set local git repository to track remote', { upstream });
await this.setBranch(targetBranch);
this.logger.info('Set local git repository to track remote', { targetBranch });
}
}
@ -312,10 +311,8 @@ export class SourceControlGitService {
// If the target branch exists on remote, check it out
if (remoteBranches.includes(targetBranch)) {
try {
await this.git.checkout(targetBranch);
const upstream = [SOURCE_CONTROL_ORIGIN, targetBranch].join('/');
await this.git.branch([`--set-upstream-to=${upstream}`, targetBranch]);
this.logger.info('Recovered source control branch setup', { targetBranch, upstream });
await this.setBranch(targetBranch);
this.logger.info('Recovered source control branch setup', { targetBranch });
} catch (error) {
this.logger.warn('Failed to checkout branch during recovery', { targetBranch, error });
}
@ -358,7 +355,9 @@ export class SourceControlGitService {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (setBranch)');
}
await this.git.checkout(branch);
const { commit } = await this.fetchAndValidateRemoteCommit(branch);
await this.git.raw(['checkout', '-B', branch, commit]);
await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]);
return await this.getBranches();
}
@ -406,16 +405,141 @@ export class SourceControlGitService {
return await this.git.fetch();
}
/**
* Reject a remote commit whose managed paths would materialize the wrong shape
* before applying it. Each managed path is either a directory or a regular file;
* a directory placed at a file path (or the reverse) passes a naive recursive
* scan but breaks the next import/export, so validate the direct entries too.
*/
private async assertManagedTreeContainsOnlyRegularFiles(ref: string): Promise<void> {
if (!this.git) {
throw new UnexpectedError(
'Git is not initialized (assertManagedTreeContainsOnlyRegularFiles)',
);
}
// 1. Validate the shape of each direct managed entry. `ls-tree` without `-r`
// reports the entry itself, so a directory reads as `tree` and a file as
// `blob` instead of being flattened into its contents.
const directShapes = await this.git.raw([
'ls-tree',
'-z',
'--full-tree',
ref,
'--',
...SOURCE_CONTROL_MANAGED_PATHS,
]);
const managedDirectories: readonly string[] = SOURCE_CONTROL_MANAGED_DIRECTORIES;
for (const entry of this.parseTreeEntries(directShapes)) {
let reason: string | undefined;
if (entry.malformed) {
reason = 'Git returned malformed tree metadata';
} else if (managedDirectories.includes(entry.filePath)) {
if (entry.objectType !== 'tree') {
reason = `Managed path ${entry.filePath} must be a directory`;
}
} else {
reason = this.regularFileRejectionReason(entry);
}
if (reason) this.rejectUnsupportedEntry(entry.filePath, reason);
}
// 2. Validate that every entry inside the managed directories is a regular
// file. Recursion here lists leaf blobs only, so symlinks and submodules
// at any depth are rejected.
const directoryContents = await this.git.raw([
'ls-tree',
'-r',
'-z',
'--full-tree',
ref,
'--',
...SOURCE_CONTROL_MANAGED_DIRECTORIES,
]);
for (const entry of this.parseTreeEntries(directoryContents)) {
const reason = entry.malformed
? 'Git returned malformed tree metadata'
: this.regularFileRejectionReason(entry);
if (reason) this.rejectUnsupportedEntry(entry.filePath, reason);
}
}
/** Parse NUL-delimited `ls-tree -z` output into entries. Paths can contain tabs and newlines. */
private parseTreeEntries(output: string) {
return output
.split('\0')
.filter((entry) => entry.length > 0)
.map((entry) => {
const separatorIndex = entry.indexOf('\t');
const metadata = separatorIndex === -1 ? entry : entry.slice(0, separatorIndex);
const filePath = separatorIndex === -1 ? '<unknown>' : entry.slice(separatorIndex + 1);
const [mode, objectType, objectId, ...unexpectedMetadata] = metadata.split(' ');
const malformed =
separatorIndex === -1 ||
!mode ||
!objectType ||
!objectId ||
unexpectedMetadata.length > 0;
return { mode, objectType, objectId, filePath, malformed };
});
}
/** Return why a tree entry is not a supported regular file, or undefined if it is. */
private regularFileRejectionReason(entry: {
mode: string;
objectType: string;
}): string | undefined {
if (entry.objectType !== 'blob') {
return `Git object type ${entry.objectType} is not supported`;
}
if (entry.mode !== '100644' && entry.mode !== '100755') {
// 120000 = symlink; other modes are special git objects
return `Git file mode ${entry.mode} is not supported`;
}
return undefined;
}
private rejectUnsupportedEntry(filePath: string, reason: string): never {
this.logger.error('Remote source control tree contains an unsupported managed entry', {
filePath,
reason,
});
throw new UserError(
'The remote repository contains an unsupported source control entry. Update the repository and try again.',
);
}
private async fetchAndValidateRemoteCommit(
branch?: string,
): Promise<{ branch: string; commit: string }> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (fetchAndValidateRemoteCommit)');
}
await this.fetch();
const resolvedBranch = branch ?? (await this.git.branch()).current;
if (!resolvedBranch) {
throw new UserError('The source control branch is not configured.');
}
const remoteRef = `refs/remotes/${SOURCE_CONTROL_ORIGIN}/${resolvedBranch}`;
const commit = (await this.git.raw(['rev-parse', '--verify', `${remoteRef}^{commit}`])).trim();
await this.assertManagedTreeContainsOnlyRegularFiles(commit);
return { branch: resolvedBranch, commit };
}
async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise<PullResult> {
if (!this.git) {
throw new UnexpectedError('Git is not initialized (pull)');
}
await this.setGitCommand();
const params = {};
if (options.ffOnly) {
Object.assign(params, { '--ff-only': true });
}
return await this.git.pull(params);
// Split pull into fetch + merge so the remote tree can be validated before it touches disk.
const { commit } = await this.fetchAndValidateRemoteCommit();
return await this.git.merge([...(options.ffOnly ? ['--ff-only'] : []), commit]);
}
async push(
@ -456,6 +580,16 @@ export class SourceControlGitService {
throw new UnexpectedError('Git is not initialized (Promise)');
}
if (options?.hard) {
// A remote target writes remote content onto disk, so it needs the same validation as
// pull; a local target (e.g. HEAD) does not.
const remotePrefix = `${SOURCE_CONTROL_ORIGIN}/`;
if (options.target.startsWith(remotePrefix)) {
const branch = options.target.slice(remotePrefix.length);
// Reset to the validated SHA, not the ref, so the ref can't move between validation
// and reset.
const { commit } = await this.fetchAndValidateRemoteCommit(branch);
return await this.git.raw(['reset', '--hard', commit]);
}
return await this.git.raw(['reset', '--hard', options.target]);
}
return await this.git.raw(['reset', options.target]);

View File

@ -173,6 +173,7 @@ export class SourceControlImportService {
const remoteWorkflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
followSymbolicLinks: false,
});
const accessibleProjects =
@ -328,6 +329,7 @@ export class SourceControlImportService {
const remoteCredentialFiles = await glob('*.json', {
cwd: this.credentialExportFolder,
absolute: true,
followSymbolicLinks: false,
});
const accessibleProjects =
@ -434,6 +436,7 @@ export class SourceControlImportService {
const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
followSymbolicLinks: false,
});
if (variablesFile.length > 0) {
this.logger.debug(`Importing variables from file ${variablesFile[0]}`);
@ -457,6 +460,7 @@ export class SourceControlImportService {
const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
followSymbolicLinks: false,
});
if (foldersFile.length > 0) {
this.logger.debug(`Importing folders from file ${foldersFile[0]}`);
@ -512,6 +516,7 @@ export class SourceControlImportService {
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
followSymbolicLinks: false,
});
if (tagsFile.length > 0) {
this.logger.debug(`Importing tags from file ${tagsFile[0]}`);
@ -560,6 +565,7 @@ export class SourceControlImportService {
const remoteProjectFiles = await glob('*.json', {
cwd: this.projectExportFolder,
absolute: true,
followSymbolicLinks: false,
});
const remoteProjects = await Promise.all(

View File

@ -324,13 +324,28 @@ export class Server extends AbstractServer {
}
const maxAge = Time.days.toMilliseconds;
const cacheOptions = inE2ETests || inDevelopment ? {} : { maxAge };
const cacheOptions: { maxAge?: number } = inE2ETests || inDevelopment ? {} : { maxAge };
const { staticCacheDir } = Container.get(InstanceSettings);
this.protectTypeFiles(staticCacheDir);
if (frontendService) {
this.app.use(
const assetAuthMiddleware = Container.get(AuthService).createAssetAuthMiddleware();
const registerAuthenticatedRoutes = (paths: string[], handler: express.RequestHandler) => {
for (const assetPath of paths) {
this.app.get(assetPath, assetAuthMiddleware, handler);
}
};
const respondWithPrivateFile = (res: express.Response, filePath: string, assetMaxAge = 0) => {
const maxAgeSeconds = Math.floor(assetMaxAge * Time.milliseconds.toSeconds);
// `res.sendFile` writes its own `Cache-Control` only when the response carries none.
res.setHeader('Cache-Control', `private, max-age=${maxAgeSeconds}`);
return res.sendFile(filePath, { maxAge: assetMaxAge, dotfiles: 'allow' });
};
registerAuthenticatedRoutes(
[
'/icons/{@:scope/}:packageName/*path/*file.svg',
'/icons/{@:scope/}:packageName/*path/*file.png',
@ -343,7 +358,7 @@ export class Server extends AbstractServer {
if (filePath) {
try {
await fsAccess(filePath);
return res.sendFile(filePath, { maxAge, dotfiles: 'allow' });
return respondWithPrivateFile(res, filePath, maxAge);
} catch {}
}
res.sendStatus(404);
@ -362,12 +377,15 @@ export class Server extends AbstractServer {
if (filePath) {
try {
await fsAccess(filePath);
return res.sendFile(filePath, { ...cacheOptions, dotfiles: 'allow' });
return respondWithPrivateFile(res, filePath, cacheOptions.maxAge);
} catch {}
}
res.sendStatus(404);
};
this.app.use('/schemas/:node/:version{/:resource}{/:operation}.json', serveSchemas);
registerAuthenticatedRoutes(
['/schemas/:node/:version{/:resource}{/:operation}.json'],
serveSchemas,
);
const isTLSEnabled =
this.globalConfig.protocol === 'https' && !!(this.sslKey && this.sslCert);

View File

@ -3,7 +3,7 @@ import type express from 'express';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { generateUrlSignature, prepareUrlForSigning, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core';
import type { IWorkflowBase, Workflow } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION, type IWorkflowBase, type Workflow } from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -303,6 +303,113 @@ describe('WaitingWebhooks', () => {
});
});
describe('token stripping on resume', () => {
const EXAMPLE_HOST = 'example.com';
const resumeUrlPath = '/webhook-waiting/execution-id?foo=bar';
const resumeToken = generateUrlSignature(
prepareUrlForSigning(new URL(resumeUrlPath, `http://${EXAMPLE_HOST}`)),
SIGNING_SECRET,
);
const buildExecution = () =>
mock<IExecutionResponse>({
finished: false,
status: 'waiting',
data: {
validateSignature: true,
executionData: {
nodeExecutionStack: [
{
node: {
id: 'send-and-wait-node-id',
name: 'SendAndWaitNode',
type: 'n8n-nodes-base.wait',
parameters: { operation: SEND_AND_WAIT_OPERATION },
typeVersion: 1,
position: [0, 0],
disabled: false,
},
data: {},
source: null,
},
],
},
resultData: {
lastNodeExecuted: 'SendAndWaitNode',
runData: {
SendAndWaitNode: [{ startTime: 0, executionTime: 0, executionIndex: 0, source: [] }],
},
error: undefined,
},
},
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [
{
id: 'send-and-wait-node-id',
name: 'SendAndWaitNode',
type: 'n8n-nodes-base.wait',
parameters: { operation: SEND_AND_WAIT_OPERATION },
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
settings: {},
staticData: {},
},
});
it('removes the token from the request query and url before the node runs', async () => {
/* Arrange */
executionRepository.findSingleExecution.mockResolvedValue(buildExecution());
webhookService.getNodeWebhooks.mockReturnValue([
{
httpMethod: 'GET',
path: '',
webhookDescription: {
restartWebhook: true,
httpMethod: 'GET',
name: 'default',
path: '',
nodeType: undefined,
} as any,
},
] as any);
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
const executeWebhookSpy = jest
.spyOn(WebhookHelpers, 'executeWebhook')
.mockImplementation(
async (_w, _wd, _wfd, _wsn, _m, _pr, _red, _eid, _req, _res, callback) => {
callback(null, { noWebhookResponse: true });
return undefined;
},
);
const req = mock<WaitingWebhookRequest>({
params: { path: 'execution-id', suffix: undefined },
method: 'GET',
url: `/webhook-waiting/execution-id?foo=bar&${WAITING_TOKEN_QUERY_PARAM}=${resumeToken}`,
host: EXAMPLE_HOST,
headers: { host: EXAMPLE_HOST },
});
req.query = { foo: 'bar', [WAITING_TOKEN_QUERY_PARAM]: resumeToken };
/* Act */
await waitingWebhooks.executeWebhook(req, mock<express.Response>());
/* Assert */
expect(executeWebhookSpy).toHaveBeenCalled();
const [, , , , , , , , calledReq] = executeWebhookSpy.mock.calls[0];
expect((calledReq as express.Request).query).not.toHaveProperty(WAITING_TOKEN_QUERY_PARAM);
expect((calledReq as express.Request).query).toEqual({ foo: 'bar' });
expect((calledReq as express.Request).url).not.toContain(resumeToken);
expect((calledReq as express.Request).url).toBe(resumeUrlPath);
});
});
describe('createWorkflow', () => {
it('should handle old executions with missing activeVersionId field when active=true', () => {
const workflowData = {

View File

@ -460,6 +460,56 @@ describe('WebhookService', () => {
});
});
describe('findWebhook() - path equal to a dynamic template', () => {
test('does not resolve a path that matches a dynamic template', async () => {
const method = 'GET';
const template = 'user/:id/posts';
const webhookId = uuid();
const dynamicWebhook = createWebhook(method, template, webhookId, 3);
webhookRepository.findOneBy.mockResolvedValue(dynamicWebhook);
webhookRepository.findBy.mockResolvedValue([]);
const returnedWebhook = await webhookService.findWebhook(method, template);
expect(returnedWebhook).toBeNull();
});
test('resolves the dynamic row for its advertised webhookId path', async () => {
const method = 'GET';
const template = 'user/:id/posts';
const webhookId = uuid();
const dynamicWebhook = createWebhook(method, template, webhookId, 3);
webhookRepository.findOneBy.mockResolvedValue(null);
webhookRepository.findBy.mockResolvedValue([dynamicWebhook]);
const returnedWebhook = await webhookService.findWebhook(
method,
[webhookId, 'user/123/posts'].join('/'),
);
expect(returnedWebhook).toBe(dynamicWebhook);
});
test('does not serve a dynamic row cached under its bare template', async () => {
const method = 'GET';
const template = 'user/:id/posts';
const webhookId = uuid();
const dynamicWebhook = createWebhook(method, template, webhookId, 3);
cacheService.get.mockResolvedValueOnce(dynamicWebhook);
webhookRepository.create.mockImplementationOnce(
(data) => Object.assign(new WebhookEntity(), data) as WebhookEntity,
);
webhookRepository.findBy.mockResolvedValue([]);
const returnedWebhook = await webhookService.findWebhook(method, template);
expect(returnedWebhook).toBeNull();
});
});
describe('findCached()', () => {
test('should not cache dynamic webhooks', async () => {
const method = 'GET';

View File

@ -126,6 +126,18 @@ export class WaitingWebhooks implements IWebhookManager {
}
}
/**
* Removes the waiting token from the request's query so it never reaches
* the resumed node's own output data.
*/
private stripTokenFromRequest(req: express.Request) {
delete req.query[WAITING_TOKEN_QUERY_PARAM];
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
url.searchParams.delete(WAITING_TOKEN_QUERY_PARAM);
req.url = `${url.pathname}${url.search}`;
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
@ -150,6 +162,8 @@ export class WaitingWebhooks implements IWebhookManager {
res.status(401).json({ error: 'Invalid token' });
return { noWebhookResponse: true };
}
this.stripTokenFromRequest(req);
}
if (!execution) {

View File

@ -62,9 +62,13 @@ export class WebhookService {
cachedStaticWebhook = undefined;
}
if (cachedStaticWebhook) return this.webhookRepository.create(cachedStaticWebhook);
if (cachedStaticWebhook) {
const entity = this.webhookRepository.create(cachedStaticWebhook);
// The cache key is the request path, so a stored entry can hold a dynamic row.
return entity.isDynamic ? null : entity;
}
const dbStaticWebhook = await this.findStaticWebhook(method, path);
const dbStaticWebhook = await this.findStaticWebhookInDb(method, path);
if (dbStaticWebhook) {
try {
@ -83,8 +87,9 @@ export class WebhookService {
/**
* Find a matching webhook with zero dynamic path segments, e.g. `<uuid>` or `user/profile`.
*/
private async findStaticWebhook(method: Method, path: string) {
return await this.webhookRepository.findOneBy({ webhookPath: path, method });
private async findStaticWebhookInDb(method: Method, path: string) {
const webhook = await this.webhookRepository.findOneBy({ webhookPath: path, method });
return webhook?.isDynamic ? null : webhook;
}
/**

View File

@ -134,9 +134,9 @@ export async function getWorkflowData(
} else {
workflowData = workflowInfo.code ?? null;
if (workflowData) {
if (!workflowData.id) {
workflowData.id = parentWorkflowId;
}
// An inline sub-workflow is part of the parent that embeds it, not a
// workflow of its own, so it runs under the parent workflow's id.
workflowData.id = parentWorkflowId;
workflowData.settings ??= parentWorkflowSettings;
}
}

View File

@ -22,7 +22,7 @@ import { Service } from '@n8n/di';
import { In, type EntityManager } from '@n8n/typeorm';
import type { INode, IWorkflowBase, WorkflowId } from 'n8n-workflow';
import {
EXECUTE_WORKFLOW_NODE_TYPE,
isNodeWithWorkflowSelector,
jsonParse,
NodeOperationError,
PROJECT_ROOT,
@ -281,34 +281,47 @@ export class EnterpriseWorkflowService {
/**
* Collect every credential id a node references. Besides the node's own
* `credentials`, an Execute Sub-workflow node with an inline source embeds a
* whole workflow — including its nodes' credentials — inside a single string
* `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.
*
* An inline sub-workflow may itself contain inline sub-workflows, so the walk
* is iterative over an explicit stack: every referenced credential is
* inspected regardless of nesting depth, with no silent cut-off that could let
* a deeply nested reference escape validation. Nesting is inherently bounded by
* 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[] = [];
const stack: INode[] = [node];
if (node.credentials) {
for (const nodeCred of Object.values(node.credentials)) {
const id = nodeCred.id?.toString();
if (id) credentialIds.push(id);
while (stack.length > 0) {
const current = stack.pop()!;
if (current.credentials) {
for (const nodeCred of Object.values(current.credentials)) {
const id = nodeCred.id?.toString();
if (id) credentialIds.push(id);
}
}
}
if (node.type === EXECUTE_WORKFLOW_NODE_TYPE) {
credentialIds.push(...this.getCredentialIdsFromInlineWorkflow(node.parameters?.workflowJson));
if (isNodeWithWorkflowSelector(current)) {
stack.push(...this.getInlineWorkflowNodes(current.parameters?.workflowJson));
}
}
return credentialIds;
}
/**
* Extract the credential ids referenced by the nodes of an inline sub-workflow
* passed as the `workflowJson` parameter of an Execute Sub-workflow node.
* Non-string or unparseable values reference no resolvable credentials and are
* ignored (the node would fail at run time).
* Parse the nodes of an inline sub-workflow passed as the `workflowJson`
* parameter of a workflow-selector node. Non-string or unparseable values
* reference no resolvable credentials and yield no nodes (the node would fail
* at run time).
*/
private getCredentialIdsFromInlineWorkflow(workflowJson: unknown): string[] {
private getInlineWorkflowNodes(workflowJson: unknown): INode[] {
if (typeof workflowJson !== 'string') return [];
let parsed: Partial<IWorkflowBase>;
@ -319,15 +332,7 @@ export class EnterpriseWorkflowService {
}
if (!parsed || !Array.isArray(parsed.nodes)) return [];
const credentialIds: string[] = [];
for (const subNode of parsed.nodes) {
if (!subNode?.credentials) continue;
for (const nodeCred of Object.values(subNode.credentials)) {
const id = nodeCred?.id?.toString();
if (id) credentialIds.push(id);
}
}
return credentialIds;
return parsed.nodes.filter((subNode): subNode is INode => Boolean(subNode));
}
async transferWorkflow(

View File

@ -46,9 +46,14 @@ jest.mock('@/mfa/helpers', () => ({
const MOUNTS = ['without a frontend service', 'with a frontend service'] as const;
type Mount = (typeof MOUNTS)[number];
const ICON_MARKER = 'node-icon-marker';
const SCHEMA_MARKER = 'node-schema-marker';
const BROWSER_ID = 'test-browser-id';
describe('Server static assets', () => {
const httpServers: http.Server[] = [];
const ports = {} as Record<Mount, number>;
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
let authCookie: string;
// Raw `http.request` rather than `supertest`: superagent normalises a path
@ -92,6 +97,15 @@ describe('Server static assets', () => {
writeFileSync(path.join(staticCacheDir, 'public-asset.txt'), 'public');
writeFileSync(path.join(staticCacheDir, 'types-extra.txt'), 'extra');
const resolvedDir = path.join(staticCacheDir, 'resolved');
mkdirSync(resolvedDir);
const iconFilePath = path.join(resolvedDir, 'example.svg');
const schemaFilePath = path.join(resolvedDir, 'example.json');
writeFileSync(iconFilePath, `<svg id="${ICON_MARKER}"></svg>`);
writeFileSync(schemaFilePath, `{"marker":"${SCHEMA_MARKER}"}`);
loadNodesAndCredentials.resolveIcon.mockReturnValue(iconFilePath);
loadNodesAndCredentials.resolveSchema.mockReturnValue(schemaFilePath);
Container.set(Logger, mockLogger());
mockInstance(PostHogClient);
mockInstance(Push);
@ -105,7 +119,7 @@ describe('Server static assets', () => {
await testDb.init();
const owner = await createOwner();
const jwt = Container.get(AuthService).issueJWT(owner, false, 'test-browser-id');
const jwt = Container.get(AuthService).issueJWT(owner, false, BROWSER_ID);
authCookie = `${AUTH_COOKIE_NAME}=${jwt}`;
const globalConfig = Container.get(GlobalConfig);
@ -122,7 +136,7 @@ describe('Server static assets', () => {
for (const mount of MOUNTS) {
const server = new Server(
mock<LoadNodesAndCredentials>(),
loadNodesAndCredentials,
Container.get(PostHogClient),
mock<EventService>(),
instanceSettings,
@ -237,6 +251,156 @@ describe('Server static assets', () => {
});
});
describe('node icons', () => {
const iconPaths = [
'/icons/n8n-nodes-base/dist/nodes/Example/example.svg',
'/icons/n8n-nodes-base/dist/nodes/Example/example.png',
];
const assertProtected = async (requestPath: string) => {
const { statusCode, body, headers } = await get(requestPath, ports[mount]);
expect(statusCode).toBe(404);
expect(body).not.toContain(ICON_MARKER);
expect(headers['cache-control']).toBeUndefined();
};
test.each(iconPaths)(
'requires authentication for %s',
async (requestPath) => await assertProtected(requestPath),
);
test('requires authentication for a scoped package path', async () => {
await assertProtected('/icons/@a-scope/n8n-nodes-example/dist/nodes/Example/example.svg');
});
test.each(['HEAD', 'POST'])(
'requires authentication for %s on an icon path',
async (method) => {
const { statusCode, body } = await request(method, iconPaths[0], ports[mount]);
expect(statusCode).toBe(404);
expect(body).not.toContain(ICON_MARKER);
},
);
test('serves the icon without a session while preview mode is on', async () => {
if (mount !== 'with a frontend service') return;
const unauthenticated = await get(iconPaths[0], ports[mount]);
expect(unauthenticated.statusCode).toBe(404);
expect(unauthenticated.body).not.toContain(ICON_MARKER);
process.env.N8N_PREVIEW_MODE = 'true';
try {
const { statusCode, body } = await get(iconPaths[0], ports[mount]);
expect(statusCode).toBe(200);
expect(body).toContain(ICON_MARKER);
} finally {
delete process.env.N8N_PREVIEW_MODE;
}
});
test.each(iconPaths)(
'serves %s to an authenticated request and marks it private',
async (requestPath) => {
if (mount !== 'with a frontend service') return;
const { statusCode, body, headers } = await get(requestPath, ports[mount], {
cookie: authCookie,
});
expect(statusCode).toBe(200);
expect(body).toContain(ICON_MARKER);
expect(headers['cache-control']).toBe('private, max-age=86400');
},
);
test('sends no cache header when the icon resolves to nothing', async () => {
if (mount !== 'with a frontend service') return;
loadNodesAndCredentials.resolveIcon.mockReturnValueOnce(undefined);
const { statusCode, headers } = await get(iconPaths[0], ports[mount], {
cookie: authCookie,
});
expect(statusCode).toBe(404);
expect(headers['cache-control']).toBeUndefined();
});
});
describe('node schemas', () => {
const schemaPaths = [
'/schemas/exampleNode/1.json',
'/schemas/exampleNode/1/exampleResource/exampleOperation.json',
];
const assertProtected = async (requestPath: string) => {
const { statusCode, body, headers } = await get(requestPath, ports[mount]);
expect(statusCode).toBe(404);
expect(body).not.toContain(SCHEMA_MARKER);
expect(headers['cache-control']).toBeUndefined();
};
test.each(schemaPaths)(
'requires authentication for %s',
async (requestPath) => await assertProtected(requestPath),
);
test.each(schemaPaths)(
'serves %s to an authenticated request and marks it private',
async (requestPath) => {
if (mount !== 'with a frontend service') return;
const { statusCode, body, headers } = await get(requestPath, ports[mount], {
cookie: authCookie,
});
expect(statusCode).toBe(200);
expect(body).toContain(SCHEMA_MARKER);
expect(headers['cache-control']).toBe('private, max-age=86400');
},
);
test('serves the schema without a session while preview mode is on', async () => {
if (mount !== 'with a frontend service') return;
const unauthenticated = await get(schemaPaths[0], ports[mount]);
expect(unauthenticated.statusCode).toBe(404);
expect(unauthenticated.body).not.toContain(SCHEMA_MARKER);
process.env.N8N_PREVIEW_MODE = 'true';
try {
const { statusCode, body } = await get(schemaPaths[0], ports[mount]);
expect(statusCode).toBe(200);
expect(body).toContain(SCHEMA_MARKER);
} finally {
delete process.env.N8N_PREVIEW_MODE;
}
});
test('sends no cache header when the schema resolves to nothing', async () => {
if (mount !== 'with a frontend service') return;
loadNodesAndCredentials.resolveSchema.mockReturnValueOnce(undefined);
const { statusCode, headers } = await get(schemaPaths[0], ports[mount], {
cookie: authCookie,
});
expect(statusCode).toBe(404);
expect(headers['cache-control']).toBeUndefined();
});
});
test.each([
['/public-asset.txt', 'public'],
['/types-extra.txt', 'extra'],

View File

@ -1,5 +1,10 @@
import { WorkflowEntity } from '@n8n/db';
import { EXECUTE_WORKFLOW_NODE_TYPE, type INode } from 'n8n-workflow';
import {
EXECUTE_WORKFLOW_NODE_TYPE,
RETRIEVER_WORKFLOW_LANGCHAIN_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
type INode,
} from 'n8n-workflow';
export const FIRST_CREDENTIAL_ID = '1';
export const SECOND_CREDENTIAL_ID = '2';
@ -9,6 +14,10 @@ const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a';
const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226';
const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da';
const NODE_WITH_INLINE_SUBWORKFLOW_CRED = 'a1f8c2e0-1b2c-4d3e-9f0a-1234567890ab';
const NODE_WITH_WORKFLOW_TOOL_INLINE_CRED = 'b2e9d3f1-2c3d-4e5f-a0b1-234567890abc';
const NODE_WITH_WORKFLOW_RETRIEVER_INLINE_CRED = 'e5b2a6c4-5f60-7182-d3e4-567890abcdef';
const NODE_WITH_NESTED_INLINE_SUBWORKFLOW_CRED = 'c3f0e4a2-3d4e-5f60-b1c2-34567890abcd';
const NODE_WITH_DEEPLY_NESTED_INLINE_CRED = 'd4a1f5b3-4e5f-6071-c2d3-4567890abcde';
const nodeWithNoCredentials: INode = {
id: NODE_WITH_NO_CRED,
@ -82,11 +91,120 @@ const nodeWithInlineSubworkflowCredential: INode = {
},
};
// Builds the inline `workflowJson` of a sub-workflow containing a single
// HTTP Request node that references `credentialId`.
const inlineWorkflowJson = (credentialId: string) =>
JSON.stringify({
nodes: [
{
name: 'Steal',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
parameters: {},
credentials: {
httpHeaderAuth: { id: credentialId, name: 'Inline fake credential' },
},
},
],
connections: {},
});
// Workflow Tool (LangChain) node whose inline `workflowJson` embeds a
// sub-workflow referencing a credential (FIRST_CREDENTIAL_ID) not exposed via
// the node's own top-level `credentials`. Same inline-executor path as the
// Execute Sub-workflow node, so it must be inspected too.
const nodeWithWorkflowToolInlineCredential: INode = {
id: NODE_WITH_WORKFLOW_TOOL_INLINE_CRED,
name: 'Workflow Tool',
typeVersion: 2,
type: WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
position: [0, 0],
parameters: {
source: 'parameter',
workflowJson: inlineWorkflowJson(FIRST_CREDENTIAL_ID),
},
};
// Workflow Retriever node carrying the same inline-workflow credential reference.
const nodeWithWorkflowRetrieverInlineCredential: INode = {
id: NODE_WITH_WORKFLOW_RETRIEVER_INLINE_CRED,
name: 'Workflow Retriever',
typeVersion: 1.1,
type: RETRIEVER_WORKFLOW_LANGCHAIN_NODE_TYPE,
position: [0, 0],
parameters: {
source: 'parameter',
workflowJson: inlineWorkflowJson(FIRST_CREDENTIAL_ID),
},
};
// Execute Sub-workflow node whose inline sub-workflow itself contains another
// Execute Sub-workflow node with an inline sub-workflow referencing
// SECOND_CREDENTIAL_ID — exercises the recursive walk into nested inline
// workflows.
const nodeWithNestedInlineSubworkflowCredential: INode = {
id: NODE_WITH_NESTED_INLINE_SUBWORKFLOW_CRED,
name: 'Execute Sub-workflow (nested)',
typeVersion: 1.2,
type: EXECUTE_WORKFLOW_NODE_TYPE,
position: [0, 0],
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [
{
name: 'Inner Execute Sub-workflow',
type: EXECUTE_WORKFLOW_NODE_TYPE,
typeVersion: 1.2,
parameters: {
source: 'parameter',
workflowJson: inlineWorkflowJson(SECOND_CREDENTIAL_ID),
},
},
],
connections: {},
}),
},
};
// Execute Sub-workflow node whose inline sub-workflow nests `depth` further
// inline Execute Sub-workflow nodes, with the credential reference buried at the
// deepest level. Used to prove the credential collector inspects every level and
// never silently stops before reaching the bottom.
const buildDeeplyNestedInlineNode = (depth: number): INode => {
let workflowJson = inlineWorkflowJson(FIRST_CREDENTIAL_ID);
for (let i = 0; i < depth; i++) {
workflowJson = JSON.stringify({
nodes: [
{
name: `Nested Execute Sub-workflow ${i}`,
type: EXECUTE_WORKFLOW_NODE_TYPE,
typeVersion: 1.2,
parameters: { source: 'parameter', workflowJson },
},
],
connections: {},
});
}
return {
id: NODE_WITH_DEEPLY_NESTED_INLINE_CRED,
name: 'Execute Sub-workflow (deeply nested)',
typeVersion: 1.2,
type: EXECUTE_WORKFLOW_NODE_TYPE,
position: [0, 0],
parameters: { source: 'parameter', workflowJson },
};
};
export function getWorkflow(options?: {
addNodeWithoutCreds?: boolean;
addNodeWithOneCred?: boolean;
addNodeWithTwoCreds?: boolean;
addNodeWithInlineSubworkflowCred?: boolean;
addNodeWithWorkflowToolInlineCred?: boolean;
addNodeWithWorkflowRetrieverInlineCred?: boolean;
addNodeWithNestedInlineSubworkflowCred?: boolean;
addNodeWithDeeplyNestedInlineCred?: boolean;
}) {
const workflow = new WorkflowEntity();
@ -108,5 +226,22 @@ export function getWorkflow(options?: {
workflow.nodes.push(nodeWithInlineSubworkflowCredential);
}
if (options?.addNodeWithWorkflowToolInlineCred) {
workflow.nodes.push(nodeWithWorkflowToolInlineCredential);
}
if (options?.addNodeWithWorkflowRetrieverInlineCred) {
workflow.nodes.push(nodeWithWorkflowRetrieverInlineCredential);
}
if (options?.addNodeWithNestedInlineSubworkflowCred) {
workflow.nodes.push(nodeWithNestedInlineSubworkflowCredential);
}
if (options?.addNodeWithDeeplyNestedInlineCred) {
// 15 levels deep — beyond any small fixed traversal budget.
workflow.nodes.push(buildDeeplyNestedInlineNode(15));
}
return workflow;
}

View File

@ -287,5 +287,61 @@ describe('EnterpriseWorkflowService', () => {
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
test('Should flag a Workflow Tool node referencing an inaccessible credential inside its inline workflow JSON', () => {
const workflow = getWorkflow({ addNodeWithWorkflowToolInlineCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(1);
});
test('Should not flag a Workflow Tool node when the inline credential is accessible', () => {
const workflow = getWorkflow({ addNodeWithWorkflowToolInlineCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
FIRST_CREDENTIAL_ID,
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
test('Should flag a Workflow Retriever node referencing an inaccessible credential inside its inline workflow JSON', () => {
const workflow = getWorkflow({ addNodeWithWorkflowRetrieverInlineCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(1);
});
test('Should not flag a Workflow Retriever node when the inline credential is accessible', () => {
const workflow = getWorkflow({ addNodeWithWorkflowRetrieverInlineCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
FIRST_CREDENTIAL_ID,
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
test('Should flag a node referencing an inaccessible credential inside a nested inline sub-workflow', () => {
const workflow = getWorkflow({ addNodeWithNestedInlineSubworkflowCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(1);
});
test('Should not flag a nested inline sub-workflow when the credential is accessible', () => {
const workflow = getWorkflow({ addNodeWithNestedInlineSubworkflowCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
SECOND_CREDENTIAL_ID,
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
test('Should flag an inaccessible credential buried in a deeply nested inline sub-workflow', () => {
const workflow = getWorkflow({ addNodeWithDeeplyNestedInlineCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(1);
});
test('Should not flag a deeply nested inline sub-workflow when the credential is accessible', () => {
const workflow = getWorkflow({ addNodeWithDeeplyNestedInlineCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
FIRST_CREDENTIAL_ID,
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,123 @@
import { createTestingPinia } from '@pinia/testing';
import type { IBinaryData } from 'n8n-workflow';
import BinaryDataDisplayEmbed from '@/features/ndv/runData/components/BinaryDataDisplayEmbed.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { renderComponent } from '@/__tests__/render';
function createBinaryData(overrides: Partial<IBinaryData> = {}): IBinaryData {
return {
data: 'ZGF0YQ==',
mimeType: 'text/plain',
...overrides,
};
}
describe('BinaryDataDisplayEmbed.vue', () => {
it('should ignore the declared fileType and derive the renderer from mimeType', async () => {
const binaryData = createBinaryData({ mimeType: undefined, fileType: 'image' });
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia: createTestingPinia(),
props: { binaryData },
});
await vi.waitFor(() => {
expect(container.querySelector('img')).not.toBeInTheDocument();
expect(container.querySelector('.binary-data.other')).toBeInTheDocument();
});
});
it('should not treat a mimeType as the type its name starts with', async () => {
const binaryData = createBinaryData({ mimeType: 'application/pdfx', fileType: 'pdf' });
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia: createTestingPinia(),
props: { binaryData },
});
await vi.waitFor(() => {
expect(container.querySelector('.binary-data.pdf')).not.toBeInTheDocument();
expect(container.querySelector('.binary-data.other')).toBeInTheDocument();
});
});
it('should detect image file type from mimeType', async () => {
const binaryData = createBinaryData({ mimeType: 'image/png' });
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia: createTestingPinia(),
props: { binaryData },
});
await vi.waitFor(() => {
expect(container.querySelector('img')).toBeInTheDocument();
});
});
it('should detect JSON file type from a mimeType carrying a charset parameter', async () => {
const binaryData = createBinaryData({
data: btoa('{"a":1}'),
mimeType: 'application/json; charset=utf-8',
});
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia: createTestingPinia(),
props: { binaryData },
});
await vi.waitFor(() => {
expect(container.querySelector('.vjs-tree')).toBeInTheDocument();
});
});
it('should keep an html mimeType that carries a markdown parameter as html', async () => {
const binaryData = createBinaryData({
data: btoa('<div>hi</div>'),
mimeType: 'text/html; x=markdown',
});
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia: createTestingPinia(),
props: { binaryData },
});
await vi.waitFor(() => {
expect(container.querySelector('iframe')).toBeInTheDocument();
});
});
it('should detect text file type from a mimeType carrying parameters', async () => {
const binaryData = createBinaryData({ mimeType: 'text/csv; charset=utf-8' });
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia: createTestingPinia(),
props: { binaryData },
});
await vi.waitFor(() => {
expect(container.querySelector('.binary-data.text')).toBeInTheDocument();
});
});
it('should build the embed source from the stored mimeType, not the declared fileType', async () => {
const binaryData = createBinaryData({
id: 'binary-id',
mimeType: 'application/pdf',
fileType: 'image',
});
const pinia = createTestingPinia();
const workflowsStore = useWorkflowsStore(pinia);
workflowsStore.getBinaryUrl = vi.fn().mockReturnValue('http://test.local/binary');
const { container } = renderComponent(BinaryDataDisplayEmbed, {
pinia,
props: { binaryData },
});
await vi.waitFor(() => {
const embed = container.querySelector('.binary-data.pdf');
expect(embed).toBeInTheDocument();
expect(embed?.getAttribute('src')).toBe('http://test.local/binary');
});
});
});

View File

@ -20,14 +20,37 @@ const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
// The renderer choice always comes from mimeType. fileType is supplied by
// the item's producer and does not steer it. An unrecognised, empty or
// absent mimeType gives `other`, which has no dedicated renderer.
const fileType = computed(() => {
const { mimeType } = props.binaryData;
if (!mimeType) return 'other';
// Match on the base type, so a parameter such as `; charset=utf-8` does not
// stop a type matching. Each single type is compared whole, so a longer name
// such as `application/pdfx` does not match a shorter one.
const baseType = mimeType.split(';')[0].trim();
if (baseType.startsWith('image/')) return 'image';
if (baseType.startsWith('audio/')) return 'audio';
if (baseType.startsWith('video/')) return 'video';
if (baseType === 'application/pdf') return 'pdf';
if (baseType === 'application/json' || baseType === 'text/json') return 'json';
if (baseType === 'text/html') return 'html';
if (baseType === 'text/markdown' || baseType.includes('markdown')) return 'markdown';
if (baseType.startsWith('text/')) return 'text';
return 'other';
});
const embedClass = computed(() => {
return [props.binaryData.fileType ?? 'other'];
return [fileType.value];
});
onMounted(async () => {
const { id, data: binaryData, fileName, fileType, mimeType } = props.binaryData;
const isJSONData = fileType === 'json';
const isHTMLData = fileType === 'html';
const { id, data: binaryData, fileName, mimeType } = props.binaryData;
const isJSONData = fileType.value === 'json';
const isHTMLData = fileType.value === 'html';
if (!id) {
if (isJSONData || isHTMLData) {
data.value = isJSONData
@ -59,22 +82,17 @@ onMounted(async () => {
<div v-if="isLoading">Loading binary data...</div>
<div v-else-if="error">Error loading binary data</div>
<span v-else>
<video v-if="binaryData.fileType === 'video'" controls autoplay>
<video v-if="fileType === 'video'" controls autoplay>
<source :src="embedSource" :type="binaryData.mimeType" />
{{ i18n.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</video>
<audio v-else-if="binaryData.fileType === 'audio'" controls autoplay>
<audio v-else-if="fileType === 'audio'" controls autoplay>
<source :src="embedSource" :type="binaryData.mimeType" />
{{ i18n.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</audio>
<img v-else-if="binaryData.fileType === 'image'" :src="embedSource" />
<VueJsonPretty
v-else-if="binaryData.fileType === 'json'"
:data="data"
:deep="3"
:show-length="true"
/>
<RunDataHtml v-else-if="binaryData.fileType === 'html'" :input-html="data" />
<img v-else-if="fileType === 'image'" :src="embedSource" />
<VueJsonPretty v-else-if="fileType === 'json'" :data="data" :deep="3" :show-length="true" />
<RunDataHtml v-else-if="fileType === 'html'" :input-html="data" />
<embed v-else :src="embedSource" class="binary-data" :class="embedClass" />
</span>
</span>

View File

@ -1,5 +1,5 @@
import type { IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { NodeOperationError, isDomainAllowed } from 'n8n-workflow';
import { createHmac, timingSafeEqual } from 'crypto';
@ -33,10 +33,30 @@ export async function getChannelInfo(this: IWebhookFunctions, channelId: string)
return channel.channel.name;
}
// Slack serves file downloads only from these hosts.
const SLACK_FILE_DOMAINS = '*.slack.com, *.slack-gov.com';
function isSlackFileUrl(url: string): boolean {
try {
return (
new URL(url).protocol === 'https:' &&
isDomainAllowed(url, { allowedDomains: SLACK_FILE_DOMAINS })
);
} catch {
return false;
}
}
export async function downloadFile(this: IWebhookFunctions, url: string): Promise<any> {
if (!isSlackFileUrl(url)) {
throw new NodeOperationError(this.getNode(), 'The file URL is not an HTTPS Slack file URL');
}
let options: IHttpRequestOptions = {
method: 'GET',
url,
allowedDomains: SLACK_FILE_DOMAINS,
sendCredentialsOnCrossOriginRedirect: false,
};
const requestOptions = {

View File

@ -1,5 +1,5 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { verifySignature } from '../SlackTriggerHelpers';
import { downloadFile, verifySignature } from '../SlackTriggerHelpers';
jest.mock('crypto', () => ({
...jest.requireActual('crypto'),
@ -159,4 +159,45 @@ describe('SlackTriggerHelpers', () => {
expect(mockHmac.update).toHaveBeenCalledWith(`v0:${testTimestamp}:${testBody}`);
});
});
describe('downloadFile', () => {
let requestWithAuthentication: jest.Mock;
beforeEach(() => {
requestWithAuthentication = jest.fn().mockResolvedValue(Buffer.from('file'));
mockWebhookFunctions.helpers = { requestWithAuthentication };
});
it.each([
'https://files.slack.com/files-pri/T1-F1/download/test.txt',
'https://files.slack-gov.com/files-pri/T1-F1/download/test.txt',
])(
'should download a file from a Slack host with the Slack host allowlist: %s',
async (url) => {
await downloadFile.call(mockWebhookFunctions, url);
expect(requestWithAuthentication).toHaveBeenCalledWith(
'slackApi',
expect.objectContaining({
url,
allowedDomains: '*.slack.com, *.slack-gov.com',
sendCredentialsOnCrossOriginRedirect: false,
}),
);
},
);
it.each([
'https://example.com/file',
'http://localhost:9111/file',
'http://files.slack.com/file',
'https://files.slack.com.example.com/file',
'https://example.com/?host=files.slack.com',
])('should reject a file URL outside the Slack hosts: %s', async (url) => {
await expect(downloadFile.call(mockWebhookFunctions, url)).rejects.toThrow(
'The file URL is not an HTTPS Slack file URL',
);
expect(requestWithAuthentication).not.toHaveBeenCalled();
});
});
});

View File

@ -53,7 +53,7 @@
"dependencies": {
"@n8n/errors": "workspace:*",
"@n8n/expression-runtime": "workspace:*",
"@n8n/tournament": "1.9.1",
"@n8n/tournament": "1.11.1",
"ast-types": "0.16.1",
"callsites": "catalog:",
"esprima-next": "5.8.4",

View File

@ -94,6 +94,7 @@ export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE =
export const AGENT_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.agentTool';
export const CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode';
export const WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow';
export const RETRIEVER_WORKFLOW_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.retrieverWorkflow';
export const HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolHttpRequest';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const RESPOND_TO_CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat';

View File

@ -50,6 +50,7 @@ export {
randomInt,
randomString,
isSafeObjectProperty,
isUsableObjectKey,
setSafeObjectProperty,
isDomainAllowed,
getCredentialAllowedDomains,

View File

@ -6,7 +6,11 @@ import { ApplicationError } from '@n8n/errors';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import { EXECUTE_WORKFLOW_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE } from './constants';
import {
EXECUTE_WORKFLOW_NODE_TYPE,
RETRIEVER_WORKFLOW_LANGCHAIN_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
} from './constants';
import { isExpression } from './expressions/expression-helpers';
import { NodeConnectionTypes } from './interfaces';
import { safeRegex } from './safe-regex';
@ -1640,7 +1644,11 @@ export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INod
}
export function isNodeWithWorkflowSelector(node: INode) {
return [EXECUTE_WORKFLOW_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE].includes(node.type);
return [
EXECUTE_WORKFLOW_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
RETRIEVER_WORKFLOW_LANGCHAIN_NODE_TYPE,
].includes(node.type);
}
/**

View File

@ -387,6 +387,24 @@ export function isSafeObjectProperty(property: string) {
return !unsafeObjectProperties.has(property);
}
/**
* Checks if a value can be used as an own key of a plain object: it has to be a string,
* and not a name that resolves to an inherited member.
*
* Use this where the key comes from data rather than from code — a node name, or a
* connection field in a stored workflow. `isSafeObjectProperty` is declared
* `(property: string)` but only tests the name, so a value that is not a string at
* runtime passes it and is then coerced by the bracket access that follows:
* `obj[['__proto__']]` writes to `obj['__proto__']`.
*
* It is a separate function on purpose. `isSafeObjectProperty` is also called with keys
* that are legitimately not strings, such as the numeric literal in `items[0]` while an
* expression is sanitised, so it cannot require a string itself.
*/
export function isUsableObjectKey(value: unknown): value is string {
return typeof value === 'string' && isSafeObjectProperty(value);
}
const unsafeObjectPropertyTokenPattern = new RegExp(
`\\b(?:${[...unsafeObjectProperties]
.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))

View File

@ -1,164 +0,0 @@
diff --git a/dist/ExpressionBuilder.js b/dist/ExpressionBuilder.js
index 64a15f6aafe12fa6252b59afc686c42964ddc67e..65fe5eb5bc082f9ba08ada6737f355d554716342 100644
--- a/dist/ExpressionBuilder.js
+++ b/dist/ExpressionBuilder.js
@@ -76,7 +76,7 @@ const maybeWrapExpr = (expr) => {
const buildFunctionBody = (expr) => {
return ast_types_1.builders.blockStatement([
ast_types_1.builders.expressionStatement(ast_types_1.builders.assignmentExpression('=', v, expr)),
- ast_types_1.builders.returnStatement(ast_types_1.builders.conditionalExpression(ast_types_1.builders.logicalExpression('||', ast_types_1.builders.logicalExpression('||', v, ast_types_1.builders.binaryExpression('===', v, ast_types_1.builders.literal(0))), ast_types_1.builders.binaryExpression('===', v, ast_types_1.builders.literal(false))), v, ast_types_1.builders.literal(''))),
+ ast_types_1.builders.returnStatement(ast_types_1.builders.conditionalExpression(ast_types_1.builders.logicalExpression('||', ast_types_1.builders.logicalExpression('||', v, ast_types_1.builders.binaryExpression('===', v, ast_types_1.builders.literal(0))), ast_types_1.builders.binaryExpression('===', v, ast_types_1.builders.literal(false))), v, (0, VariablePolyfill_1.rawStringLiteral)(''))),
]);
};
const fixStringNewLines = (node) => {
@@ -129,7 +129,7 @@ const getExpressionCode = (expr, dataNodeName, hooks) => {
let parts = [];
for (const chunk of chunks) {
if (chunk.type === 'text') {
- parts.push(ast_types_1.builders.literal(chunk.text));
+ parts.push((0, VariablePolyfill_1.rawStringLiteral)(chunk.text));
}
else {
const fixed = fixStringNewLines(chunk.parsed);
@@ -160,7 +160,7 @@ const getExpressionCode = (expr, dataNodeName, hooks) => {
else {
parts = parts.filter((i) => !(i.type === 'Literal' && i.value === ''));
newProg.body.push(ast_types_1.builders.returnStatement(ast_types_1.builders.callExpression(ast_types_1.builders.memberExpression(ast_types_1.builders.arrayExpression(parts), ast_types_1.builders.identifier('join')), [
- ast_types_1.builders.literal(''),
+ (0, VariablePolyfill_1.rawStringLiteral)(''),
])));
}
}
diff --git a/dist/VariablePolyfill.d.ts b/dist/VariablePolyfill.d.ts
index b260c8d497dd35ab28487984e445bd2b37d198d1..c32a78680b5adf5a488b4722ebace284a49f24ad 100644
--- a/dist/VariablePolyfill.d.ts
+++ b/dist/VariablePolyfill.d.ts
@@ -1,6 +1,7 @@
import type { namedTypes } from 'ast-types';
import type { StatementKind } from 'ast-types/lib/gen/kinds';
import type { types } from 'recast';
+export declare const rawStringLiteral: (value: string) => namedTypes.Literal;
export declare const globalIdentifier: namedTypes.Identifier;
export type DataNode = namedTypes.ThisExpression | namedTypes.Identifier;
export declare const jsVariablePolyfill: (ast: types.namedTypes.File, dataNode: DataNode) => StatementKind[] | undefined;
diff --git a/dist/VariablePolyfill.js b/dist/VariablePolyfill.js
index 75b4a03da26b04b07cd06fb2fa4b57552eff3119..b603a9bbc724b33e5de232901d028432cb03382e 100644
--- a/dist/VariablePolyfill.js
+++ b/dist/VariablePolyfill.js
@@ -1,15 +1,34 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
-exports.jsVariablePolyfill = exports.globalIdentifier = void 0;
+exports.jsVariablePolyfill = exports.globalIdentifier = exports.rawStringLiteral = void 0;
const ast_types_1 = require("ast-types");
const recast_1 = require("recast");
const Constants_1 = require("./Constants");
function assertNever(_value) {
return true;
}
+// Captured at module load so printing stays stable even if the global is later replaced.
+const safeStringify = JSON.stringify;
+// A string literal carrying its own printed form. recast's printer emits `extra.raw`
+// verbatim (when it matches the value) instead of stringifying at print time.
+const rawStringLiteral = (value) => {
+ const literal = ast_types_1.builders.literal(value);
+ // JSON.stringify leaves U+2028/U+2029 unescaped; since the raw form is printed
+ // verbatim, escape them so the emitted literal stays single-line and valid.
+ const raw = safeStringify(value)
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029');
+ // Attach `extra` in place so the ast-types node identity is preserved.
+ literal.extra = {
+ raw,
+ rawValue: value,
+ };
+ return literal;
+};
+exports.rawStringLiteral = rawStringLiteral;
exports.globalIdentifier = ast_types_1.builders.identifier(typeof window !== 'object' ? 'global' : 'window');
const buildGlobalSwitch = (node, dataNode) => {
- return ast_types_1.builders.memberExpression(ast_types_1.builders.conditionalExpression(ast_types_1.builders.binaryExpression('in', ast_types_1.builders.literal(node.name), dataNode), dataNode, exports.globalIdentifier), ast_types_1.builders.identifier(node.name));
+ return ast_types_1.builders.memberExpression(ast_types_1.builders.conditionalExpression(ast_types_1.builders.binaryExpression('in', rawStringLiteral(node.name), dataNode), dataNode, exports.globalIdentifier), ast_types_1.builders.identifier(node.name));
};
const isInScope = (path) => {
let scope = path.scope;
diff --git a/src/ExpressionBuilder.ts b/src/ExpressionBuilder.ts
index df0b6e168eeec22fa41a582f8b31c99d727cba84..c7c51807a92796d80204ba14625568636fa1a611 100644
--- a/src/ExpressionBuilder.ts
+++ b/src/ExpressionBuilder.ts
@@ -8,7 +8,7 @@ import type { TournamentHooks } from './ast';
import { splitExpression } from './ExpressionSplitter';
import type { ExpressionCode, ExpressionText } from './ExpressionSplitter';
import { parseWithEsprimaNext } from './Parser';
-import { globalIdentifier, jsVariablePolyfill } from './VariablePolyfill';
+import { globalIdentifier, jsVariablePolyfill, rawStringLiteral } from './VariablePolyfill';
import type { DataNode } from './VariablePolyfill';
export interface ExpressionAnalysis {
@@ -123,7 +123,7 @@ const buildFunctionBody = (expr: ExpressionKind) => {
b.binaryExpression('===', v, b.literal(false)),
),
v,
- b.literal(''),
+ rawStringLiteral(''),
),
),
]);
@@ -211,7 +211,7 @@ export const getExpressionCode = (
for (const chunk of chunks) {
// This is just a text chunks, push it up as a literal.
if (chunk.type === 'text') {
- parts.push(b.literal(chunk.text));
+ parts.push(rawStringLiteral(chunk.text));
// This is a code chunk so do some magic
} else {
const fixed = fixStringNewLines(chunk.parsed);
@@ -264,7 +264,7 @@ export const getExpressionCode = (
newProg.body.push(
b.returnStatement(
b.callExpression(b.memberExpression(b.arrayExpression(parts), b.identifier('join')), [
- b.literal(''),
+ rawStringLiteral(''),
]),
),
);
diff --git a/src/VariablePolyfill.ts b/src/VariablePolyfill.ts
index e716ec9e446fa075ffce1b4487eb8f615a62d314..31adc56c49e0a89801f934784a28fcd79c0cdf34 100644
--- a/src/VariablePolyfill.ts
+++ b/src/VariablePolyfill.ts
@@ -13,6 +13,26 @@ function assertNever(_value: never): _value is never {
return true;
}
+// Captured at module load so printing stays stable even if the global is later replaced.
+const safeStringify = JSON.stringify;
+
+// A string literal carrying its own printed form. recast's printer emits `extra.raw`
+// verbatim (when it matches the value) instead of stringifying at print time.
+export const rawStringLiteral = (value: string) => {
+ const literal = b.literal(value);
+ // JSON.stringify leaves U+2028/U+2029 unescaped; since the raw form is printed
+ // verbatim, escape them so the emitted literal stays single-line and valid.
+ const raw = safeStringify(value)
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029');
+ // Attach `extra` in place so the ast-types node identity is preserved.
+ (literal as namedTypes.Literal & { extra?: { raw: string; rawValue: string } }).extra = {
+ raw,
+ rawValue: value,
+ };
+ return literal;
+};
+
export const globalIdentifier = b.identifier(
// @ts-expect-error window not in lib target
typeof window !== 'object' ? 'global' : 'window',
@@ -21,7 +41,7 @@ export const globalIdentifier = b.identifier(
const buildGlobalSwitch = (node: types.namedTypes.Identifier, dataNode: DataNode) => {
return b.memberExpression(
b.conditionalExpression(
- b.binaryExpression('in', b.literal(node.name), dataNode),
+ b.binaryExpression('in', rawStringLiteral(node.name), dataNode),
dataNode,
globalIdentifier,
),

View File

@ -292,9 +292,6 @@ patchedDependencies:
'@lezer/highlight':
hash: 97f85e6fe46f23015ea0dd420e33d584bc2dc71633910cf131321da31b27ca8c
path: patches/@lezer__highlight.patch
'@n8n/tournament@1.9.1':
hash: c946603b884abb5d26bad2f200f57c63574a1e48b134625295c712a92c137567
path: patches/@n8n__tournament@1.9.1.patch
'@types/express-serve-static-core@5.0.6':
hash: d602248fcd302cf5a794d1e85a411633ba9635ea5d566d6f2e0429c7ae0fa3eb
path: patches/@types__express-serve-static-core@5.0.6.patch
@ -959,8 +956,8 @@ importers:
specifier: workspace:*
version: link:../errors
'@n8n/tournament':
specifier: 1.9.1
version: 1.9.1(patch_hash=c946603b884abb5d26bad2f200f57c63574a1e48b134625295c712a92c137567)
specifier: 1.11.1
version: 1.11.1
isolated-vm:
specifier: 'catalog:'
version: 6.2.0
@ -3422,8 +3419,8 @@ importers:
specifier: workspace:*
version: link:../@n8n/expression-runtime
'@n8n/tournament':
specifier: 1.9.1
version: 1.9.1(patch_hash=c946603b884abb5d26bad2f200f57c63574a1e48b134625295c712a92c137567)
specifier: 1.11.1
version: 1.11.1
ast-types:
specifier: 0.16.1
version: 0.16.1
@ -6460,8 +6457,8 @@ packages:
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
engines: {node: '>=18'}
'@n8n/tournament@1.9.1':
resolution: {integrity: sha512-cqlvqs1mrIsU4FyVvzLz9n8tFNfglHXwTfRNoZ/upDMahoNE2faitj/eB5HFS72gCNoS7M1/HuoA1qfv4zpt+Q==}
'@n8n/tournament@1.11.1':
resolution: {integrity: sha512-URiQPKXNHSOvNrCKCw4IxsSPdOju+Fcmihq4Syv1cUpypQATt1GqJxzc7jFJo6KS9xCK/4573rge+tp4Jk9b2A==}
'@n8n/typeorm@0.3.20-15':
resolution: {integrity: sha512-qTksdwNypUElt3gYwPejy5Nl3MZGmex1/5BXE8AqZZXjjapCfw6xu0mwJ9JkO+WR0BhvA6DQWni4RCc6t9HN2Q==}
@ -22793,7 +22790,7 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
'@n8n/tournament@1.9.1(patch_hash=c946603b884abb5d26bad2f200f57c63574a1e48b134625295c712a92c137567)':
'@n8n/tournament@1.11.1':
dependencies:
ast-types: 0.16.1
esprima-next: 5.8.4
@ -26724,6 +26721,16 @@ snapshots:
- debug
- supports-color
axios@1.18.0(debug@4.4.3):
dependencies:
follow-redirects: 1.16.0(debug@4.4.3)
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
b4a@1.6.7: {}
babel-jest@29.6.2(@babel/core@7.28.4):
@ -30150,7 +30157,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.21
'@types/tough-cookie': 4.0.5
axios: 1.18.0(debug@4.4.1)
axios: 1.18.0(debug@4.4.3)
camelcase: 6.3.0
debug: 4.4.3
dotenv: 16.6.1
@ -30160,7 +30167,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.3
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.18.0(debug@4.4.3))
retry-axios: 2.6.0(axios@1.18.0)
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@ -34529,7 +34536,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.18.0(debug@4.4.3)):
retry-axios@2.6.0(axios@1.18.0):
dependencies:
axios: 1.18.0(debug@4.4.1)