chore: Bundle/1.x (#34679)

Co-authored-by: Charlie Kolb <[email protected]>
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: yehorkardash <[email protected]>
Co-authored-by: Mike Repeć <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Bernhard Wittmann <[email protected]>
Co-authored-by: RomanDavydchuk <[email protected]>
Co-authored-by: Yuliia Pominchuk <[email protected]>
Co-authored-by: Yuliia Pominchuk <[email protected]>
Co-authored-by: Guillaume Jacquart <[email protected]>
This commit is contained in:
n8n-assistant[bot]
2026-07-22 09:03:28 +02:00
committed by GitHub
co-authored by Charlie Kolb yehorkardash Mike Repeć Claude Opus 4.8 Bernhard Wittmann RomanDavydchuk Yuliia Pominchuk Yuliia Pominchuk Guillaume Jacquart
parent 205a227df8
commit 1cd2c5fb22
44 changed files with 2956 additions and 450 deletions
+2 -1
View File
@@ -177,7 +177,8 @@
"minifaker": "patches/minifaker.patch",
"z-vue-scan": "patches/z-vue-scan.patch",
"@lezer/highlight": "patches/@lezer__highlight.patch",
"v-code-diff": "patches/v-code-diff.patch"
"v-code-diff": "patches/v-code-diff.patch",
"[email protected]": "patches/[email protected]"
}
}
}
@@ -377,6 +377,72 @@ describe('Integration: ExpressionEvaluator + IsolatedVmBridge', () => {
expect(result).toBe('name,age,city');
});
it('should not leak host references through non-index array access', () => {
// Reading a non-index key like $json.a.constructor must return undefined,
// so a guest can't walk from an array to the host Object prototype.
const data = { $json: { a: [1337] } };
const expression = `{{
(function() {
const hostLookupParams = (() => eval)()(\`(args, res) => {
const _copy = {copy: true};
const _reference = {reference: true};
const params = {};
params.arguments = args === 'copy' ? _copy : _reference;
params.result = res === 'copy' ? _copy : _reference;
return params;
}\`);
const refs = (() => eval)()(\`
(function () { return arguments.callee.caller.caller.caller.arguments })()
\`);
const getArrayElement = refs[1];
var log = [];
function t(label, fn) {
try {
const result = fn();
log.push(label + '=' + result);
return result;
} catch (e) {
log.push(label + ':ERR ' + String(e && e.message ? e.message : e));
}
}
const hostArrayPath = ['$json', 'a'];
const HostArray = t('host array', function() {
return getArrayElement.applySync(null, [hostArrayPath, 'constructor'], hostLookupParams('copy', 'reference'));
});
const HostLookupGetter = t('host lookup getter', function() {
return getArrayElement.applySync(null, [hostArrayPath, '__lookupGetter__'], hostLookupParams('copy', 'reference'));
});
const hostArrayInstance = t('host array instance', function() {
return HostArray.applySync(null, [1], hostLookupParams('copy', 'reference'));
});
const HostProtoGetter = t('host __proto__ getter', function() {
return HostLookupGetter.applySync(hostArrayInstance.derefInto(), ['__proto__'], hostLookupParams('copy', 'reference'));
});
const arrProto = t('host Array prototype', function() {
return HostProtoGetter.applySync(hostArrayInstance.derefInto(), [], hostLookupParams('copy', 'reference'));
});
const objProto = t('host Object prototype', function() {
return HostProtoGetter.applySync(arrProto.derefInto(), [], hostLookupParams('copy', 'reference'));
});
objProto.setSync('win', 1337);
return log;
})();
}}`;
try {
// The bridge swallows the TypeError; what matters is the host Object
// prototype was never mutated.
evaluator.evaluate(expression, data, caller);
expect((Object.prototype as Record<string, unknown>).win).toBeUndefined();
} finally {
delete (Object.prototype as Record<string, unknown>).win;
}
});
it('should preserve error name, message, and custom properties across isolate boundary', () => {
const data = { $json: {} };
@@ -400,8 +400,21 @@ export class IsolatedVmBridge implements RuntimeBridge {
return undefined;
}
// Only genuine array indices are reachable; anything else (e.g.
// 'constructor', '__lookupGetter__') would read off the prototype
// chain and could leak a host function reference across the boundary.
if (!Number.isInteger(index) || index < 0) {
return undefined;
}
const element = arr[index];
// Functions are never reachable through the data surface — mirror the
// guard in getValueAtPath so a host callable can't cross the boundary.
if (typeof element === 'function') {
return undefined;
}
// Dates have no enumerable own keys; pass through instead of
// marshaling as an empty object.
if (element instanceof Date) {
@@ -1,7 +1,13 @@
import type * as nodeCrypto from 'node:crypto';
import { ExecutionError } from '@/js-task-runner/errors/execution-error';
import { DisallowedModuleError } from '../errors/disallowed-module.error';
import { createRequireResolver, type RequireResolverOpts } from '../require-resolver';
import {
createRequireResolver,
secureModuleExport,
type RequireResolverOpts,
} from '../require-resolver';
describe('require resolver', () => {
let defaultOpts: RequireResolverOpts;
@@ -61,6 +67,194 @@ describe('require resolver', () => {
});
});
describe('module securing', () => {
it('should not let one resolution mutate the shared module for another', () => {
const resolver = createRequireResolver({ ...defaultOpts, secureModules: true });
const first = resolver('path') as { normalize: unknown };
const original = first.normalize;
// Own-property reassignment on the shared cached module must not stick.
expect(() => {
'use strict';
first.normalize = () => 'poisoned';
}).toThrow();
const second = resolver('path') as { normalize: unknown };
expect(second.normalize).toBe(original);
expect((second.normalize as (p: string) => string)('a//b')).toBe('a/b');
});
it('should return the same view for repeated resolutions (stable identity)', () => {
const resolver = createRequireResolver({ ...defaultOpts, secureModules: true });
expect(resolver('path')).toBe(resolver('path'));
});
it('should not secure modules by default', () => {
const resolver = createRequireResolver(defaultOpts);
const fs = resolver('fs') as Record<string, unknown>;
const marker = () => 'noop';
fs.__probe = marker;
expect(fs.__probe).toBe(marker);
delete fs.__probe;
});
});
describe('secureModuleExport', () => {
it('should not throw when securing a non-freezable Buffer/TypedArray export', () => {
// Object.freeze throws on these ("Cannot freeze array buffer views with elements").
const buf = Buffer.from([1, 2, 3]);
const view = secureModuleExport(buf) as Buffer;
expect(view[0]).toBe(1); // reads pass through
expect(() => {
'use strict';
view[0] = 9;
}).toThrow(); // writes are blocked
expect(buf[0]).toBe(1); // underlying buffer untouched
});
it('should block assignment through accessor setters', () => {
let sideEffect = 0;
const mod = {};
Object.defineProperty(mod, 'danger', {
get: () => sideEffect,
set: (v: number) => {
sideEffect = v;
},
configurable: true,
});
const secured = secureModuleExport(mod) as { danger: number };
// Strict-mode module code: the blocked write throws instead of the setter running.
expect(() => {
secured.danger = 99;
}).toThrow();
expect(sideEffect).toBe(0); // the real setter never ran
});
it('should block adding, redefining, deleting properties and swapping the prototype', () => {
const mod: Record<string, unknown> = { existing: 1 };
const secured = secureModuleExport(mod) as Record<string, unknown>;
expect(() => Object.defineProperty(secured, 'added', { value: 1 })).toThrow();
expect(Reflect.deleteProperty(secured, 'existing')).toBe(false);
expect(Reflect.setPrototypeOf(secured, {})).toBe(false);
expect(mod.existing).toBe(1);
});
it('should keep a real module callable and usable through the view', () => {
const resolver = createRequireResolver({
allowedBuiltInModules: new Set(['crypto']),
allowedExternalModules: new Set(),
secureModules: true,
});
const crypto = resolver('crypto') as typeof nodeCrypto;
expect(crypto.randomBytes(8)).toHaveLength(8);
expect(crypto.createHash('sha256').update('x').digest('hex')).toHaveLength(64);
// poisoning attempts must not stick
expect(() => {
(crypto as { randomBytes: unknown }).randomBytes = () => Buffer.from('PWNED');
}).toThrow();
expect(crypto.randomBytes(8)).toHaveLength(8);
});
});
describe('secureModuleExport (nested membrane)', () => {
it('should wrap nested objects and block writes to them', () => {
const shared = { count: 0 };
const secured = secureModuleExport({ nested: shared }) as { nested: { count: number } };
expect(() => {
secured.nested.count = 5;
}).toThrow();
expect(shared.count).toBe(0); // shared nested state untouched
});
it('should return a stable wrapped view for the same nested object', () => {
const secured = secureModuleExport({ nested: {} }) as { nested: object };
expect(secured.nested).toBe(secured.nested);
});
it('should freeze (not wrap) non-configurable non-writable data properties', () => {
const mod = {};
Object.defineProperty(mod, 'FIXED', {
value: { a: 1 },
writable: false,
configurable: false,
});
const secured = secureModuleExport(mod) as { FIXED: { a: number } };
// Proxy invariant forbids wrapping, so it's returned raw — but frozen,
// so it still can't be mutated for other tasks.
expect(secured.FIXED.a).toBe(1);
expect(Object.isFrozen(secured.FIXED)).toBe(true);
expect(() => {
(secured.FIXED as Record<string, unknown>).evil = 1;
}).toThrow();
});
it('should wrap values reached through descriptor reflection', () => {
const shared = { count: 0 };
const secured = secureModuleExport({ nested: shared });
const descriptor = Object.getOwnPropertyDescriptor(secured, 'nested');
const reflected = descriptor?.value as { count: number };
expect(() => {
reflected.count = 5;
}).toThrow();
expect(shared.count).toBe(0); // reflection can't bypass the membrane
});
it('should construct classes exposed by a module', () => {
const secured = secureModuleExport({
widget: class {
constructor(public v: number) {}
},
}) as { widget: new (v: number) => { v: number } };
expect(new secured.widget(7).v).toBe(7);
});
it('should preserve new.target when subclassing a module constructor', () => {
const secured = secureModuleExport({
base: class {
kind: string;
constructor() {
this.kind = new.target.name;
}
},
}) as { base: new () => { kind: string } };
class Derived extends secured.base {}
expect(new Derived().kind).toBe('Derived');
});
it('should keep nested real-module state readable but not mutable', () => {
const resolver = createRequireResolver({
allowedBuiltInModules: new Set(['crypto']),
allowedExternalModules: new Set(),
secureModules: true,
});
const crypto = resolver('crypto') as typeof nodeCrypto;
// nested reads and receiver-sensitive method calls still work
expect(typeof crypto.constants.RSA_PKCS1_PADDING).toBe('number');
expect(crypto.webcrypto.getRandomValues(new Uint8Array(4))).toHaveLength(4);
// nested writes are blocked
expect(() => {
(crypto.webcrypto as { getRandomValues: unknown }).getRandomValues = () => null;
}).toThrow();
expect(crypto.webcrypto.getRandomValues(new Uint8Array(2))).toHaveLength(2);
});
});
describe('error handling', () => {
it('should wrap DisallowedModuleError in ExecutionError', () => {
const resolver = createRequireResolver(defaultOpts);
@@ -154,6 +154,7 @@ export class JsTaskRunner extends TaskRunner {
this.requireResolver = createRequireResolver({
allowedBuiltInModules,
allowedExternalModules,
secureModules: this.mode === 'secure',
});
if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules);
@@ -15,13 +15,105 @@ export type RequireResolverOpts = {
* execution sandbox. `"*"` means all are allowed.
*/
allowedExternalModules: Set<string> | '*';
/**
* When true, return a write-blocking view of each resolved module. The
* module cache is shared across every task in the runner process, so an
* unprotected module object lets one task's changes leak into other tasks.
*/
secureModules?: boolean;
};
export type RequireResolver = (request: string) => unknown;
type Constructor = new (...args: unknown[]) => object;
const isWrappable = (value: unknown): value is object =>
value !== null && (typeof value === 'object' || typeof value === 'function');
// Views (write-blocking proxies) keyed by their real target, plus the reverse
// lookup used to unwrap `this`/arguments back to the real object before a
// wrapped function runs. One view per target keeps identity stable across reads.
const viewByTarget = new WeakMap<object, unknown>();
const targetByView = new WeakMap<object, object>();
const unwrap = (value: unknown): unknown =>
isWrappable(value) ? (targetByView.get(value) ?? value) : value;
// A non-configurable, non-writable data property: a Proxy must hand back its
// exact value (invariant), so it can't be wrapped.
const isFixedData = (descriptor: PropertyDescriptor | undefined): boolean =>
!!descriptor && 'value' in descriptor && !descriptor.configurable && !descriptor.writable;
// Secure a value read off the module. Normally wrap it in the membrane; when an
// invariant forces us to return the exact object, freeze it best-effort instead
// so it still can't be mutated for other tasks.
function secureReadValue(value: unknown, mustReturnRaw: boolean): unknown {
if (!mustReturnRaw) return secureModuleExport(value);
if (isWrappable(value) && !Object.isFrozen(value)) {
try {
Object.freeze(value);
} catch {
// Non-freezable (e.g. a populated TypedArray) — nothing more we can do.
}
}
return value;
}
// Blocks every write (assignment incl. accessor setters, (re)definition,
// deletion, prototype change) and wraps values read from the module — via both
// property reads and descriptor reflection — so nested objects can't be mutated
// either. Function calls forward to the real module with `this`/arguments
// unwrapped, so internal-slot/brand checks still pass.
const membraneHandler: ProxyHandler<object> = {
set: () => false,
defineProperty: () => false,
deleteProperty: () => false,
setPrototypeOf: () => false,
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown;
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
return secureReadValue(value, isFixedData(descriptor));
},
getOwnPropertyDescriptor(target, prop) {
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
if (descriptor && 'value' in descriptor) {
descriptor.value = secureReadValue(descriptor.value, isFixedData(descriptor));
}
return descriptor;
},
apply: (target, thisArg, args) =>
Reflect.apply(target as (...a: unknown[]) => unknown, unwrap(thisArg), args.map(unwrap)),
// Return values are left unwrapped so callers keep ownership of what a
// module hands back (e.g. a Buffer they can still mutate).
construct: (target, args, newTarget) =>
Reflect.construct(target as Constructor, args.map(unwrap), unwrap(newTarget) as Constructor),
};
/**
* Wrap a resolved module in a write-blocking membrane. Proxying rather than
* `Object.freeze` avoids two problems: freezing throws on non-freezable
* exports (e.g. a non-empty `Buffer`/`TypedArray`), and it leaves accessor
* setters (e.g. `crypto.fips`) able to mutate shared process state. The
* membrane also wraps nested objects on read, so one task cannot mutate a
* module's nested state (e.g. `http.globalAgent`) for the others.
*/
export function secureModuleExport(resolved: unknown): unknown {
if (!isWrappable(resolved)) return resolved;
const cached = viewByTarget.get(resolved);
if (cached !== undefined) return cached;
const view = new Proxy(resolved, membraneHandler);
viewByTarget.set(resolved, view);
targetByView.set(view, resolved);
return view;
}
export function createRequireResolver({
allowedBuiltInModules,
allowedExternalModules,
secureModules = false,
}: RequireResolverOpts) {
return (request: string) => {
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
@@ -37,6 +129,9 @@ export function createRequireResolver({
throw new ExecutionError(error);
}
return require(request) as unknown;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const resolved = require(request) as unknown;
return secureModules ? secureModuleExport(resolved) : resolved;
};
}
@@ -4,7 +4,7 @@ import type { WorkflowEntity } from '@n8n/db';
import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { ExternalSecretsProxy } from 'n8n-core';
import { ExternalSecretsProxy, WorkflowExecute } from 'n8n-core';
import type {
IWorkflowBase,
IExecuteWorkflowInfo,
@@ -103,7 +103,7 @@ describe('WorkflowExecuteAdditionalData', () => {
mockInstance(Telemetry);
const workflowRepository = mockInstance(WorkflowRepository);
const activeExecutions = mockInstance(ActiveExecutions);
mockInstance(CredentialsPermissionChecker);
const credentialsPermissionChecker = mockInstance(CredentialsPermissionChecker);
mockInstance(SubworkflowPolicyChecker);
mockInstance(WorkflowStatisticsService);
mockInstance(DataTableProxyService);
@@ -228,6 +228,100 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, undefined);
});
describe('credential permission check routing', () => {
const subWorkflowData = () =>
mock<IWorkflowBase>({
id: 'sub-id',
name: 'Sub Workflow',
nodes: [],
connections: {},
staticData: {},
settings: {},
});
beforeEach(() => {
jest.mocked(credentialsPermissionChecker.check).mockClear();
jest.mocked(credentialsPermissionChecker.checkForUser).mockClear();
jest.mocked(WorkflowExecute).mockClear();
});
it('checks credentials against the triggering user for an inline sub-workflow', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
);
expect(credentialsPermissionChecker.checkForUser).toHaveBeenCalledTimes(1);
expect(jest.mocked(credentialsPermissionChecker.checkForUser).mock.calls[0][0]).toBe(
'user-1',
);
expect(credentialsPermissionChecker.check).not.toHaveBeenCalled();
});
it('checks credentials against the project for a database sub-workflow', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: 'db-id', code: undefined }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
}),
);
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
});
it('falls back to the project check for an inline sub-workflow without a triggering user', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
);
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
});
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 () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
parentWorkflowId: 'parent-1',
}),
);
const integratedAdditionalData = jest.mocked(WorkflowExecute).mock.calls[0][0];
expect(integratedAdditionalData.userId).toBe('user-1');
});
it('does not carry the triggering user into a database sub-workflow (runs under its own project scope)', async () => {
await executeWorkflow(
mock<IExecuteWorkflowInfo>({ id: 'db-id', code: undefined }),
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
mock<ExecuteWorkflowOptions>({
loadedWorkflowData: subWorkflowData(),
doNotWaitToFinish: false,
}),
);
const integratedAdditionalData = jest.mocked(WorkflowExecute).mock.calls[0][0];
expect(integratedAdditionalData.userId).toBeUndefined();
});
});
});
describe('getRunData', () => {
@@ -4,10 +4,13 @@ import {
type SharedCredentialsRepository,
type CredentialsRepository,
type CredentialsEntity,
type UserRepository,
GLOBAL_OWNER_ROLE,
GLOBAL_MEMBER_ROLE,
} from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { INode } from 'n8n-workflow';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { NodeTypes } from '@/node-types';
import type { OwnershipService } from '@/services/ownership.service';
import type { ProjectService } from '@/services/project.service.ee';
@@ -19,13 +22,18 @@ describe('CredentialsPermissionChecker', () => {
const credentialsRepository = mock<CredentialsRepository>();
const ownershipService = mock<OwnershipService>();
const projectService = mock<ProjectService>();
const nodeTypes = mock<NodeTypes>();
const userRepository = mock<UserRepository>();
const credentialsFinderService = mock<CredentialsFinderService>();
const permissionChecker = new CredentialsPermissionChecker(
sharedCredentialsRepository,
credentialsRepository,
ownershipService,
projectService,
nodeTypes,
userRepository,
credentialsFinderService,
);
const nodeTypes = mock<NodeTypes>();
const workflowId = 'workflow123';
const credentialId = 'cred123';
@@ -256,6 +264,82 @@ describe('CredentialsPermissionChecker', () => {
);
});
it('should check the credential when genericAuthType is an expression', async () => {
const victimCredentialId = 'victim-cred';
const httpRequestNodeWithExpressionAuth: INode = {
id: 'node-3',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0],
parameters: {
authentication: 'genericCredentialType',
// Resolves to "httpHeaderAuth" only at execution time
genericAuthType: '={{ "httpHeaderAuth" }}',
},
credentials: {
httpHeaderAuth: {
id: victimCredentialId,
name: 'Victim Header Auth',
},
},
};
nodeTypes.getByNameAndVersion.mockReturnValue({
description: { credentials: [] },
} as never);
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
credentialsRepository.find.mockResolvedValue([]);
await expect(
permissionChecker.check(workflowId, [httpRequestNodeWithExpressionAuth]),
).rejects.toThrow('Node "HTTP Request" does not have access to the credential');
// The unresolved expression must not let the credential bypass the check
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
[teamProject.id],
[victimCredentialId],
);
});
it('should check the credential when nodeCredentialType is an expression', async () => {
const victimCredentialId = 'victim-cred';
const httpRequestNodeWithExpressionAuth: INode = {
id: 'node-4',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.3,
position: [0, 0],
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: '={{ "googleOAuth2Api" }}',
},
credentials: {
googleOAuth2Api: {
id: victimCredentialId,
name: 'Victim OAuth2',
},
},
};
nodeTypes.getByNameAndVersion.mockReturnValue({
description: { credentials: [] },
} as never);
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
credentialsRepository.find.mockResolvedValue([]);
await expect(
permissionChecker.check(workflowId, [httpRequestNodeWithExpressionAuth]),
).rejects.toThrow('Node "HTTP Request" does not have access to the credential');
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
[teamProject.id],
[victimCredentialId],
);
});
it('should fall back to checking all credentials if node type cannot be resolved', async () => {
nodeTypes.getByNameAndVersion.mockImplementation(() => {
throw new Error('Unknown node type');
@@ -276,4 +360,59 @@ describe('CredentialsPermissionChecker', () => {
);
});
});
describe('checkForUser', () => {
const userId = 'user-123';
it('should not throw when the workflow has no credentials', async () => {
await expect(permissionChecker.checkForUser(userId, [])).resolves.not.toThrow();
expect(userRepository.findOne).not.toHaveBeenCalled();
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
});
it('should throw when the triggering user cannot be resolved', async () => {
userRepository.findOne.mockResolvedValueOnce(null);
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
'Node "Test Node" uses a credential you do not have access to',
);
});
it('should throw when the user does not have access to the credential', async () => {
// A plain object (rather than `mock<User>()`) is used here so the assertion below
// can rely on `expect.objectContaining`: a jest-mock-extended deep mock's Proxy
// auto-mocks any accessed property (including `asymmetricMatch`), which fools Jest's
// asymmetric-matcher detection and produces a false negative for the received value.
const memberUser = { id: userId, role: GLOBAL_MEMBER_ROLE } as User;
userRepository.findOne.mockResolvedValueOnce(memberUser);
credentialsFinderService.findCredentialsForUser.mockResolvedValueOnce([]);
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
'Node "Test Node" uses a credential you do not have access to',
);
expect(credentialsFinderService.findCredentialsForUser).toHaveBeenCalledWith(
expect.objectContaining({ id: userId }),
['credential:read'],
);
});
it('should not throw when the user has access to the credential', async () => {
userRepository.findOne.mockResolvedValueOnce(
mock<User>({ id: userId, role: GLOBAL_MEMBER_ROLE }),
);
credentialsFinderService.findCredentialsForUser.mockResolvedValueOnce([
mock<CredentialsEntity>({ id: credentialId }),
]);
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
});
it('should skip the check for a user with instance-wide credential listing', async () => {
userRepository.findOne.mockResolvedValueOnce(mock<User>({ role: GLOBAL_OWNER_ROLE }));
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
});
});
});
@@ -1,10 +1,12 @@
import type { Project } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type { INode } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import { displayParameter, isExpression, UserError } from 'n8n-workflow';
import { NodeTypes } from '@/node-types';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee';
@@ -16,6 +18,15 @@ class InvalidCredentialError extends UserError {
}
}
class InaccessibleCredentialForUserError extends UserError {
override description =
'This node uses a credential you do not have access to. Ask its owner to share it with you.';
constructor(readonly node: INode) {
super(`Node "${node.name}" uses a credential you do not have access to`);
}
}
class InaccessibleCredentialError extends UserError {
override description =
this.project.type === 'personal'
@@ -37,8 +48,49 @@ export class CredentialsPermissionChecker {
private readonly credentialsRepository: CredentialsRepository,
private readonly ownershipService: OwnershipService,
private readonly projectService: ProjectService,
private readonly nodeTypes: NodeTypes,
private readonly userRepository: UserRepository,
private readonly credentialsFinderService: CredentialsFinderService,
) {}
/**
* Check that a specific user can use every credential referenced by the given
* nodes. Used for sub-workflows whose source is not a stored database workflow
* (inline/parameter, file, url): these carry no project of their own, so their
* credentials must be evaluated against the user triggering the run rather than
* against the parent workflow's project.
*/
async checkForUser(userId: string, nodes: INode[]) {
const credIdsToNodes = this.mapCredIdsToNodes(nodes);
const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return;
// Load the role relation (scopes are eager) so hasGlobalScope can resolve.
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['role'],
});
if (!user) {
// Cannot resolve the triggering user - fail closed.
throw new InaccessibleCredentialForUserError(credIdsToNodes[workflowCredIds[0]][0]);
}
// A user with instance-wide credential listing can use any credential.
if (hasGlobalScope(user, 'credential:list')) return;
const accessibleCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [
'credential:read',
]);
const accessibleSet = new Set(accessibleCredentials.map((cred) => cred.id));
for (const credentialsId of workflowCredIds) {
if (!accessibleSet.has(credentialsId)) {
throw new InaccessibleCredentialForUserError(credIdsToNodes[credentialsId][0]);
}
}
}
/**
* Check if a workflow has the ability to execute based on the projects it's apart of.
*/
@@ -99,13 +151,62 @@ export class CredentialsPermissionChecker {
return nodes.reduce<{ [credentialId: string]: INode[] }>((map, node) => {
if (node.disabled || !node.credentials) return map;
Object.values(node.credentials).forEach((cred) => {
const activeCredTypes = this.getActiveCredentialTypes(node);
for (const [credType, cred] of Object.entries(node.credentials)) {
if (!cred.id) throw new InvalidCredentialError(node);
// Skip credentials that are not actively used by the node's current configuration
if (activeCredTypes !== null && !activeCredTypes.has(credType)) continue;
map[cred.id] = map[cred.id] ? [...map[cred.id], node] : [node];
});
}
return map;
}, {});
}
/**
* Determines which credential types are actively used by a node based on its
* current configuration. Returns null if the node type cannot be resolved,
* in which case all credentials should be checked as a safe fallback.
*/
private getActiveCredentialTypes(node: INode): Set<string> | null {
try {
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
const activeTypes = new Set<string>();
// Check credentials defined in the node type description with display options
for (const credDef of nodeType.description.credentials ?? []) {
if (displayParameter(node.parameters, credDef, node, nodeType.description)) {
activeTypes.add(credDef.name);
}
}
// For nodes using predefined credential type (e.g., HTTP Request node),
// the active credential is specified by the nodeCredentialType parameter
const { nodeCredentialType } = node.parameters;
if (typeof nodeCredentialType === 'string' && nodeCredentialType) {
// An expression only resolves to its real type at execution time, so the
// static filter can't know which credential it activates. Check all
// referenced credentials rather than trust the unresolved literal.
if (isExpression(nodeCredentialType)) return null;
activeTypes.add(nodeCredentialType);
}
// For nodes using generic credential types (e.g., HTTP Request with
// authentication=genericCredentialType), the active credential type is
// specified by the genericAuthType parameter
const { genericAuthType } = node.parameters;
if (typeof genericAuthType === 'string' && genericAuthType) {
if (isExpression(genericAuthType)) return null;
activeTypes.add(genericAuthType);
}
return activeTypes;
} catch {
// If we can't resolve the node type, fall back to checking all credentials
return null;
}
}
}
@@ -163,12 +163,18 @@ export async function executeWorkflow(
const executionId = await activeExecutions.add(runData);
// A sub-workflow loaded from inline JSON / file / URL (source other than a
// stored database workflow) carries no project of its own. Its credentials
// must be evaluated against the triggering user, not the parent's project.
const isInlineSubworkflow = workflowInfo.code !== undefined && workflowInfo.id === undefined;
const executionPromise = startExecution(
additionalData,
options,
executionId,
runData,
workflowData,
isInlineSubworkflow,
);
if (options.doNotWaitToFinish) {
@@ -184,6 +190,7 @@ async function startExecution(
executionId: string,
runData: IWorkflowExecutionDataProcess,
workflowData: IWorkflowBase,
isInlineSubworkflow = false,
): Promise<ExecuteWorkflowData> {
const nodeTypes = Container.get(NodeTypes);
const activeExecutions = Container.get(ActiveExecutions);
@@ -212,7 +219,17 @@ async function startExecution(
let data;
try {
await Container.get(CredentialsPermissionChecker).check(workflowData.id, workflowData.nodes);
if (isInlineSubworkflow && additionalData.userId) {
// Inline sub-workflow triggered by a specific user: its credentials were
// never vetted against that user (they live only in the parameter JSON),
// so validate them against the user rather than the parent's project.
await Container.get(CredentialsPermissionChecker).checkForUser(
additionalData.userId,
workflowData.nodes,
);
} else {
await Container.get(CredentialsPermissionChecker).check(workflowData.id, workflowData.nodes);
}
await Container.get(SubworkflowPolicyChecker).check(
workflow,
options.parentWorkflowId,
@@ -223,6 +240,11 @@ async function startExecution(
// Create new additionalData to have different workflow loaded and to call
// different webhooks
const additionalDataIntegrated = await getBase({
// Inline sub-workflows carry no project, so the triggering user must be
// preserved for nested inline calls to be validated against that user too.
// Stored sub-workflows run under their own project scope, so their userId
// stays unset (their credentials are validated against the project instead).
userId: isInlineSubworkflow ? additionalData.userId : undefined,
workflowId: workflowData.id,
});
additionalDataIntegrated.hooks = getLifecycleHooksForSubExecutions(
@@ -21,8 +21,15 @@ import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type EntityManager } from '@n8n/typeorm';
import omit from 'lodash/omit';
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { NodeOperationError, PROJECT_ROOT, UserError, WorkflowActivationError } from 'n8n-workflow';
import type { INode, IWorkflowBase, WorkflowId } from 'n8n-workflow';
import {
EXECUTE_WORKFLOW_NODE_TYPE,
jsonParse,
NodeOperationError,
PROJECT_ROOT,
UserError,
WorkflowActivationError,
} from 'n8n-workflow';
import { WorkflowFinderService } from './workflow-finder.service';
@@ -260,17 +267,62 @@ export class EnterpriseWorkflowService {
return [];
}
return workflow.nodes.filter((node) => {
if (!node.credentials) return false;
const allUsedCredentials = Object.values(node.credentials);
const allUsedCredentialIds = allUsedCredentials.map((nodeCred) => nodeCred.id?.toString());
return allUsedCredentialIds.some(
(nodeCredId) => nodeCredId && !userCredIds.includes(nodeCredId),
);
const usedCredentialIds = this.getCredentialIdsUsedByNode(node);
return usedCredentialIds.some((credId) => !userCredIds.includes(credId));
});
}
/**
* 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
* parameter, so those references are parsed out and returned as well.
*/
private getCredentialIdsUsedByNode(node: INode): string[] {
const credentialIds: string[] = [];
if (node.credentials) {
for (const nodeCred of Object.values(node.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));
}
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).
*/
private getCredentialIdsFromInlineWorkflow(workflowJson: unknown): string[] {
if (typeof workflowJson !== 'string') return [];
let parsed: Partial<IWorkflowBase>;
try {
parsed = jsonParse<Partial<IWorkflowBase>>(workflowJson);
} catch {
return [];
}
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;
}
async transferWorkflow(
user: User,
workflowId: string,
@@ -1,5 +1,5 @@
import { WorkflowEntity } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import { EXECUTE_WORKFLOW_NODE_TYPE, type INode } from 'n8n-workflow';
export const FIRST_CREDENTIAL_ID = '1';
export const SECOND_CREDENTIAL_ID = '2';
@@ -8,6 +8,7 @@ export const THIRD_CREDENTIAL_ID = '3';
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 nodeWithNoCredentials: INode = {
id: NODE_WITH_NO_CRED,
@@ -53,10 +54,39 @@ const nodeWithTwoCredentials: INode = {
parameters: {},
};
// Execute Sub-workflow node whose inline `workflowJson` embeds a sub-workflow
// referencing a credential (FIRST_CREDENTIAL_ID) that is not exposed via the
// node's own top-level `credentials`.
const nodeWithInlineSubworkflowCredential: INode = {
id: NODE_WITH_INLINE_SUBWORKFLOW_CRED,
name: 'Execute Sub-workflow',
typeVersion: 1.2,
type: EXECUTE_WORKFLOW_NODE_TYPE,
position: [0, 0],
parameters: {
source: 'parameter',
workflowJson: JSON.stringify({
nodes: [
{
name: 'Steal',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
parameters: {},
credentials: {
httpHeaderAuth: { id: FIRST_CREDENTIAL_ID, name: 'First fake credential' },
},
},
],
connections: {},
}),
},
};
export function getWorkflow(options?: {
addNodeWithoutCreds?: boolean;
addNodeWithOneCred?: boolean;
addNodeWithTwoCreds?: boolean;
addNodeWithInlineSubworkflowCred?: boolean;
}) {
const workflow = new WorkflowEntity();
@@ -74,5 +104,9 @@ export function getWorkflow(options?: {
workflow.nodes.push(nodeWithTwoCredentials);
}
if (options?.addNodeWithInlineSubworkflowCred) {
workflow.nodes.push(nodeWithInlineSubworkflowCredential);
}
return workflow;
}
@@ -108,6 +108,14 @@ describe('EnterpriseWorkflowService', () => {
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
}).toThrow();
});
it('Should throw error saving a workflow adding an Execute Sub-workflow node whose inline JSON uses an inaccessible credential', () => {
const newWorkflowVersion = getWorkflow({ addNodeWithInlineSubworkflowCred: true });
const previousWorkflowVersion = getWorkflow();
expect(() => {
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
}).toThrow();
});
});
describe('getNodesWithInaccessibleCreds', () => {
@@ -188,5 +196,19 @@ describe('EnterpriseWorkflowService', () => {
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(2);
});
test('Should flag an Execute Sub-workflow node referencing an inaccessible credential inside its inline workflow JSON', () => {
const workflow = getWorkflow({ addNodeWithInlineSubworkflowCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
expect(nodesWithInaccessibleCreds).toHaveLength(1);
});
test('Should not flag an Execute Sub-workflow node when the inline credential is accessible', () => {
const workflow = getWorkflow({ addNodeWithInlineSubworkflowCred: true });
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
FIRST_CREDENTIAL_ID,
]);
expect(nodesWithInaccessibleCreds).toHaveLength(0);
});
});
});
@@ -773,6 +773,78 @@ describe('RoutingNode', () => {
expect(result).toEqual(testData.output);
});
}
describe('when a routed property name is an inherited object member', () => {
// Guard the shared prototype so a regression here cannot cascade to other tests.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact', () => {
const nodeTypeProperties: INodeProperties = {
displayName: 'Value',
name: 'value',
type: 'string',
routing: {
send: {
property: 'toString.call',
type: 'body',
value: 'x',
},
},
default: '',
};
node.parameters = {};
nodeType.description.properties = [nodeTypeProperties];
const workflow = new Workflow({
nodes: workflowData.nodes,
connections: workflowData.connections,
active: false,
nodeTypes,
});
const executeFunctions = mock<executionContexts.ExecuteContext>();
Object.assign(executeFunctions, {
runIndex,
additionalData,
workflow,
node,
mode,
connectionInputData,
runExecutionData,
nodeType,
});
const routingNode = new RoutingNode(executeFunctions, nodeType);
const executeSingleFunctions = getExecuteSingleFunctions(
workflow,
runExecutionData,
runIndex,
node,
itemIndex,
);
const result = routingNode.getRequestOptionsFromParameters(
executeSingleFunctions,
nodeTypeProperties,
itemIndex,
runIndex,
path,
{},
);
// Prototype untouched; the value lands as an own property on the request body.
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
expect((result?.options.body as { toString?: { call?: unknown } }).toString?.call).toBe(
'x',
);
});
});
});
describe('runNode', () => {
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import type { INodeParameters } from 'n8n-workflow';
import { setParameterValue } from './parameterUtils';
describe('parameterUtils', () => {
@@ -62,4 +63,38 @@ describe('parameterUtils', () => {
expect(target).toEqual({ foo: 'bar' });
});
});
describe('dot-notation paths that name inherited members', () => {
// Guard the shared prototype so a regression in one test cannot cascade to others.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact for a top-level inherited key', () => {
const target: INodeParameters = {};
setParameterValue(target, 'toString.call', 'x');
// The shared prototype member must be untouched.
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
// The value lands as a plain own property on the target instead.
expect(Object.prototype.hasOwnProperty.call(target, 'toString')).toBe(true);
expect((target as { toString: { call: unknown } }).toString.call).toBe('x');
});
it('keeps built-in object prototypes intact for a nested inherited key', () => {
const target: INodeParameters = {};
setParameterValue(target, 'a.toString.call', 'y');
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call({})).toBe('[object Object]');
expect((target as { a: { toString: { call: unknown } } }).a.toString.call).toBe('y');
});
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect, afterAll } from 'vitest';
import { describe, it, expect, afterAll, afterEach } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type {
IConnections,
@@ -600,3 +600,39 @@ describe('setValue', () => {
expect(nodeValues.value.newProperty).toBe('newValue');
});
});
describe('setValue with dot-notation paths that name inherited members', () => {
// Guard the shared prototype so a regression in one test cannot cascade to others.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact when a path names an inherited member', () => {
const nodeValues: Ref<INodeParameters> = ref({ parameters: {} });
setValue(nodeValues, 'parameters.toString.call', 'x');
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
// The value lands as a plain own property on the parameters object.
expect((nodeValues.value.parameters as { toString?: { call?: unknown } }).toString?.call).toBe(
'x',
);
});
it('keeps built-in object prototypes intact for a nested inherited key', () => {
const nodeValues: Ref<INodeParameters> = ref({ parameters: {} });
setValue(nodeValues, 'parameters.a.toString.call', 'y');
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call({})).toBe('[object Object]');
expect(
(nodeValues.value.parameters as { a?: { toString?: { call?: unknown } } }).a?.toString?.call,
).toBe('y');
});
});
@@ -104,20 +104,13 @@ export function setValue(
}
}
} else {
// Value should be set
if (typeof value === 'object') {
set(
get(nodeValues.value, nameParts.join('.')) as Record<string, unknown>,
lastNamePart as string,
deepCopy(value),
);
} else {
set(
get(nodeValues.value, nameParts.join('.')) as Record<string, unknown>,
lastNamePart as string,
value,
);
}
// Value should be set. Write from the root via the full path so the setter
// builds own-property intermediates rather than reading an inherited member
// of an object along the path.
const fullPath = isArray
? `${nameParts.join('.')}[${lastNamePart}]`
: `${nameParts.join('.')}.${lastNamePart}`;
set(nodeValues.value, fullPath, typeof value === 'object' ? deepCopy(value) : value);
}
}
@@ -5,6 +5,7 @@ import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
INode,
INodeExecutionData,
INodeProperties,
INodePropertyOptions,
@@ -15,6 +16,19 @@ import { NodeOperationError, NodeConnectionTypes, deepCopy } from 'n8n-workflow'
import { parse as pathParse } from 'path';
import { file } from 'tmp-promise';
const VALID_IMAGE_FORMATS = new Set(['bmp', 'gif', 'jpeg', 'png', 'tiff', 'tif', 'webp']);
function validateImageFormat(format: unknown, node: INode): string {
if (typeof format === 'string' && VALID_IMAGE_FORMATS.has(format)) {
return format;
}
throw new NodeOperationError(
node,
`Invalid image format: ${format}. Valid formats are: ${Array.from(VALID_IMAGE_FORMATS).join(', ')}`,
);
}
const nodeOperations: INodePropertyOptions[] = [
{
name: 'Blur',
@@ -1278,7 +1292,8 @@ export class EditImage implements INodeType {
}
if (options.format !== undefined) {
gmInstance = gmInstance!.setFormat(options.format as string);
const format = validateImageFormat(options.format, this.getNode());
gmInstance = gmInstance!.setFormat(format);
newItem.binary![dataPropertyName].fileExtension = options.format as string;
newItem.binary![dataPropertyName].mimeType = `image/${options.format}`;
const fileName = newItem.binary![dataPropertyName].fileName;
@@ -0,0 +1,161 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { EditImage } from '../EditImage.node';
const createTestBuffer = () =>
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
]);
const mockGmInstance: any = {
background: jest.fn(function (this: any) {
return this;
}),
blur: jest.fn(function (this: any) {
return this;
}),
borderColor: jest.fn(function (this: any) {
return this;
}),
border: jest.fn(function (this: any) {
return this;
}),
compose: jest.fn(function (this: any) {
return this;
}),
geometry: jest.fn(function (this: any) {
return this;
}),
composite: jest.fn(function (this: any) {
return this;
}),
crop: jest.fn(function (this: any) {
return this;
}),
drawCircle: jest.fn(function (this: any) {
return this;
}),
drawLine: jest.fn(function (this: any) {
return this;
}),
drawRectangle: jest.fn(function (this: any) {
return this;
}),
fill: jest.fn(function (this: any) {
return this;
}),
font: jest.fn(function (this: any) {
return this;
}),
fontSize: jest.fn(function (this: any) {
return this;
}),
drawText: jest.fn(function (this: any) {
return this;
}),
identify: jest.fn(function (this: any, callback: any) {
callback(null, { width: 100, height: 100, format: 'PNG' });
return this;
}),
quality: jest.fn(function (this: any) {
return this;
}),
resize: jest.fn(function (this: any) {
return this;
}),
rotate: jest.fn(function (this: any) {
return this;
}),
setFormat: jest.fn(function (this: any) {
return this;
}),
shear: jest.fn(function (this: any) {
return this;
}),
stream: jest.fn(function (this: any) {
return this;
}),
transparent: jest.fn(function (this: any) {
return this;
}),
toBuffer: jest.fn(function (this: any, callback: any) {
callback(null, createTestBuffer());
return this;
}),
autoOrient: jest.fn(function (this: any) {
return this;
}),
out: jest.fn(function (this: any) {
return this;
}),
};
jest.mock('gm', () => ({
default: jest.fn(() => mockGmInstance),
__esModule: true,
}));
describe('EditImage Node', () => {
let editImageNode: EditImage;
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
let mockNode: INode;
beforeEach(() => {
jest.clearAllMocks();
editImageNode = new EditImage();
mockNode = {
id: 'test-node-id',
name: 'EditImage',
type: 'n8n-nodes-base.editImage',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
mockExecuteFunctions.helpers.assertBinaryData.mockReturnValue(undefined as any);
});
describe('format and quality options', () => {
it.each([
['unsupported string', 'pdf'],
['non-string value', 123],
])('should reject an %s format', async (_, format) => {
const testBuffer = createTestBuffer();
const items: INodeExecutionData[] = [
{
json: {},
binary: {
data: {
data: testBuffer.toString('base64'),
mimeType: 'image/png',
fileExtension: 'png',
fileName: 'test.png',
},
},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'blur';
if (paramName === 'dataPropertyName') return 'data';
if (paramName === 'blur') return 5;
if (paramName === 'sigma') return 2;
if (paramName === 'options') return { format };
return {};
});
mockExecuteFunctions.helpers.getBinaryDataBuffer.mockResolvedValue(testBuffer);
await expect(editImageNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
`Invalid image format: ${format}`,
);
expect(mockGmInstance.setFormat).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,73 @@
import type { IExecuteFunctions, INodeTypeBaseDescription } from 'n8n-workflow';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { EmailSendV1 } from '../../v1/EmailSendV1.node';
const mockTransporter = { sendMail: jest.fn() };
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => mockTransporter),
}));
describe('Test EmailSendV1', () => {
let emailSendV1: EmailSendV1;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
emailSendV1 = new EmailSendV1({
description: {
displayName: 'Send Email',
name: 'emailSend',
icon: 'fa:envelope',
group: ['output'],
description: 'Sends an Email',
version: 1,
defaults: {
name: 'Send Email',
color: '#00bb88',
},
},
} as unknown as INodeTypeBaseDescription);
mockExecuteFunctions = mock<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should serialize object message values before sending', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { data: 'test' } }]);
mockExecuteFunctions.getCredentials.mockResolvedValue({
host: 'smtp.example.com',
port: 587,
secure: false,
});
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('[email protected]')
.mockReturnValueOnce('[email protected]')
.mockReturnValueOnce('[email protected]')
.mockReturnValueOnce('[email protected]')
.mockReturnValueOnce('Test Subject')
.mockReturnValueOnce({ path: 'message.txt' })
.mockReturnValueOnce({ href: 'https://example.test/message' })
.mockReturnValueOnce('')
.mockReturnValueOnce({ replyTo: '[email protected]' });
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
await emailSendV1.execute.call(mockExecuteFunctions);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: '[email protected]',
to: '[email protected]',
cc: '[email protected]',
bcc: '[email protected]',
subject: 'Test Subject',
text: '{"path":"message.txt"}',
html: '{"href":"https://example.test/message"}',
replyTo: '[email protected]',
disableFileAccess: true,
disableUrlAccess: true,
}),
);
});
});
@@ -0,0 +1,65 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import * as sendOperation from '../../v2/send.operation';
const mockTransporter = { sendMail: jest.fn() };
jest.mock('../../v2/utils', () => {
const originalModule = jest.requireActual('../../v2/utils');
return {
...originalModule,
configureTransport: jest.fn(() => mockTransporter),
};
});
describe('Test EmailSendV2, send operation', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
jest.clearAllMocks();
});
it('should request normalized text and HTML values before sending', async () => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { data: 'test' } }]);
mockExecuteFunctions.getNode.mockReturnValue({
typeVersion: 2.1,
} as ReturnType<IExecuteFunctions['getNode']>);
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
mockExecuteFunctions.getCredentials.mockResolvedValue({
host: 'smtp.example.com',
port: 587,
});
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('[email protected]')
.mockReturnValueOnce('[email protected]')
.mockReturnValueOnce('Test Subject')
.mockReturnValueOnce('both')
.mockReturnValueOnce({
appendAttribution: false,
ccEmail: '[email protected]',
bccEmail: '[email protected]',
replyTo: '[email protected]',
})
.mockReturnValueOnce(['plain text'])
.mockReturnValueOnce(['rich text']);
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
await sendOperation.execute.call(mockExecuteFunctions);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: '[email protected]',
to: '[email protected]',
cc: '[email protected]',
bcc: '[email protected]',
subject: 'Test Subject',
text: '["plain text"]',
html: '["rich text"]',
replyTo: '[email protected]',
}),
);
});
});
@@ -28,7 +28,7 @@ describe('Test EmailSendV2, email => sendAndWait', () => {
jest.clearAllMocks();
});
it('should send message and put execution to wait', async () => {
it('should use the shared transport configuration and put execution to wait', async () => {
const items = [{ json: { data: 'test' } }];
//node
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION);
@@ -57,7 +57,7 @@ describe('Test EmailSendV2, email => sendAndWait', () => {
const result = await emailSendV2.execute.call(mockExecuteFunctions);
expect(result).toEqual([items]);
expect(utils.configureTransport).toHaveBeenCalledTimes(1);
expect(utils.configureTransport).toHaveBeenCalledWith({}, {});
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
expect(transporter.sendMail).toHaveBeenCalledWith({
@@ -0,0 +1,32 @@
import { createTransport } from 'nodemailer';
import { configureTransport } from '../../v2/utils';
jest.mock('nodemailer', () => ({
createTransport: jest.fn(),
}));
describe('EmailSend transport configuration', () => {
it('should apply content access restrictions to the transport', () => {
configureTransport(
{
host: 'smtp.example.com',
port: 587,
secure: false,
},
{},
);
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({
host: 'smtp.example.com',
port: 587,
secure: false,
}),
{
disableFileAccess: true,
disableUrlAccess: true,
},
);
});
});
@@ -0,0 +1,9 @@
export function toMailString(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return value.toString();
}
if (typeof value === 'symbol' || typeof value === 'function') return value.toString();
return JSON.stringify(value);
}
@@ -9,6 +9,7 @@ import type {
import { NodeConnectionTypes } from 'n8n-workflow';
import { createTransport } from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { toMailString } from '../utils';
const versionDescription: INodeTypeDescription = {
displayName: 'Send Email',
@@ -149,15 +150,16 @@ export class EmailSendV1 implements INodeType {
try {
item = items[itemIndex];
const fromEmail = this.getNodeParameter('fromEmail', itemIndex) as string;
const toEmail = this.getNodeParameter('toEmail', itemIndex) as string;
const ccEmail = this.getNodeParameter('ccEmail', itemIndex) as string;
const bccEmail = this.getNodeParameter('bccEmail', itemIndex) as string;
const subject = this.getNodeParameter('subject', itemIndex) as string;
const text = this.getNodeParameter('text', itemIndex) as string;
const html = this.getNodeParameter('html', itemIndex) as string;
const fromEmail = toMailString(this.getNodeParameter('fromEmail', itemIndex));
const toEmail = toMailString(this.getNodeParameter('toEmail', itemIndex));
const ccEmail = toMailString(this.getNodeParameter('ccEmail', itemIndex));
const bccEmail = toMailString(this.getNodeParameter('bccEmail', itemIndex));
const subject = toMailString(this.getNodeParameter('subject', itemIndex));
const text = toMailString(this.getNodeParameter('text', itemIndex, ''));
const html = toMailString(this.getNodeParameter('html', itemIndex, ''));
const attachmentPropertyString = this.getNodeParameter('attachments', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {});
const replyTo = toMailString(options.replyTo);
const credentials = await this.getCredentials('smtp');
@@ -191,7 +193,9 @@ export class EmailSendV1 implements INodeType {
subject,
text,
html,
replyTo: options.replyTo as string | undefined,
replyTo,
disableFileAccess: true,
disableUrlAccess: true,
};
if (attachmentPropertyString && item.binary) {
@@ -12,6 +12,7 @@ import { createUtmCampaignLink, updateDisplayOptions } from '@utils/utilities';
import { fromEmailProperty, toEmailProperty } from './descriptions';
import { configureTransport, type EmailSendOptions } from './utils';
import { appendAttributionOption } from '../../../utils/descriptions';
import { toMailString } from '../utils';
const properties: INodeProperties[] = [
// TODO: Add choice for text as text or html (maybe also from name)
@@ -186,29 +187,35 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
try {
item = items[itemIndex];
const fromEmail = this.getNodeParameter('fromEmail', itemIndex) as string;
const toEmail = this.getNodeParameter('toEmail', itemIndex) as string;
const subject = this.getNodeParameter('subject', itemIndex) as string;
const fromEmail = toMailString(this.getNodeParameter('fromEmail', itemIndex));
const toEmail = toMailString(this.getNodeParameter('toEmail', itemIndex));
const subject = toMailString(this.getNodeParameter('subject', itemIndex));
const emailFormat = this.getNodeParameter('emailFormat', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as EmailSendOptions;
const ccEmail = toMailString(options.ccEmail);
const bccEmail = toMailString(options.bccEmail);
const replyTo = toMailString(options.replyTo);
const transporter = configureTransport(credentials, options);
const mailOptions: IDataObject = {
from: fromEmail,
to: toEmail,
cc: options.ccEmail,
bcc: options.bccEmail,
cc: ccEmail,
bcc: bccEmail,
subject,
replyTo: options.replyTo,
replyTo,
};
if (emailFormat === 'text' || emailFormat === 'both') {
mailOptions.text = this.getNodeParameter('text', itemIndex, '');
const text = toMailString(this.getNodeParameter('text', itemIndex, ''));
mailOptions.text = text;
}
if (emailFormat === 'html' || emailFormat === 'both') {
mailOptions.html = this.getNodeParameter('html', itemIndex, '');
const html = toMailString(this.getNodeParameter('html', itemIndex, ''));
mailOptions.html = html;
}
let appendAttribution = options.appendAttribution;
@@ -17,6 +17,7 @@ import {
getSendAndWaitConfig,
getSendAndWaitProperties,
} from '../../../utils/sendAndWait/utils';
import { toMailString } from '../utils';
export const description: INodeProperties[] = getSendAndWaitProperties(
[fromEmailProperty, toEmailProperty],
@@ -24,8 +25,8 @@ export const description: INodeProperties[] = getSendAndWaitProperties(
);
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const fromEmail = this.getNodeParameter('fromEmail', 0) as string;
const toEmail = this.getNodeParameter('toEmail', 0) as string;
const fromEmail = toMailString(this.getNodeParameter('fromEmail', 0));
const toEmail = toMailString(this.getNodeParameter('toEmail', 0));
const config = getSendAndWaitConfig(this);
const buttons: string[] = [];
@@ -44,7 +44,10 @@ export function configureTransport(credentials: IDataObject, options: EmailSendO
};
}
return createTransport(connectionOptions);
return createTransport(connectionOptions, {
disableFileAccess: true,
disableUrlAccess: true,
});
}
export async function smtpConnectionTest(
@@ -3,19 +3,21 @@ import { NodeOperationError } from 'n8n-workflow';
import type { ConfigListSummary } from 'simple-git';
/**
* Validates a git reference to prevent command injection attacks
* @param reference - The git reference to validate (e.g., branch name, HEAD, refs/heads/main)
* @param node - The node instance for error throwing
* @throws {NodeOperationError} If the reference contains unsafe characters or patterns
* Shared safeguards for git references: block argument injection, path
* traversal, and control characters. The caller supplies the allowed-character
* whitelist so tags (which permit `+`) can be validated less strictly than
* branch/ref names without weakening the injection protections.
*/
export function validateGitReference(reference: string, node: INode): void {
// Allow only safe characters: alphanumeric, /, @, {, }, ., -, _, :
const safeReferencePattern = /^[a-zA-Z0-9/@{}._:-]+$/;
function assertSafeGitReference(
reference: string,
node: INode,
safeReferencePattern: RegExp,
allowedCharsMessage: string,
): void {
if (!safeReferencePattern.test(reference)) {
throw new NodeOperationError(
node,
'Invalid reference format. Reference contains unsafe characters. Only alphanumeric characters and /@{}._:- are allowed',
`Invalid reference format. Reference contains unsafe characters. Only alphanumeric characters and ${allowedCharsMessage} are allowed`,
);
}
@@ -42,10 +44,119 @@ export function validateGitReference(reference: string, node: INode): void {
}
}
/**
* Validates a git reference to prevent command injection attacks
* @param reference - The git reference to validate (e.g., branch name, HEAD, refs/heads/main)
* @param node - The node instance for error throwing
* @throws {NodeOperationError} If the reference contains unsafe characters or patterns
*/
export function validateGitReference(reference: string, node: INode): void {
// Allow only safe characters: alphanumeric, /, @, {, }, ., -, _, :
assertSafeGitReference(reference, node, /^[a-zA-Z0-9/@{}._:-]+$/, '/@{}._:-');
}
/**
* Validates a git tag name. Tags follow git's ref-format rules, which permit
* `+` (e.g. SemVer build metadata like `v1.2.3+build.1`), so the whitelist is
* a superset of `validateGitReference` while keeping the same safeguards.
* @param name - The tag name to validate
* @param node - The node instance for error throwing
* @throws {NodeOperationError} If the tag name contains unsafe characters or patterns
*/
export function validateGitTag(name: string, node: INode): void {
assertSafeGitReference(name, node, /^[a-zA-Z0-9/@{}._:+-]+$/, '/@{}._:+-');
}
const REMOTE_ORIGIN_URL_KEY = 'remote.origin.url';
const REMOTE_ORIGIN_PUSH_URL_KEY = 'remote.origin.pushurl';
const REMOTE_CONFIG_KEY_PATTERN = /^remote\.(.+)\.(url|pushurl)$/i;
export type GitRepositoryType = 'source' | 'target';
export interface ConfiguredRemoteRepositories {
sourceValidationTargets: string[];
targetValidationTargets: string[];
pushTarget: string | undefined;
}
export function getRepositoryTypeForRemoteConfigKey(key: string): GitRepositoryType | undefined {
const match = REMOTE_CONFIG_KEY_PATTERN.exec(key);
if (!match) {
return undefined;
}
return match[2].toLowerCase() === 'pushurl' ? 'target' : 'source';
}
function addRemoteValue(
remoteValuesByName: Map<string, string[]>,
remoteName: string,
value: string,
) {
const existingValues = remoteValuesByName.get(remoteName) ?? [];
existingValues.push(value);
remoteValuesByName.set(remoteName, existingValues);
}
export function getConfiguredRemoteRepositories(
configValues: Record<string, Record<string, string | string[] | undefined>>,
node: INode,
): ConfiguredRemoteRepositories {
const sourceValidationTargets: string[] = [];
const targetValidationTargets: string[] = [];
const remoteOriginUrls: string[] = [];
const remoteOriginPushUrls: string[] = [];
const remoteUrlsByName = new Map<string, string[]>();
const remotePushUrlsByName = new Map<string, string[]>();
for (const values of Object.values(configValues)) {
for (const [key, value] of Object.entries(values)) {
const match = REMOTE_CONFIG_KEY_PATTERN.exec(key);
if (value === undefined || match === null) {
continue;
}
if (typeof value !== 'string') {
throw new NodeOperationError(node, 'Target repository is required');
}
const remoteName = match[1].toLowerCase();
const repositoryType = match[2].toLowerCase();
const normalizedKey = key.toLowerCase();
if (repositoryType === 'pushurl') {
addRemoteValue(remotePushUrlsByName, remoteName, value);
if (normalizedKey === REMOTE_ORIGIN_PUSH_URL_KEY) {
remoteOriginPushUrls.push(value);
}
} else {
sourceValidationTargets.push(value);
addRemoteValue(remoteUrlsByName, remoteName, value);
}
if (normalizedKey === REMOTE_ORIGIN_URL_KEY) {
remoteOriginUrls.push(value);
}
}
}
const remoteNames = new Set([...remoteUrlsByName.keys(), ...remotePushUrlsByName.keys()]);
for (const remoteName of remoteNames) {
const remoteUrls = remoteUrlsByName.get(remoteName) ?? [];
const remotePushUrls = remotePushUrlsByName.get(remoteName);
targetValidationTargets.push.apply(targetValidationTargets, remotePushUrls ?? remoteUrls);
}
return {
sourceValidationTargets,
targetValidationTargets,
pushTarget: remoteOriginPushUrls[0] ?? remoteOriginUrls[0],
};
}
function sanitizeUrl(url: string): string {
const urlObj = new URL(url);
urlObj.username = '';
+56 -41
View File
@@ -31,7 +31,14 @@ import {
switchBranchFields,
tagFields,
} from './descriptions';
import { mapGitConfigList, validateGitReference } from './GenericFunctions';
import {
type ConfiguredRemoteRepositories,
getConfiguredRemoteRepositories,
getRepositoryTypeForRemoteConfigKey,
mapGitConfigList,
validateGitReference,
validateGitTag,
} from './GenericFunctions';
const REMOTE_HELPER_TRANSPORT = /^[a-zA-Z][a-zA-Z0-9+.-]*::/;
@@ -295,8 +302,20 @@ export class Git implements INodeType {
const hasUrlScheme = (repositoryPath: string) =>
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(repositoryPath);
const isSshRepositoryPath = (repositoryPath: string) =>
/^(?:[^@\s]+@)?[^:\s]+:.+/.test(repositoryPath);
const isSshRepositoryPath = (repositoryPath: string) => {
const colonIndex = repositoryPath.indexOf(':');
if (colonIndex === -1) {
return false;
}
const remoteHost = repositoryPath.slice(0, colonIndex);
return (
remoteHost.length > 0 &&
!remoteHost.includes('/') &&
!remoteHost.includes('\\') &&
/^(?:[^@\s]+@)?[^@\s]+$/.test(remoteHost)
);
};
const assertLocalRepositoryPathAllowed = async (
repositoryPath: string,
@@ -367,35 +386,23 @@ export class Git implements INodeType {
}
};
const getRemoteOriginTargetRepositories = (
configValues: Record<string, Record<string, string | string[] | undefined>>,
) => {
const remoteOriginUrls: string[] = [];
const remoteOriginPushUrls: string[] = [];
const validateConfiguredRemoteRepositories = async (
git: SimpleGit,
repositoryType: 'source' | 'target',
baseDir: string,
): Promise<ConfiguredRemoteRepositories> => {
const config = await git.listConfig();
const remoteRepositories = getConfiguredRemoteRepositories(config.values, this.getNode());
const validationTargets =
repositoryType === 'source'
? remoteRepositories.sourceValidationTargets
: remoteRepositories.targetValidationTargets;
for (const values of Object.values(configValues)) {
for (const key of ['remote.origin.url', 'remote.origin.pushurl'] as const) {
const value = values[key];
if (value === undefined) {
continue;
}
if (typeof value !== 'string') {
throw new NodeOperationError(this.getNode(), 'Target repository is required');
}
if (key === 'remote.origin.pushurl') {
remoteOriginPushUrls.push(value);
} else {
remoteOriginUrls.push(value);
}
}
for (const repository of validationTargets) {
await assertRepositoryReferenceAllowed(repository, repositoryType, baseDir);
}
return {
validationTargets: [...remoteOriginUrls, ...remoteOriginPushUrls],
pushTarget: remoteOriginPushUrls[0] ?? remoteOriginUrls[0],
};
return remoteRepositories;
};
const isFileNotFoundError = (error: unknown) =>
@@ -491,14 +498,20 @@ export class Git implements INodeType {
const gitOptions: Partial<SimpleGitOptions> = {
baseDir: operation === 'clone' ? cloneStagingBase : resolvedRepositoryPath,
config: gitConfig,
// simple-git blocks callers from setting `core.hooksPath` via `config`
// unless this flag is set. We set it deliberately as a mitigation, so
// opt in to keep that mitigation working.
...(!enableHooks && { unsafe: { allowUnsafeHooksPath: true } }),
};
const cleanEnv = Object.create(null) as Record<string, unknown>;
const isWriteOperation = operation === 'push' || operation === 'pushTags';
// Tell git not to ask for any information via the terminal like for
// example the username. As nobody will be able to answer it would
// n8n keep on waiting forever.
cleanEnv['GIT_TERMINAL_PROMPT'] = '0';
cleanEnv['GIT_ALLOW_PROTOCOL'] = 'file:git:http:https:ssh';
cleanEnv['GIT_ALLOW_PROTOCOL'] =
isWriteOperation && !enableHooks ? 'git:http:https:ssh' : 'file:git:http:https:ssh';
const git: SimpleGit = simpleGit(gitOptions).env(cleanEnv);
if (operation === 'add') {
@@ -540,6 +553,11 @@ export class Git implements INodeType {
);
}
const repositoryType = getRepositoryTypeForRemoteConfigKey(key);
if (repositoryType) {
await assertRepositoryReferenceAllowed(value, repositoryType, resolvedRepositoryPath);
}
if (options.mode === 'append') {
append = true;
}
@@ -641,6 +659,7 @@ export class Git implements INodeType {
// fetch
// ----------------------------------
await validateConfiguredRemoteRepositories(git, 'source', resolvedRepositoryPath);
await git.fetch();
returnItems.push({
json: {
@@ -681,6 +700,7 @@ export class Git implements INodeType {
// pull
// ----------------------------------
await validateConfiguredRemoteRepositories(git, 'source', resolvedRepositoryPath);
await git.pull();
returnItems.push({
json: {
@@ -713,19 +733,12 @@ export class Git implements INodeType {
await git.push(targetRepository);
} else {
const authentication = this.getNodeParameter('authentication', 0) as string;
const config = await git.listConfig();
const { validationTargets, pushTarget } = getRemoteOriginTargetRepositories(
config.values,
const { pushTarget } = await validateConfiguredRemoteRepositories(
git,
'target',
resolvedRepositoryPath,
);
for (const targetRepository of validationTargets) {
await assertRepositoryReferenceAllowed(
targetRepository,
'target',
resolvedRepositoryPath,
);
}
if (authentication === 'gitPassword') {
if (pushTarget === undefined) {
throw new NodeOperationError(this.getNode(), 'Target repository is required');
@@ -751,6 +764,7 @@ export class Git implements INodeType {
// pushTags
// ----------------------------------
await validateConfiguredRemoteRepositories(git, 'target', resolvedRepositoryPath);
await git.pushTags();
returnItems.push({
json: {
@@ -849,6 +863,7 @@ export class Git implements INodeType {
// ----------------------------------
const name = this.getNodeParameter('name', itemIndex, '') as string;
validateGitTag(name, this.getNode());
await git.addTag(name);
returnItems.push({
@@ -1,7 +1,8 @@
import { mockDeep } from 'jest-mock-extended';
import type { ConfigListSummary } from 'simple-git';
import type { INode } from 'n8n-workflow';
import { mapGitConfigList } from '../GenericFunctions';
import { getConfiguredRemoteRepositories, mapGitConfigList } from '../GenericFunctions';
describe('GenericFunctions', () => {
describe('mapGitConfigList', () => {
@@ -131,4 +132,43 @@ describe('GenericFunctions', () => {
]);
});
});
describe('getConfiguredRemoteRepositories', () => {
it('should return every configured remote URL as a validation target', () => {
const config = mockDeep<ConfigListSummary>({
values: {
'.git/config': {
'remote.origin.url': 'https://github.com/test/repo.git',
'remote.origin.pushurl': 'https://github.com/test/push-repo.git',
'remote.upstream.url': '/outside/source-repo',
'remote.upstream.pushurl': '/outside/target-repo',
'branch.main.remote': 'upstream',
},
},
});
const result = getConfiguredRemoteRepositories(config.values, mockDeep<INode>());
expect(result).toEqual({
sourceValidationTargets: ['https://github.com/test/repo.git', '/outside/source-repo'],
targetValidationTargets: ['https://github.com/test/push-repo.git', '/outside/target-repo'],
pushTarget: 'https://github.com/test/push-repo.git',
});
});
it('should keep using origin as the prepared push target', () => {
const config = mockDeep<ConfigListSummary>({
values: {
'.git/config': {
'remote.upstream.url': '/outside/source-repo',
'remote.origin.url': 'https://github.com/test/repo.git',
},
},
});
const result = getConfiguredRemoteRepositories(config.values, mockDeep<INode>());
expect(result.pushTarget).toBe('https://github.com/test/repo.git');
});
});
});
@@ -1,186 +0,0 @@
import { DeploymentConfig, SecurityConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import type { SimpleGit } from 'simple-git';
import simpleGit from 'simple-git';
import { Git } from '../Git.node';
const mockGit = {
log: jest.fn(),
env: jest.fn().mockReturnThis(),
};
jest.mock('simple-git');
const mockSimpleGit = simpleGit as jest.MockedFunction<typeof simpleGit>;
mockSimpleGit.mockReturnValue(mockGit as unknown as SimpleGit);
describe('Git Node', () => {
let gitNode: Git;
let executeFunctions: jest.Mocked<IExecuteFunctions>;
let deploymentConfig: jest.Mocked<DeploymentConfig>;
let securityConfig: jest.Mocked<SecurityConfig>;
beforeEach(() => {
jest.clearAllMocks();
deploymentConfig = mock<DeploymentConfig>({
type: 'default',
});
securityConfig = mock<SecurityConfig>({
disableBareRepos: false,
enableGitNodeHooks: true,
});
Container.set(DeploymentConfig, deploymentConfig);
Container.set(SecurityConfig, securityConfig);
executeFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNodeParameter: jest.fn(),
helpers: {
isFilePathBlocked: jest.fn(),
returnJsonArray: jest
.fn()
.mockImplementation((data: unknown[]) => data.map((item: unknown) => ({ json: item }))),
},
});
executeFunctions.getNodeParameter.mockImplementation((name: string) => {
switch (name) {
case 'operation':
return 'log';
case 'repositoryPath':
return '/tmp/test-repo';
case 'options':
return {};
default:
return '';
}
});
mockGit.log.mockResolvedValue({ all: [] });
gitNode = new Git();
});
describe('Bare Repository Configuration', () => {
it('should add safe.bareRepository=explicit when deployment type is cloud', async () => {
deploymentConfig.type = 'cloud';
securityConfig.disableBareRepos = false;
await gitNode.execute.call(executeFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['safe.bareRepository=explicit'],
}),
);
});
it('should add safe.bareRepository=explicit when disableBareRepos is true', async () => {
deploymentConfig.type = 'default';
securityConfig.disableBareRepos = true;
await gitNode.execute.call(executeFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['safe.bareRepository=explicit'],
}),
);
});
it('should add safe.bareRepository=explicit when both cloud and disableBareRepos are true', async () => {
deploymentConfig.type = 'cloud';
securityConfig.disableBareRepos = true;
await gitNode.execute.call(executeFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['safe.bareRepository=explicit'],
}),
);
});
it('should not add safe.bareRepository=explicit when neither cloud nor disableBareRepos is true', async () => {
deploymentConfig.type = 'default';
securityConfig.disableBareRepos = false;
await gitNode.execute.call(executeFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: [],
}),
);
});
});
describe('Hooks Configuration', () => {
it('should add core.hooksPath=/dev/null when enableGitNodeHooks is false', async () => {
securityConfig.enableGitNodeHooks = false;
await gitNode.execute.call(executeFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['core.hooksPath=/dev/null'],
}),
);
});
it('should not add core.hooksPath=/dev/null when enableGitNodeHooks is true', async () => {
securityConfig.enableGitNodeHooks = true;
await gitNode.execute.call(executeFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: [],
}),
);
});
});
describe('Restricted file paths', () => {
it('should throw an error if the repository path is blocked', async () => {
(executeFunctions.helpers.isFilePathBlocked as jest.Mock).mockReturnValue(true);
(executeFunctions.helpers.resolvePath as jest.Mock).mockResolvedValue('/tmp/test-repo');
await expect(gitNode.execute.call(executeFunctions)).rejects.toThrow(
'Access to the repository path is not allowed',
);
});
it('should use the resolved repository path for git operations', async () => {
const originalPath = '/tmp/link-to-repo';
const resolvedPath = '/tmp/actual-repo';
executeFunctions.getNodeParameter.mockImplementation((name: string) => {
switch (name) {
case 'operation':
return 'log';
case 'repositoryPath':
return originalPath;
case 'options':
return {};
default:
return '';
}
});
(executeFunctions.helpers.resolvePath as jest.Mock).mockResolvedValue(resolvedPath);
(executeFunctions.helpers.isFilePathBlocked as jest.Mock).mockReturnValue(false);
await gitNode.execute.call(executeFunctions);
// Verify git is initialized with the resolved path, not the original
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: resolvedPath,
}),
);
});
});
});
@@ -1,16 +1,19 @@
import { SecurityConfig } from '@n8n/config';
import { DeploymentConfig, SecurityConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, ResolvedFilePath } from 'n8n-workflow';
import { mkdir, rename, rm } from 'node:fs/promises';
import { dirname } from 'node:path';
import { dirname, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import simpleGit, { type SimpleGit } from 'simple-git';
import { Git } from '../Git.node';
import { ALLOWED_CONFIG_KEYS } from '../descriptions';
// Matches the unguessable staging directory the clone operation creates under the base.
const CLONE_STAGING_RE = /^\/git\/\.n8n-clone-[0-9a-f]{24}$/;
const CLONE_STAGING_RE = /^[\\/]git[\\/]\.n8n-clone-[0-9a-f]{24}$/;
const GIT_ROOT_RE = /^[\\/]git$/;
const GIT_NEW_REPO_RE = /^[\\/]git[\\/]new-repo$/;
// Mock simple-git
const mockGit = {
@@ -54,9 +57,22 @@ const mockRm = jest.mocked(rm);
describe('Git Node', () => {
let gitNode: Git;
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let deploymentConfig: jest.Mocked<DeploymentConfig>;
let securityConfig: jest.Mocked<SecurityConfig>;
beforeEach(() => {
jest.clearAllMocks();
gitNode = new Git();
deploymentConfig = mock<DeploymentConfig>({
type: 'default',
});
securityConfig = mock<SecurityConfig>({
disableBareRepos: false,
enableGitNodeHooks: true,
enableGitNodeAllConfigKeys: false,
});
Container.set(DeploymentConfig, deploymentConfig);
Container.set(SecurityConfig, securityConfig);
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn(() => [{ json: {} }]),
getNodeParameter: jest.fn(),
@@ -73,8 +89,22 @@ describe('Git Node', () => {
pinDirectory: jest.fn(async () => null),
},
});
jest.clearAllMocks();
mockExecuteFunctions.getNodeParameter.mockImplementation(
(name: string, _itemIndex: number, fallbackValue?: unknown) => {
switch (name) {
case 'operation':
return 'log';
case 'repositoryPath':
return '/repo';
case 'options':
return {};
default:
return fallbackValue ?? '';
}
},
);
mockGit.listConfig.mockResolvedValue({ values: {} } as any);
mockGit.log.mockResolvedValue({ all: [] } as any);
mockMkdir.mockResolvedValue(undefined);
mockRename.mockResolvedValue(undefined);
mockRm.mockResolvedValue(undefined);
@@ -106,6 +136,151 @@ describe('Git Node', () => {
});
});
describe('Bare repository configuration', () => {
it('should add safe.bareRepository=explicit when deployment type is cloud', async () => {
deploymentConfig.type = 'cloud';
securityConfig.disableBareRepos = false;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['safe.bareRepository=explicit'],
}),
);
});
it('should add safe.bareRepository=explicit when disableBareRepos is true', async () => {
deploymentConfig.type = 'default';
securityConfig.disableBareRepos = true;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['safe.bareRepository=explicit'],
}),
);
});
it('should add safe.bareRepository=explicit when both cloud and disableBareRepos are true', async () => {
deploymentConfig.type = 'cloud';
securityConfig.disableBareRepos = true;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['safe.bareRepository=explicit'],
}),
);
});
it('should not add safe.bareRepository=explicit when neither cloud nor disableBareRepos is true', async () => {
deploymentConfig.type = 'default';
securityConfig.disableBareRepos = false;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: [],
}),
);
});
});
describe('Hooks configuration', () => {
it('should add core.hooksPath=/dev/null when enableGitNodeHooks is false', async () => {
securityConfig.enableGitNodeHooks = false;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: ['core.hooksPath=/dev/null'],
}),
);
});
it('should opt into allowUnsafeHooksPath when enableGitNodeHooks is false', async () => {
securityConfig.enableGitNodeHooks = false;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
unsafe: { allowUnsafeHooksPath: true },
}),
);
});
it('should not add core.hooksPath=/dev/null when enableGitNodeHooks is true', async () => {
securityConfig.enableGitNodeHooks = true;
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
config: [],
}),
);
});
it('should not opt into allowUnsafeHooksPath when enableGitNodeHooks is true', async () => {
securityConfig.enableGitNodeHooks = true;
await gitNode.execute.call(mockExecuteFunctions);
const options = mockSimpleGit.mock.calls[0][0] as { unsafe?: unknown };
expect(options.unsafe).toBeUndefined();
});
});
describe('Restricted file paths', () => {
it('should throw an error if the repository path is blocked', async () => {
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(() => true);
mockExecuteFunctions.helpers.resolvePath = jest.fn(
async () => '/tmp/test-repo' as ResolvedFilePath,
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the repository path is not allowed',
);
});
it('should use the resolved repository path for git operations', async () => {
const originalPath = '/tmp/link-to-repo';
const resolvedPath = '/tmp/actual-repo';
mockExecuteFunctions.getNodeParameter.mockImplementation(
(name: string, _itemIndex: number, fallbackValue?: unknown) => {
switch (name) {
case 'operation':
return 'log';
case 'repositoryPath':
return originalPath;
case 'options':
return {};
default:
return fallbackValue ?? '';
}
},
);
mockExecuteFunctions.helpers.resolvePath = jest.fn(
async () => resolvedPath as ResolvedFilePath,
);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(() => false);
await gitNode.execute.call(mockExecuteFunctions);
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: resolvedPath,
}),
);
});
});
describe('Branch switching', () => {
const mockNodeParameters = (params: Record<string, unknown>) => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
@@ -764,6 +939,226 @@ describe('Git Node', () => {
expect(mockGit.addConfig).toHaveBeenCalledWith('user.name', 'test user', false);
});
describe('Configured remotes', () => {
const expectRemoteOperationNotCalled = (operation: 'fetch' | 'pull' | 'pushTags') => {
if (operation === 'fetch') {
expect(mockGit.fetch).not.toHaveBeenCalled();
} else if (operation === 'pull') {
expect(mockGit.pull).not.toHaveBeenCalled();
} else {
expect(mockGit.pushTags).not.toHaveBeenCalled();
}
};
it('should validate remote.origin.url before writing git config', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('addConfig')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('remote.origin.url')
.mockReturnValueOnce('/outside/repo');
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/repo',
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the source repository path is not allowed',
);
expect(mockGit.addConfig).not.toHaveBeenCalled();
});
it('should validate remote.origin.pushurl before writing git config when custom keys are enabled', async () => {
Container.set(
SecurityConfig,
mock<SecurityConfig>({
enableGitNodeAllConfigKeys: true,
}),
);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('addConfig')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('remote.origin.pushurl')
.mockReturnValueOnce('/outside/repo');
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/repo',
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the target repository path is not allowed',
);
expect(mockGit.addConfig).not.toHaveBeenCalled();
});
it.each([
{ key: 'remote.upstream.url', repositoryType: 'source' },
{ key: 'remote.upstream.pushurl', repositoryType: 'target' },
{ key: 'remote.team.upstream.url', repositoryType: 'source' },
{ key: 'remote.team.upstream.pushurl', repositoryType: 'target' },
{ key: 'remote.upstream.URL', repositoryType: 'source' },
{ key: 'remote.upstream.PushURL', repositoryType: 'target' },
])(
'should validate $key before writing git config when custom keys are enabled',
async ({ key, repositoryType }) => {
Container.set(
SecurityConfig,
mock<SecurityConfig>({
enableGitNodeAllConfigKeys: true,
}),
);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('addConfig')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce(key)
.mockReturnValueOnce('/outside/repo');
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/repo',
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
`Access to the ${repositoryType} repository path is not allowed`,
);
expect(mockGit.addConfig).not.toHaveBeenCalled();
},
);
it.each([
{ operation: 'fetch' as const, configKey: 'remote.origin.url', repositoryType: 'source' },
{
operation: 'fetch' as const,
configKey: 'remote.upstream.url',
repositoryType: 'source',
},
{ operation: 'pull' as const, configKey: 'remote.origin.url', repositoryType: 'source' },
{
operation: 'pull' as const,
configKey: 'remote.upstream.url',
repositoryType: 'source',
},
{
operation: 'pushTags' as const,
configKey: 'remote.origin.url',
repositoryType: 'target',
},
{
operation: 'pushTags' as const,
configKey: 'remote.origin.pushurl',
repositoryType: 'target',
},
{
operation: 'pushTags' as const,
configKey: 'remote.upstream.pushurl',
repositoryType: 'target',
},
])(
'should validate configured $configKey before $operation',
async ({ operation, configKey, repositoryType }) => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce(operation)
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({});
mockGit.listConfig.mockResolvedValueOnce({
values: {
'.git/config': {
'remote.origin.url': 'https://github.com/test/repo.git',
[configKey]: '/outside/repo',
},
},
} as any);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/repo',
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
`Access to the ${repositoryType} repository path is not allowed`,
);
expectRemoteOperationNotCalled(operation);
},
);
it.each(['fetch' as const, 'pull' as const])(
'should not validate configured push URLs before %s',
async (operation) => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce(operation)
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({});
mockGit.listConfig.mockResolvedValueOnce({
values: {
'.git/config': {
'remote.origin.url': 'https://github.com/test/repo.git',
'remote.origin.pushurl': '/outside/repo',
},
},
} as any);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/repo',
);
await gitNode.execute.call(mockExecuteFunctions);
if (operation === 'fetch') {
expect(mockGit.fetch).toHaveBeenCalled();
} else {
expect(mockGit.pull).toHaveBeenCalled();
}
},
);
it('should validate non-origin remotes before default push', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('push')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('none');
mockGit.listConfig.mockResolvedValueOnce({
values: {
'.git/config': {
'remote.origin.url': 'https://github.com/test/repo.git',
'remote.upstream.pushurl': '/outside/repo',
},
},
} as any);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/repo',
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the target repository path is not allowed',
);
expect(mockGit.push).not.toHaveBeenCalled();
});
it('should prefer push URLs over source URLs when validating default push targets', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('push')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('none');
mockGit.listConfig.mockResolvedValueOnce({
values: {
'.git/config': {
'remote.origin.url': '/outside/source-repo',
'remote.origin.pushurl': 'https://github.com/test/push-repo.git',
},
},
} as any);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/outside/source-repo',
);
await gitNode.execute.call(mockExecuteFunctions);
expect(mockGit.push).toHaveBeenCalled();
});
});
it('should handle clone operation and create the parent directory', async () => {
const missingParentError = Object.assign(new Error('Directory does not exist'), {
code: 'ENOENT',
@@ -783,7 +1178,9 @@ describe('Git Node', () => {
expect(mockExecuteFunctions.helpers.resolvePath).toHaveBeenCalledWith('/git/new-repo');
expect(mockExecuteFunctions.helpers.resolvePath).toHaveBeenCalledWith('/git');
expect(mockSimpleGit).toHaveBeenCalledWith(expect.objectContaining({ baseDir: '/git' }));
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({ baseDir: expect.stringMatching(GIT_ROOT_RE) }),
);
// The clone goes into an unguessable staging directory, not the target.
const stagingPath = mockGit.clone.mock.calls[0][1] as unknown as string;
@@ -796,11 +1193,11 @@ describe('Git Node', () => {
// The target is verified and then the staged clone is moved into place.
expect(
mockExecuteFunctions.helpers.ensureParentDirectoryWithoutFollowingSymlinks,
).toHaveBeenCalledWith('/git/new-repo');
).toHaveBeenCalledWith(expect.stringMatching(GIT_NEW_REPO_RE));
expect(mockExecuteFunctions.helpers.assertNoSymlinkInPath).toHaveBeenCalledWith(
'/git/new-repo',
expect.stringMatching(GIT_NEW_REPO_RE),
);
expect(mockRename).toHaveBeenCalledWith(stagingPath, '/git/new-repo');
expect(mockRename).toHaveBeenCalledWith(stagingPath, expect.stringMatching(GIT_NEW_REPO_RE));
expect(result[0]).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
});
@@ -834,7 +1231,9 @@ describe('Git Node', () => {
expect(
mockExecuteFunctions.helpers.ensureParentDirectoryWithoutFollowingSymlinks,
).toHaveBeenCalledWith('/git/existing-repo');
expect(mockSimpleGit).toHaveBeenCalledWith(expect.objectContaining({ baseDir: '/git' }));
expect(mockSimpleGit).toHaveBeenCalledWith(
expect.objectContaining({ baseDir: expect.stringMatching(GIT_ROOT_RE) }),
);
const stagingPath = mockGit.clone.mock.calls[0][1] as unknown as string;
expect(stagingPath).toMatch(CLONE_STAGING_RE);
expect(mockGit.clone).toHaveBeenCalledWith('https://github.com/test/repo.git', stagingPath, [
@@ -914,19 +1313,61 @@ describe('Git Node', () => {
expect(mockGit.clone).not.toHaveBeenCalled();
});
it('should check file URL source repository paths', async () => {
it('should not treat Unix local source repository paths with colons as scp-style remotes', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('clone')
.mockReturnValueOnce('/git/new-repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('file:/tmp/source-repo');
.mockReturnValueOnce('/blocked/source:repo');
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn(
(path: string) => path === '/blocked/source:repo',
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the source repository path is not allowed',
);
expect(mockSimpleGit).not.toHaveBeenCalled();
expect(mockGit.clone).not.toHaveBeenCalled();
});
it('should keep validating Windows local source repository paths', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('clone')
.mockReturnValueOnce('/git/new-repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('C:\\blocked\\source-repo');
mockExecuteFunctions.helpers.resolvePath = jest.fn(
async (path: string) => path as ResolvedFilePath,
);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn((path: string) =>
path.includes('C:\\blocked\\source-repo'),
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the source repository path is not allowed',
);
expect(mockSimpleGit).not.toHaveBeenCalled();
expect(mockGit.clone).not.toHaveBeenCalled();
});
it('should check file URL source repository paths', async () => {
const sourcePath = resolve(sep, 'tmp', 'source-repo');
const sourceRepository = pathToFileURL(sourcePath).href;
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('clone')
.mockReturnValueOnce('/git/new-repo')
.mockReturnValueOnce({})
.mockReturnValueOnce(sourceRepository);
await gitNode.execute.call(mockExecuteFunctions);
expect(mockExecuteFunctions.helpers.resolvePath).toHaveBeenCalledWith('/tmp/source-repo');
expect(mockExecuteFunctions.helpers.resolvePath).toHaveBeenCalledWith(sourcePath);
const stagingPath = mockGit.clone.mock.calls[0][1] as unknown as string;
expect(stagingPath).toMatch(CLONE_STAGING_RE);
expect(mockGit.clone).toHaveBeenCalledWith('file:/tmp/source-repo', stagingPath, ['--']);
expect(mockGit.clone).toHaveBeenCalledWith(sourceRepository, stagingPath, ['--']);
expect(mockRename).toHaveBeenCalledWith(stagingPath, '/git/new-repo');
});
@@ -948,6 +1389,25 @@ describe('Git Node', () => {
);
});
it('should reject malformed scp-like source repository references with extra at signs', async () => {
const sourceRepository = 'git@[email protected]:org/repo.git';
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('clone')
.mockReturnValueOnce('/git/new-repo')
.mockReturnValueOnce({})
.mockReturnValueOnce(sourceRepository);
mockExecuteFunctions.helpers.isFilePathBlocked = jest.fn((path: string) =>
path.includes('git@[email protected]:org'),
);
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Access to the source repository path is not allowed',
);
expect(mockSimpleGit).not.toHaveBeenCalled();
expect(mockGit.clone).not.toHaveBeenCalled();
});
it('should reject clone source repositories starting with a hyphen', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('clone')
@@ -1030,6 +1490,22 @@ describe('Git Node', () => {
expect(envArg.GIT_ALLOW_PROTOCOL).toBe('file:git:http:https:ssh');
});
it.each(['push', 'pushTags'])(
'should disable the file transport for %s when hooks are disabled',
async (operation) => {
securityConfig.enableGitNodeHooks = false;
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce(operation)
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({});
await gitNode.execute.call(mockExecuteFunctions);
const envArg = mockGit.env.mock.calls[0][0] as Record<string, string>;
expect(envArg.GIT_ALLOW_PROTOCOL).toBe('git:http:https:ssh');
},
);
it('should not place the repository when a target path component is a symlink', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('clone')
@@ -1188,6 +1664,48 @@ describe('Git Node', () => {
});
});
describe('Tag reference validation', () => {
it('should accept tag operation when name contains a plus sign', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('tag')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('v1.2.3+build.1');
await gitNode.execute.call(mockExecuteFunctions);
expect(mockGit.addTag).toHaveBeenCalledWith('v1.2.3+build.1');
});
it('should reject tag operation when name starts with a hyphen', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('tag')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('--force');
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Reference cannot start with a hyphen',
);
expect(mockGit.addTag).not.toHaveBeenCalled();
});
it('should reject tag operation when name contains unsafe characters', async () => {
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('tag')
.mockReturnValueOnce('/repo')
.mockReturnValueOnce({})
.mockReturnValueOnce('v1;id');
await expect(gitNode.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Reference contains unsafe characters',
);
expect(mockGit.addTag).not.toHaveBeenCalled();
});
});
describe('Error handling', () => {
it('should continue on fail when enabled', async () => {
mockExecuteFunctions.getNodeParameter
@@ -6,10 +6,36 @@ import type {
INodeListSearchResult,
INodeListSearchItems,
} from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import { configurePostgres } from './transport';
import type { PgpDatabase, PostgresNodeCredentials } from './v2/helpers/interfaces';
const postgresIdentifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const triggerEvents = ['INSERT', 'UPDATE', 'DELETE'] as const;
type TriggerEvent = (typeof triggerEvents)[number];
export function validatePostgresIdentifier(
name: unknown,
parameterName: string,
): asserts name is string {
if (typeof name !== 'string' || !postgresIdentifierRegex.test(name)) {
throw new UserError(
`${parameterName} must start with a letter or underscore and contain only letters, digits, and underscores`,
{ level: 'warning' },
);
}
}
function getTriggerEvent(firesOn: string): TriggerEvent {
if (triggerEvents.includes(firesOn as TriggerEvent)) {
return firesOn as TriggerEvent;
}
throw new UserError('Event must be Insert, Update, or Delete', { level: 'warning' });
}
export function prepareNames(id: string, mode: string, additionalFields: IDataObject) {
let suffix = id.replace(/-/g, '_');
@@ -17,19 +43,18 @@ export function prepareNames(id: string, mode: string, additionalFields: IDataOb
suffix = `${suffix}_manual`;
}
let functionName =
(additionalFields.functionName as string) || `n8n_trigger_function_${suffix}()`;
let functionName = (additionalFields.functionName as string) || `n8n_trigger_function_${suffix}`;
if (!(functionName.includes('(') && functionName.includes(')'))) {
functionName = `${functionName}()`;
if (typeof functionName === 'string' && functionName.endsWith('()')) {
functionName = functionName.slice(0, -2);
}
const triggerName = (additionalFields.triggerName as string) || `n8n_trigger_${suffix}`;
const channelName = (additionalFields.channelName as string) || `n8n_channel_${suffix}`;
if (channelName.includes('-')) {
throw new ApplicationError('Channel name cannot contain hyphens (-)', { level: 'warning' });
}
validatePostgresIdentifier(functionName, 'Function name');
validatePostgresIdentifier(triggerName, 'Trigger name');
validatePostgresIdentifier(channelName, 'Channel name');
return { functionName, triggerName, channelName };
}
@@ -47,43 +72,28 @@ export async function pgTriggerFunction(
extractValue: true,
}) as string;
const target = `${schema}."${tableName}"`;
const firesOn = getTriggerEvent(this.getNodeParameter('firesOn', 0) as string);
const rowRecord = firesOn === 'DELETE' ? 'OLD' : 'NEW';
const firesOn = this.getNodeParameter('firesOn', 0) as string;
// Identifiers use :name (double-quote escaped); the channel is bound as a text
// value to pg_notify, and the event/row-record are validated keywords.
const functionBody = `$1:name() RETURNS trigger LANGUAGE 'plpgsql' COST 100 VOLATILE NOT LEAKPROOF AS $BODY$ begin perform pg_notify($2, row_to_json(${rowRecord})::text); return null; end; $BODY$`;
const functionReplace = `CREATE OR REPLACE FUNCTION ${functionBody};`;
const functionExists = `CREATE FUNCTION ${functionBody}`;
const functionReplace =
"CREATE OR REPLACE FUNCTION $1:raw RETURNS trigger LANGUAGE 'plpgsql' COST 100 VOLATILE NOT LEAKPROOF AS $BODY$ begin perform pg_notify('$2:raw', row_to_json($3:raw)::text); return null; end; $BODY$;";
const dropIfExist = 'DROP TRIGGER IF EXISTS $1:name ON $2:name.$3:name';
const dropIfExist = 'DROP TRIGGER IF EXISTS $1:raw ON $2:raw';
const functionExists =
"CREATE FUNCTION $1:raw RETURNS trigger LANGUAGE 'plpgsql' COST 100 VOLATILE NOT LEAKPROOF AS $BODY$ begin perform pg_notify('$2:raw', row_to_json($3:raw)::text); return null; end; $BODY$";
const trigger =
'CREATE TRIGGER $4:raw AFTER $3:raw ON $1:raw FOR EACH ROW EXECUTE FUNCTION $2:raw';
const whichData = firesOn === 'DELETE' ? 'old' : 'new';
if (channelName.includes('-')) {
throw new ApplicationError('Channel name cannot contain hyphens (-)', { level: 'warning' });
}
const trigger = `CREATE TRIGGER $3:name AFTER ${firesOn} ON $1:name.$2:name FOR EACH ROW EXECUTE FUNCTION $4:name()`;
const replaceIfExists = additionalFields.replaceIfExists ?? false;
try {
if (replaceIfExists || !(additionalFields.triggerName ?? additionalFields.functionName)) {
await db.any(functionReplace, [functionName, channelName, whichData]);
await db.any(dropIfExist, [triggerName, target, whichData]);
} else {
await db.any(functionExists, [functionName, channelName, whichData]);
}
await db.any(trigger, [target, functionName, firesOn, triggerName]);
} catch (error) {
if ((error as Error).message.includes('near "-"')) {
throw new ApplicationError('Names cannot contain hyphens (-)', { level: 'warning' });
}
throw error;
if (replaceIfExists || !(additionalFields.triggerName ?? additionalFields.functionName)) {
await db.any(functionReplace, [functionName, channelName]);
await db.any(dropIfExist, [triggerName, schema, tableName]);
} else {
await db.any(functionExists, [functionName, channelName]);
}
await db.any(trigger, [schema, tableName, triggerName, functionName]);
}
export async function initDB(this: ITriggerFunctions | ILoadOptionsFunctions) {
@@ -14,6 +14,7 @@ import {
searchSchema,
searchTables,
prepareNames,
validatePostgresIdentifier,
} from './PostgresTrigger.functions';
export class PostgresTrigger implements INodeType {
@@ -252,6 +253,18 @@ export class PostgresTrigger implements INodeType {
const triggerMode = this.getNodeParameter('triggerMode', 0) as string;
const additionalFields = this.getNodeParameter('additionalFields', 0) as IDataObject;
// Resolve and validate identifiers before opening a connection, so an
// invalid name fails fast without leaking a direct connection.
const pgNames = prepareNames(this.getNode().id, this.getMode(), additionalFields);
if (triggerMode === 'listenTrigger') {
pgNames.channelName = this.getNodeParameter('channelName', '') as string;
validatePostgresIdentifier(pgNames.channelName, 'Channel name');
// Postgres folds unquoted LISTEN identifiers to lower case; since :name
// quotes the channel, lower-case it so an existing channel notified as
// e.g. `NOTIFY MyChannel` is still matched.
pgNames.channelName = pgNames.channelName.toLowerCase();
}
// initialize and connect to database
const { db } = await initDB.call(this);
const connection = await db.connect({ direct: true });
@@ -266,23 +279,27 @@ export class PostgresTrigger implements INodeType {
this.emit([this.helpers.returnJsonArray([data])]);
};
// create trigger, function and channel or use existing channel
const pgNames = prepareNames(this.getNode().id, this.getMode(), additionalFields);
if (triggerMode === 'createTrigger') {
await pgTriggerFunction.call(
this,
db,
additionalFields,
pgNames.functionName,
pgNames.triggerName,
pgNames.channelName,
);
} else {
pgNames.channelName = this.getNodeParameter('channelName', '') as string;
}
// Release the direct connection if setup fails, so an error while creating
// the trigger or issuing LISTEN doesn't leak it.
try {
// create trigger and function, or use the existing channel
if (triggerMode === 'createTrigger') {
await pgTriggerFunction.call(
this,
db,
additionalFields,
pgNames.functionName,
pgNames.triggerName,
pgNames.channelName,
);
}
// listen to channel
await connection.none(`LISTEN ${pgNames.channelName}`);
// listen to channel
await connection.none('LISTEN $1:name', [pgNames.channelName]);
} catch (error) {
await connection.done();
throw error;
}
const cleanUpDb = async () => {
try {
@@ -298,10 +315,7 @@ export class PostgresTrigger implements INodeType {
try {
await connection.none('UNLISTEN $1:name', [pgNames.channelName]);
if (triggerMode === 'createTrigger') {
const functionName = pgNames.functionName.includes('(')
? pgNames.functionName.split('(')[0]
: pgNames.functionName;
await connection.any('DROP FUNCTION IF EXISTS $1:name CASCADE', [functionName]);
await connection.any('DROP FUNCTION IF EXISTS $1:name CASCADE', [pgNames.functionName]);
const schema = this.getNodeParameter('schema', undefined, {
extractValue: true,
@@ -0,0 +1,276 @@
import type { IDataObject, ITriggerFunctions } from 'n8n-workflow';
import pgPromise from 'pg-promise';
import {
pgTriggerFunction,
prepareNames,
validatePostgresIdentifier,
} from '../PostgresTrigger.functions';
import { PostgresTrigger } from '../PostgresTrigger.node';
import { configurePostgres } from '../transport';
jest.mock('../transport', () => ({ configurePostgres: jest.fn() }));
const invalidIdentifiers = [
'name; select 1',
'name"',
"name'",
'name()',
'name.other',
'a-b',
'1abc',
'has space',
'',
];
describe('validatePostgresIdentifier', () => {
it.each(['n8n_channel', '_fn1', 'MyTrigger', 'a', 'x_1_y'])(
'accepts the valid identifier %s',
(name) => {
expect(() => validatePostgresIdentifier(name, 'Channel name')).not.toThrow();
},
);
it.each(invalidIdentifiers)('rejects the invalid identifier %j', (name) => {
expect(() => validatePostgresIdentifier(name, 'Channel name')).toThrow(/Channel name must/);
});
});
describe('prepareNames', () => {
it('returns safe defaults when no names are provided', () => {
const names = prepareNames('abc-123', 'trigger', {});
expect(names.functionName).toBe('n8n_trigger_function_abc_123');
expect(names.triggerName).toBe('n8n_trigger_abc_123');
expect(names.channelName).toBe('n8n_channel_abc_123');
});
it('strips a trailing () from a provided function name', () => {
const names = prepareNames('id1', 'trigger', { functionName: 'my_fn()' });
expect(names.functionName).toBe('my_fn');
});
it.each([
['channelName', { channelName: 'name; select 1' }],
['functionName', { functionName: 'name(); select 1' }],
['triggerName', { triggerName: 'name on t' }],
])('rejects an invalid %s', (_field, additionalFields) => {
expect(() => prepareNames('id1', 'trigger', additionalFields as IDataObject)).toThrow();
});
});
describe('pgTriggerFunction', () => {
const createContext = (params: IDataObject) => {
const getNodeParameter = jest.fn((name: string, _i?: unknown, fallback?: unknown) =>
name in params ? params[name] : fallback,
);
return { getNodeParameter } as unknown as ITriggerFunctions;
};
const invoke = (firesOn: string, additionalFields: IDataObject = { replaceIfExists: true }) => {
const db = { any: jest.fn().mockResolvedValue([]) };
const ctx = createContext({ schema: 'public', tableName: 'users', firesOn });
const run = async () =>
await pgTriggerFunction.call(
ctx,
db as never,
additionalFields,
'my_fn',
'my_trigger',
'my_channel',
);
const sqls = () => db.any.mock.calls.map(([sql]) => sql as string);
return { db, run, sqls };
};
it('never emits a :raw modifier in any executed statement', async () => {
const { db, run } = invoke('INSERT');
await run();
expect(db.any).toHaveBeenCalled();
for (const [sql] of db.any.mock.calls) {
expect(sql).not.toContain(':raw');
}
});
it('produces templates pg-promise can format without error', async () => {
const { db, run } = invoke('INSERT');
await run();
const pgp = pgPromise();
for (const [sql, params] of db.any.mock.calls) {
expect(() => pgp.as.format(sql as string, params)).not.toThrow();
}
});
it('passes schema and table as separate identifier parameters', async () => {
const { db, run } = invoke('INSERT');
await run();
const dropCall = db.any.mock.calls.find(([sql]) => (sql as string).includes('DROP TRIGGER'));
expect(dropCall?.[1]).toEqual(['my_trigger', 'public', 'users']);
});
it('uses the OLD row record for DELETE triggers', async () => {
const { run, sqls } = invoke('DELETE');
await run();
const notifySql = sqls().find((s) => s.includes('pg_notify'));
expect(notifySql).toContain('row_to_json(OLD)');
});
it('embeds the validated event keyword in the trigger', async () => {
const { run, sqls } = invoke('UPDATE');
await run();
const triggerSql = sqls().find((s) => s.includes('CREATE TRIGGER'));
expect(triggerSql).toContain('AFTER UPDATE');
});
it('creates without OR REPLACE and skips the drop when a name is provided', async () => {
const { run, sqls } = invoke('INSERT', { triggerName: 'my_trigger' });
await run();
expect(sqls().some((s) => s.startsWith('CREATE FUNCTION'))).toBe(true);
expect(sqls().some((s) => s.startsWith('CREATE OR REPLACE FUNCTION'))).toBe(false);
expect(sqls().some((s) => s.includes('DROP TRIGGER'))).toBe(false);
});
it('rejects an invalid firesOn event', async () => {
const { run } = invoke('DROP');
await expect(run()).rejects.toThrow();
});
});
describe('PostgresTrigger.trigger (Table Row Change Events mode)', () => {
const setup = (additionalFields: IDataObject = {}, firesOn = 'INSERT') => {
const connection = {
none: jest.fn().mockResolvedValue(undefined),
any: jest.fn().mockResolvedValue([]),
query: jest.fn().mockResolvedValue([]),
done: jest.fn().mockResolvedValue(undefined),
client: { on: jest.fn(), removeListener: jest.fn() },
};
const db = {
connect: jest.fn().mockResolvedValue(connection),
any: jest.fn().mockResolvedValue([]),
};
jest.mocked(configurePostgres).mockResolvedValue({ db, pgp: {} } as never);
const params: IDataObject = {
triggerMode: 'createTrigger',
additionalFields,
options: {},
schema: 'public',
tableName: 'users',
firesOn,
};
const fns = {
getNodeParameter: jest.fn((name: string, _i?: unknown, fallback?: unknown) =>
name in params ? params[name] : fallback,
),
getCredentials: jest.fn().mockResolvedValue({}),
getNode: jest.fn().mockReturnValue({ id: 'node-1' }),
getMode: jest.fn().mockReturnValue('trigger'),
emit: jest.fn(),
helpers: { returnJsonArray: jest.fn((d: unknown) => d) },
} as unknown as ITriggerFunctions;
return { connection, db, fns };
};
it('creates the trigger objects without any :raw modifier', async () => {
const { db, fns } = setup();
await new PostgresTrigger().trigger.call(fns);
expect(db.any).toHaveBeenCalled();
for (const [sql] of db.any.mock.calls) {
expect(sql).not.toContain(':raw');
}
});
it('drops the created objects with matching identifiers on close', async () => {
const { connection, fns } = setup();
const response = await new PostgresTrigger().trigger.call(fns);
await response.closeFunction?.();
expect(connection.any).toHaveBeenCalledWith('DROP FUNCTION IF EXISTS $1:name CASCADE', [
'n8n_trigger_function_node_1',
]);
expect(connection.any).toHaveBeenCalledWith(
'DROP TRIGGER IF EXISTS $1:name ON $2:name.$3:name CASCADE',
['n8n_trigger_node_1', 'public', 'users'],
);
});
it('releases the connection when setup fails', async () => {
const { connection, fns } = setup({}, 'INVALID');
await expect(new PostgresTrigger().trigger.call(fns)).rejects.toThrow();
expect(connection.done).toHaveBeenCalled();
});
});
describe('PostgresTrigger.trigger (Advanced mode)', () => {
const setup = (channelName: string) => {
const connection = {
none: jest.fn().mockResolvedValue(undefined),
client: { on: jest.fn(), removeListener: jest.fn() },
};
const db = { connect: jest.fn().mockResolvedValue(connection) };
jest.mocked(configurePostgres).mockResolvedValue({ db, pgp: {} } as never);
const params: IDataObject = {
triggerMode: 'listenTrigger',
additionalFields: {},
options: {},
channelName,
};
const fns = {
getNodeParameter: jest.fn((name: string, _i?: unknown, fallback?: unknown) =>
name in params ? params[name] : fallback,
),
getCredentials: jest.fn().mockResolvedValue({}),
getNode: jest.fn().mockReturnValue({ id: 'node-1' }),
getMode: jest.fn().mockReturnValue('trigger'),
emit: jest.fn(),
helpers: { returnJsonArray: jest.fn((d: unknown) => d) },
} as unknown as ITriggerFunctions;
return { connection, db, fns };
};
it('listens via a parameterized identifier', async () => {
const { connection, fns } = setup('my_channel');
await new PostgresTrigger().trigger.call(fns);
expect(connection.none).toHaveBeenCalledWith('LISTEN $1:name', ['my_channel']);
});
it('lower-cases the channel to match unquoted notifications', async () => {
const { connection, fns } = setup('MyChannel');
await new PostgresTrigger().trigger.call(fns);
expect(connection.none).toHaveBeenCalledWith('LISTEN $1:name', ['mychannel']);
});
it('rejects an invalid channel before opening a connection', async () => {
const { db, connection, fns } = setup('bad; select 1');
await expect(new PostgresTrigger().trigger.call(fns)).rejects.toThrow(/Channel name must/);
expect(db.connect).not.toHaveBeenCalled();
expect(connection.none).not.toHaveBeenCalled();
});
});
describe('pg-promise identifier and value formatting', () => {
const pgp = pgPromise();
it('wraps a :name identifier in a single double-quoted token', () => {
const formatted = pgp.as.format('LISTEN $1:name', ['a"b']);
expect(formatted).toBe('LISTEN "a""b"');
});
it('formats a bound value inside a dollar-quoted body as an escaped literal', () => {
const formatted = pgp.as.format("AS $BODY$ begin perform pg_notify($1, 'x'); end; $BODY$", [
"a'b",
]);
expect(formatted).toContain("pg_notify('a''b',");
expect(formatted).toContain('$BODY$');
});
});
@@ -49,6 +49,29 @@ describe('prepareReturnItem', () => {
(mockContext.getNodeParameter as jest.Mock).mockReturnValue('');
});
describe('when an assignment name is an inherited object member', () => {
// Guard the shared prototype so a regression here cannot cascade to other tests.
const hadOwnCall = Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call');
afterEach(() => {
if (!hadOwnCall) {
delete (Object.prototype.toString as unknown as { call?: unknown }).call;
}
});
it('keeps built-in object prototypes intact', () => {
const value: AssignmentCollectionValue = {
assignments: [{ id: '1', name: 'toString.call', value: 'x', type: 'string' }],
};
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
// Prototype untouched; the value lands as an own property on the item's json.
expect(Object.prototype.hasOwnProperty.call(Object.prototype.toString, 'call')).toBe(false);
expect(Object.prototype.toString.call([])).toBe('[object Array]');
expect((result.json as { toString?: { call?: unknown } }).toString?.call).toBe('x');
});
});
describe('JSON assignments', () => {
it('should handle string assignments', () => {
const value: AssignmentCollectionValue = {
@@ -111,11 +111,7 @@ export function escapeSnowflakeObjectIdentifier(identifier: string): string {
return parts.map(escapeSnowflakeIdentifier).join('.');
}
export async function execute(
conn: snowflake.Connection,
sqlText: string,
binds: snowflake.InsertBinds,
) {
export async function execute(conn: snowflake.Connection, sqlText: string, binds: snowflake.Binds) {
return await new Promise<any[] | undefined>((resolve, reject) => {
conn.execute({
sqlText,
@@ -5,7 +5,7 @@ import type {
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import snowflake from 'snowflake-sdk';
import { getResolvables } from '@utils/utilities';
@@ -89,7 +89,33 @@ export class Snowflake implements INodeType {
default: '',
placeholder: 'SELECT id, name FROM product WHERE id < 40',
required: true,
description: 'The SQL query to execute',
description:
"The SQL query to execute. Use ? (bound in order) or :1, :2, :3 to refer to the 'Query Parameters' set in the options below.",
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
operation: ['executeQuery'],
},
},
default: {},
placeholder: 'Add option',
options: [
{
displayName: 'Query Parameters',
name: 'queryReplacement',
type: 'string',
default: '',
placeholder: 'e.g. value1,value2,value3',
description:
'Comma-separated list of the values you want to use as query parameters. You can drag the values from the input panel on the left.',
hint: 'Comma-separated list of values: reference them in your query as ? (in order) or :1, :2, :3…',
},
],
},
// ----------------------------------
@@ -195,7 +221,25 @@ export class Snowflake implements INodeType {
query = query.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
const responseData = await execute(connection, query, []);
const options = this.getNodeParameter('options', i, {}) as IDataObject;
const rawReplacement = options.queryReplacement;
let binds: snowflake.Bind[] = [];
if (rawReplacement !== undefined && rawReplacement !== '') {
if (typeof rawReplacement === 'string') {
binds = rawReplacement.split(',').map((entry) => entry.trim());
} else if (Array.isArray(rawReplacement)) {
binds = rawReplacement as snowflake.Bind[];
} else {
throw new NodeOperationError(
this.getNode(),
'Query Parameters must be a string of comma-separated values, or an array of values',
{ itemIndex: i },
);
}
}
const responseData = await execute(connection, query, binds);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
@@ -217,7 +261,7 @@ export class Snowflake implements INodeType {
const query = `INSERT INTO ${quotedTable} (${quotedColumns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`;
const data = this.helpers.copyInputItems(items, columns);
const binds = data.map((element) => [...Object.values(element)]);
await execute(connection, query, binds as unknown as snowflake.InsertBinds);
await execute(connection, query, binds as snowflake.Binds);
data.forEach((d, i) => {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(d),
@@ -256,7 +300,7 @@ export class Snowflake implements INodeType {
return rowBinds;
});
for (let i = 0; i < binds.length; i++) {
await execute(connection, query, binds[i] as unknown as snowflake.InsertBinds);
await execute(connection, query, binds[i] as snowflake.Binds);
}
data.forEach((d, i) => {
const executionData = this.helpers.constructExecutionMetaData(
@@ -0,0 +1,156 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import { NodeConnectionTypes, type WorkflowTestData } from 'n8n-workflow';
const mockExecute = jest.fn();
const mockConnect = jest.fn();
const mockDestroy = jest.fn();
const mockConnection = { connect: mockConnect, execute: mockExecute, destroy: mockDestroy };
jest.mock('snowflake-sdk', () => ({
configure: jest.fn(),
createConnection: jest.fn().mockReturnValue(mockConnection),
}));
const snowflakeCredentials = {
authentication: 'password',
account: 'test-account',
database: 'TEST_DB',
schema: 'PUBLIC',
warehouse: 'WH',
role: 'SYSADMIN',
clientSessionKeepAlive: false,
username: 'user',
password: 'pass',
};
beforeEach(() => {
mockConnect.mockImplementation((callback: (err: null) => void) => callback(null));
mockDestroy.mockImplementation((callback: (err: null) => void) => callback(null));
mockExecute.mockImplementation(
({ complete }: { complete: (err: null, stmt: undefined, rows: unknown[]) => void }) =>
complete(null, undefined, []),
);
});
afterEach(() => jest.clearAllMocks());
// A manual trigger (one empty item) feeding a single executeQuery Snowflake node.
function executeQueryWorkflow(
query: string,
queryReplacement: string,
): WorkflowTestData['input']['workflowData'] {
return {
nodes: [
{
parameters: {},
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'When clicking "Execute Workflow"',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [460, 460],
},
{
parameters: { operation: 'executeQuery', query, options: { queryReplacement } },
id: 'c3d4e5f6-a7b8-9012-cdef-123456789012',
name: 'Snowflake',
type: 'n8n-nodes-base.snowflake',
typeVersion: 1,
position: [680, 460],
credentials: { snowflake: { id: '1', name: 'Snowflake account' } },
},
],
connections: {
'When clicking "Execute Workflow"': {
main: [[{ node: 'Snowflake', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
};
}
describe('Test Snowflake, executeQuery - query parameters are bound', () => {
new NodeTestHarness().setupTests({
workflowFiles: ['executeQuery.workflow.json'],
credentials: { snowflake: snowflakeCredentials },
customAssertions() {
// The value is sent as a ? bind; it never touches the SQL string.
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
sqlText: 'SELECT * FROM users WHERE name = ?',
binds: ["O'Brien"],
}),
);
// The bound value must not appear in any executed statement.
for (const [{ sqlText }] of mockExecute.mock.calls as Array<[{ sqlText: string }]>) {
expect(sqlText).not.toContain("O'Brien");
}
},
});
});
describe('Test Snowflake, executeQuery - comma-separated query parameters', () => {
new NodeTestHarness().setupTest(
{
description: 'splits on commas, trims, and binds in placeholder order',
input: {
workflowData: executeQueryWorkflow(
'SELECT * FROM users WHERE first = ? AND last = ?',
"Ada, O'Brien",
),
},
output: { nodeData: {} },
},
{
credentials: { snowflake: snowflakeCredentials },
customAssertions() {
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
sqlText: 'SELECT * FROM users WHERE first = ? AND last = ?',
binds: ['Ada', "O'Brien"],
}),
);
},
},
);
});
describe('Test Snowflake, executeQuery - query parameters provided as an array', () => {
new NodeTestHarness().setupTest(
{
description: 'binds each array entry positionally',
input: {
workflowData: executeQueryWorkflow(
'SELECT * FROM users WHERE first = ? AND last = ?',
"={{ ['Ada', 'Lovelace'] }}",
),
},
output: { nodeData: {} },
},
{
credentials: { snowflake: snowflakeCredentials },
customAssertions() {
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
sqlText: 'SELECT * FROM users WHERE first = ? AND last = ?',
binds: ['Ada', 'Lovelace'],
}),
);
},
},
);
});
describe('Test Snowflake, executeQuery - invalid query parameters', () => {
new NodeTestHarness().setupTest(
{
description: 'errors when query parameters are neither a string nor an array',
input: {
workflowData: executeQueryWorkflow('SELECT * FROM users WHERE first = ?', '={{ 42 }}'),
},
output: {
nodeData: {},
error: 'Query Parameters must be a string of comma-separated values, or an array of values',
},
},
{ credentials: { snowflake: snowflakeCredentials } },
);
});
@@ -0,0 +1,64 @@
{
"name": "Snowflake executeQuery - query parameters",
"nodes": [
{
"parameters": {},
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [460, 460]
},
{
"parameters": {
"fields": {
"values": [{ "name": "q", "stringValue": "O'Brien" }]
},
"include": "none",
"options": {}
},
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [680, 460]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT * FROM users WHERE name = ?",
"options": {
"queryReplacement": "={{ $json.q }}"
}
},
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"name": "Snowflake Execute Query",
"type": "n8n-nodes-base.snowflake",
"typeVersion": 1,
"position": [900, 460],
"credentials": {
"snowflake": {
"id": "1",
"name": "Snowflake account"
}
}
}
],
"pinData": {},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]]
},
"Edit Fields": {
"main": [[{ "node": "Snowflake Execute Query", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": { "executionOrder": "v1" },
"versionId": "d4e5f6a7-b8c9-0123-defa-234567890124",
"id": "SnowflakeExecuteQueryParameters",
"meta": {
"instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363"
},
"tags": []
}
@@ -0,0 +1,46 @@
import set from 'lodash/set';
// Guards the [email protected] patch (patches/[email protected]): while walking a
// dot-notation path, `baseSet` reuses the value already present at a segment only
// when it is an own property, otherwise it creates a fresh object. A segment that
// matches an inherited member is therefore stored as plain own data instead of
// being followed onto an object shared through the prototype chain. These tests
// fail if the patch is ever dropped (e.g. on a lodash bump).
describe('lodash/set patch', () => {
const originalToStringCall = Object.prototype.toString.call;
afterEach(() => {
Object.prototype.toString.call = originalToStringCall;
});
it('writes a top-level built-in-named segment as own data', () => {
const obj: Record<string, unknown> = {};
set(obj, 'toString.call', 'x');
// Read the global into a local and restore it before any expect(), since
// expect()/toEqual themselves use Object.prototype.toString.call.
const callType = typeof Object.prototype.toString.call;
Object.prototype.toString.call = originalToStringCall;
expect(callType).toBe('function');
expect(obj).toEqual({ toString: { call: 'x' } });
});
it('writes a nested built-in-named segment as own data', () => {
const obj: Record<string, unknown> = {};
set(obj, 'a.toString.call', 'x');
const callType = typeof Object.prototype.toString.call;
Object.prototype.toString.call = originalToStringCall;
expect(callType).toBe('function');
expect(obj).toEqual({ a: { toString: { call: 'x' } } });
});
it('preserves normal and array paths', () => {
const obj: Record<string, unknown> = {};
set(obj, 'a.b.c', 1);
set(obj, 'list[0].name', 'n');
expect(obj).toEqual({ a: { b: { c: 1 } }, list: [{ name: 'n' }] });
});
});
+26
View File
@@ -0,0 +1,26 @@
diff --git a/_baseSet.js b/_baseSet.js
index 99f4fbf9c347a74594f975df62ee353d06c85c55..316f9e8b537e294230c5123789c224c41a7034e8 100644
--- a/_baseSet.js
+++ b/_baseSet.js
@@ -37,7 +37,7 @@ function baseSet(object, path, value, customizer) {
var objValue = nested[key];
newValue = customizer ? customizer(objValue, key, nested) : undefined;
if (newValue === undefined) {
- newValue = isObject(objValue)
+ newValue = isObject(objValue) && Object.prototype.hasOwnProperty.call(nested, key)
? objValue
: (isIndex(path[index + 1]) ? [] : {});
}
diff --git a/lodash.js b/lodash.js
index 902bfd4ee8f96898526f4776b061ce1d1c189ecc..c45042ae7e99ea0b900eb665989bcb72bf880b0f 100644
--- a/lodash.js
+++ b/lodash.js
@@ -4045,7 +4045,7 @@
var objValue = nested[key];
newValue = customizer ? customizer(objValue, key, nested) : undefined;
if (newValue === undefined) {
- newValue = isObject(objValue)
+ newValue = isObject(objValue) && Object.prototype.hasOwnProperty.call(nested, key)
? objValue
: (isIndex(path[index + 1]) ? [] : {});
}
+86 -73
View File
@@ -267,8 +267,8 @@ overrides:
tmp: 0.2.7
ws: 8.21.0
axios: 1.18.0
vue-i18n@<=11.4.4: 11.4.4
hono@<=4.12.27: 4.12.27
vue-i18n@<=11.4.4: 11.4.4
linkify-it@<=5.0.1: 5.0.1
patchedDependencies:
@@ -296,6 +296,9 @@ patchedDependencies:
js-base64:
hash: bb02fdf69495c7b0768791b60ab6e1a002053b8decd19a174f5755691e5c9500
path: patches/js-base64.patch
[email protected]:
hash: c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39
path: patches/[email protected]
minifaker:
hash: bc707e2c34a2464da2c9c93ead33e80fd9883a00434ef64907ddc45208a08b33
path: patches/minifaker.patch
@@ -444,7 +447,7 @@ importers:
version: 0.6.0(@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected]))([email protected]([email protected])([email protected]))
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
n8n-workflow:
specifier: workspace:*
version: link:../../workflow
@@ -739,7 +742,7 @@ importers:
version: 3.4.2
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
n8n-core:
specifier: workspace:*
version: link:../../core
@@ -788,7 +791,7 @@ importers:
version: link:../permissions
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
n8n-workflow:
specifier: workspace:*
version: link:../../workflow
@@ -952,7 +955,7 @@ importers:
version: 3.3.1
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
luxon:
specifier: 'catalog:'
version: 3.4.4
@@ -1202,7 +1205,7 @@ importers:
version: 1.0.1(@langchain/[email protected](@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected]))([email protected]([email protected])([email protected])))([email protected])
'@langchain/community':
specifier: 'catalog:'
version: 1.1.18(3cb93a0b6e411dc400a0c826af4a0e9e)
version: 1.1.18(2acfdd5f5cb130a81dbfd044a10b3ba6)
'@langchain/core':
specifier: 'catalog:'
version: 1.1.8(@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected]))([email protected]([email protected])([email protected]))
@@ -1325,7 +1328,7 @@ importers:
version: 1.2.3(@langchain/[email protected](@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected]))([email protected]([email protected])([email protected])))(@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))([email protected]([email protected]([email protected])([email protected]))([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mammoth:
specifier: 1.11.0
version: 1.11.0
@@ -1505,7 +1508,7 @@ importers:
version: 8.3.4
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
luxon:
specifier: 'catalog:'
version: 3.4.4
@@ -1751,7 +1754,7 @@ importers:
version: 4.2.6
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
luxon:
specifier: 'catalog:'
version: 3.4.4
@@ -2049,7 +2052,7 @@ importers:
version: 9.0.2
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
luxon:
specifier: 'catalog:'
version: 3.4.4
@@ -2298,7 +2301,7 @@ importers:
version: 0.0.5
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
markdown-it:
specifier: ^13.0.2
version: 13.0.2
@@ -2791,7 +2794,7 @@ importers:
version: 10.4.0
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
luxon:
specifier: 'catalog:'
version: 3.4.4
@@ -3111,7 +3114,7 @@ importers:
version: 4.2.6
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
lossless-json:
specifier: 1.0.5
version: 1.0.5
@@ -3213,7 +3216,7 @@ importers:
version: 3.0.3
ts-ics:
specifier: 1.2.2
version: 1.2.2([email protected])([email protected])([email protected])
version: 1.2.2([email protected])([email protected](patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39))([email protected])
uuid:
specifier: 'catalog:'
version: 11.1.1
@@ -3427,7 +3430,7 @@ importers:
version: 3.3.1
lodash:
specifier: 4.18.0
version: 4.18.0
version: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
luxon:
specifier: 'catalog:'
version: 3.4.4
@@ -17654,8 +17657,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
[email protected].5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
[email protected].7:
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
[email protected]:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -21010,7 +21013,7 @@ snapshots:
istanbul-lib-coverage: 3.2.2
json-stringify-safe: 5.0.1
lil-http-terminator: 1.2.3
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
nanoid: 3.3.11
object-sizeof: 2.6.5
p-debounce: 2.1.0
@@ -21989,7 +21992,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/[email protected](3cb93a0b6e411dc400a0c826af4a0e9e)':
'@langchain/[email protected](2acfdd5f5cb130a81dbfd044a10b3ba6)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]([email protected])([email protected]))([email protected]))([email protected])([email protected])
'@ibm-cloud/watsonx-ai': 1.1.2
@@ -22031,7 +22034,7 @@ snapshots:
ignore: 5.2.4
ioredis: 5.3.2
jsdom: 23.0.1([email protected])([email protected])
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mammoth: 1.11.0
mongodb: 6.21.0(@aws-sdk/[email protected])([email protected])([email protected])
pdf-parse: 1.1.1
@@ -22279,7 +22282,7 @@ snapshots:
'@rushstack/terminal': 0.22.4(@types/[email protected])
'@rushstack/ts-command-line': 5.3.4(@types/[email protected])
diff: 8.0.2
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
minimatch: 10.2.3
resolve: 1.22.10
semver: 7.7.3
@@ -24482,7 +24485,7 @@ snapshots:
storybook: 9.1.7(@testing-library/[email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
type-fest: 2.19.0
vue: 3.5.13([email protected])
vue-component-type-helpers: 3.3.5
vue-component-type-helpers: 3.3.7
'@stylistic/[email protected]([email protected]([email protected]))':
dependencies:
@@ -24609,7 +24612,7 @@ snapshots:
chalk: 3.0.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
redent: 3.0.0
'@testing-library/[email protected](@testing-library/[email protected])':
@@ -25987,7 +25990,7 @@ snapshots:
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
normalize-path: 3.0.0
readable-stream: 4.5.2
@@ -26047,7 +26050,7 @@ snapshots:
[email protected]:
dependencies:
deep-eql: 4.0.0
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
[email protected]:
dependencies:
@@ -26241,6 +26244,16 @@ snapshots:
- debug
- supports-color
[email protected]([email protected]):
dependencies:
follow-redirects: 1.16.0([email protected])
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
[email protected]: {}
[email protected](@babel/[email protected]):
@@ -26600,7 +26613,7 @@ snapshots:
cron-parser: 4.9.0
get-port: 5.1.1
ioredis: 5.3.2
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
msgpackr: 1.11.2
semver: 7.7.2
uuid: 11.1.1
@@ -26612,7 +26625,7 @@ snapshots:
cron-parser: 4.9.0
get-port: 5.1.1
ioredis: 5.3.2
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
msgpackr: 1.11.2
semver: 7.7.3
uuid: 11.1.1
@@ -27119,7 +27132,7 @@ snapshots:
dependencies:
chalk: 4.1.2
date-fns: 2.30.0
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
rxjs: 7.8.1
shell-quote: 1.8.1
spawn-command: 0.0.2
@@ -27940,9 +27953,9 @@ snapshots:
async-validator: 4.2.5
dayjs: 1.11.10
escape-html: 1.0.3
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
lodash-es: 4.18.1
lodash-unified: 1.0.3(@types/[email protected])([email protected])([email protected])
lodash-unified: 1.0.3(@types/[email protected])([email protected])([email protected](patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39))
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.13([email protected])
@@ -28399,7 +28412,7 @@ snapshots:
[email protected]([email protected]([email protected])):
dependencies:
eslint: 9.29.0([email protected])
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
@@ -29694,7 +29707,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.21
'@types/tough-cookie': 4.0.5
axios: 1.18.0([email protected].1)
axios: 1.18.0([email protected].3)
camelcase: 6.3.0
debug: 4.4.3
dotenv: 16.6.1
@@ -29704,7 +29717,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0([email protected]([email protected]))
retry-axios: 2.6.0([email protected])
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@@ -29809,7 +29822,7 @@ snapshots:
cli-width: 3.0.0
external-editor: 3.1.0
figures: 3.2.0
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mute-stream: 0.0.8
run-async: 2.4.1
rxjs: 6.6.7
@@ -31399,10 +31412,10 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected]):
[email protected](@types/[email protected])([email protected])([email protected](patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
lodash-es: 4.18.1
[email protected]: {}
@@ -31443,7 +31456,7 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected](patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39): {}
[email protected]:
dependencies:
@@ -31819,7 +31832,7 @@ snapshots:
dependencies:
'@miragejs/pretender-node-polyfill': 0.1.2
inflected: 2.1.0
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
pretender: 3.4.7
[email protected]: {}
@@ -31827,7 +31840,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31835,7 +31848,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31843,7 +31856,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31851,7 +31864,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31863,7 +31876,7 @@ snapshots:
glob: 10.5.0
html-minifier: 4.0.0
js-beautify: 1.14.9
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
minimatch: 9.0.9
mjml-core: 4.15.3([email protected])
mjml-migrate: 4.15.3([email protected])
@@ -31876,7 +31889,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31889,7 +31902,7 @@ snapshots:
html-minifier: 4.0.0
js-beautify: 1.14.9
juice: 10.0.1([email protected])
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-migrate: 4.15.3([email protected])
mjml-parser-xml: 4.15.3
mjml-validator: 4.15.3
@@ -31899,7 +31912,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31907,7 +31920,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31915,7 +31928,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31923,7 +31936,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31931,7 +31944,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31939,7 +31952,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31947,7 +31960,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31955,7 +31968,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31963,7 +31976,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31971,7 +31984,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31979,7 +31992,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31987,7 +32000,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -31996,7 +32009,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.26.10
js-beautify: 1.14.9
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
mjml-parser-xml: 4.15.3
yargs: 17.7.2
@@ -32006,7 +32019,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32016,7 +32029,7 @@ snapshots:
'@babel/runtime': 7.28.4
detect-node: 2.1.0
htmlparser2: 9.1.0
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
[email protected]([email protected]):
dependencies:
@@ -32052,7 +32065,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32060,7 +32073,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32068,7 +32081,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32076,7 +32089,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32084,7 +32097,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32092,7 +32105,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
transitivePeerDependencies:
- encoding
@@ -32104,7 +32117,7 @@ snapshots:
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
mjml-core: 4.15.3([email protected])
mjml-section: 4.15.3([email protected])
transitivePeerDependencies:
@@ -34061,7 +34074,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
[email protected]([email protected]([email protected])):
[email protected]([email protected]):
dependencies:
axios: 1.18.0([email protected])
@@ -35565,11 +35578,11 @@ snapshots:
'@ts-graphviz/common': 2.1.5
'@ts-graphviz/core': 2.0.7
[email protected]([email protected])([email protected])([email protected]):
[email protected]([email protected])([email protected](patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39))([email protected]):
dependencies:
date-fns: 2.30.0
date-fns-tz: 2.0.0([email protected])
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
zod: 4.4.0
[email protected]: {}
@@ -36342,7 +36355,7 @@ snapshots:
[email protected]: {}
[email protected].5: {}
[email protected].7: {}
[email protected]([email protected]([email protected])):
dependencies:
@@ -36372,7 +36385,7 @@ snapshots:
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.6.0
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
semver: 7.7.3
transitivePeerDependencies:
- supports-color
@@ -36880,7 +36893,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.4
'@types/lodash': 4.17.17
lodash: 4.18.0
lodash: 4.18.0(patch_hash=c9c1aefdc3c11c84b56f0e446f4100ee59911491de392196faae26946b910b39)
lodash-es: 4.18.1
nanoclone: 0.2.1
property-expr: 2.0.5