mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
parent
205a47b0ad
commit
ab0b5351ec
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export {
|
|||
randomInt,
|
||||
randomString,
|
||||
isSafeObjectProperty,
|
||||
isUsableObjectKey,
|
||||
setSafeObjectProperty,
|
||||
isDomainAllowed,
|
||||
getCredentialAllowedDomains,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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, '\\$&'))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user