n8n stored cross-site scripting in LangChain Chat Trigger node initialMessages parameter
Description
n8n is an open source workflow automation platform. From 1.24.0 to before 1.107.0, there is a stored cross-site scripting (XSS) vulnerability in @n8n/n8n-nodes-langchain.chatTrigger. An authorized user can configure the LangChain Chat Trigger node with malicious JavaScript in the initialMessages field and enable public access so that the payload is executed in the browser of any user who visits the resulting public chat URL. This can be used for phishing or to steal cookies or other sensitive data from users accessing the public chat link. The issue is fixed in version 1.107.0. Updating to 1.107.0 or later is recommended. As a workaround, the affected chatTrigger node can be disabled. No other workarounds are known.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
n8nnpm | >= 1.24.0, < 1.107.0 | 1.107.0 |
Affected products
1Patches
1d4ef191be0b3fix(Chat Trigger Node): Prevent XSS vulnerabilities and improve parameter validation (#18148)
9 files changed · +1477 −259
packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts+51 −25 modified@@ -1,6 +1,13 @@ import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import pick from 'lodash/pick'; -import { Node, NodeConnectionTypes } from 'n8n-workflow'; +import { + Node, + NodeConnectionTypes, + NodeOperationError, + assertParamIsBoolean, + validateNodeParameters, + assertParamIsString, +} from 'n8n-workflow'; import type { IDataObject, IWebhookFunctions, @@ -15,7 +22,7 @@ import type { import { cssVariables } from './constants'; import { validateAuth } from './GenericFunctions'; import { createPage } from './templates'; -import type { LoadPreviousSessionChatOption } from './types'; +import { assertValidLoadPreviousSessionOption } from './types'; const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat'; const allowFileUploadsOption: INodeProperties = { @@ -579,27 +586,39 @@ export class ChatTrigger extends Node { async webhook(ctx: IWebhookFunctions): Promise<IWebhookResponseData> { const res = ctx.getResponseObject(); - const isPublic = ctx.getNodeParameter('public', false) as boolean; - const nodeMode = ctx.getNodeParameter('mode', 'hostedChat') as string; + const isPublic = ctx.getNodeParameter('public', false); + assertParamIsBoolean('public', isPublic, ctx.getNode()); + + const nodeMode = ctx.getNodeParameter('mode', 'hostedChat'); + assertParamIsString('mode', nodeMode, ctx.getNode()); + if (!isPublic) { res.status(404).end(); return { noWebhookResponse: true, }; } - const options = ctx.getNodeParameter('options', {}) as { - getStarted?: string; - inputPlaceholder?: string; - loadPreviousSession?: LoadPreviousSessionChatOption; - showWelcomeScreen?: boolean; - subtitle?: string; - title?: string; - allowFileUploads?: boolean; - allowedFilesMimeTypes?: string; - customCss?: string; - responseMode?: string; - }; + const options = ctx.getNodeParameter('options', {}); + validateNodeParameters( + options, + { + getStarted: { type: 'string' }, + inputPlaceholder: { type: 'string' }, + loadPreviousSession: { type: 'string' }, + showWelcomeScreen: { type: 'boolean' }, + subtitle: { type: 'string' }, + title: { type: 'string' }, + allowFileUploads: { type: 'boolean' }, + allowedFilesMimeTypes: { type: 'string' }, + customCss: { type: 'string' }, + responseMode: { type: 'string' }, + }, + ctx.getNode(), + ); + + const loadPreviousSession = options.loadPreviousSession; + assertValidLoadPreviousSessionOption(loadPreviousSession, ctx.getNode()); const enableStreaming = options.responseMode === 'streaming'; @@ -623,29 +642,36 @@ export class ChatTrigger extends Node { if (nodeMode === 'hostedChat') { // Show the chat on GET request if (webhookName === 'setup') { - const webhookUrlRaw = ctx.getNodeWebhookUrl('default') as string; + const webhookUrlRaw = ctx.getNodeWebhookUrl('default'); + if (!webhookUrlRaw) { + throw new NodeOperationError(ctx.getNode(), 'Default webhook url not set'); + } + const webhookUrl = mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw; const authentication = ctx.getNodeParameter('authentication') as | 'none' | 'basicAuth' | 'n8nUserAuth'; - const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '') as string; - const initialMessages = initialMessagesRaw - .split('\n') - .filter((line) => line) - .map((line) => line.trim()); + const initialMessagesRaw = ctx.getNodeParameter('initialMessages', ''); + assertParamIsString('initialMessage', initialMessagesRaw, ctx.getNode()); const instanceId = ctx.getInstanceId(); - const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']); + const i18nConfig: Record<string, string> = {}; + const keys = ['getStarted', 'inputPlaceholder', 'subtitle', 'title'] as const; + for (const key of keys) { + if (options[key] !== undefined) { + i18nConfig[key] = options[key]; + } + } const page = createPage({ i18n: { en: i18nConfig, }, showWelcomeScreen: options.showWelcomeScreen, - loadPreviousSession: options.loadPreviousSession, - initialMessages, + loadPreviousSession, + initialMessages: initialMessagesRaw, webhookUrl, mode, instanceId,
packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts+38 −3 modified@@ -1,6 +1,38 @@ import sanitizeHtml from 'sanitize-html'; import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types'; + +function sanitizeUserInput(input: string): string { + // Sanitize HTML tags and entities + let sanitized = sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }); + // Remove dangerous protocols + sanitized = sanitized.replace(/javascript:/gi, ''); + sanitized = sanitized.replace(/data:/gi, ''); + sanitized = sanitized.replace(/vbscript:/gi, ''); + return sanitized; +} + +export function getSanitizedInitialMessages(initialMessages: string): string[] { + const sanitizedString = sanitizeUserInput(initialMessages); + + return sanitizedString + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); +} + +export function getSanitizedI18nConfig(config: Record<string, string>): Record<string, string> { + const sanitized: Record<string, string> = {}; + + for (const [key, value] of Object.entries<string>(config)) { + sanitized[key] = sanitizeUserInput(value); + } + + return sanitized; +} export function createPage({ instanceId, webhookUrl, @@ -21,7 +53,7 @@ export function createPage({ i18n: { en: Record<string, string>; }; - initialMessages: string[]; + initialMessages: string; mode: 'test' | 'production'; authentication: AuthenticationChatOption; allowFileUploads?: boolean; @@ -57,6 +89,9 @@ export function createPage({ ? loadPreviousSession : 'notSupported'; + const sanitizedInitialMessages = getSanitizedInitialMessages(initialMessages); + const sanitizedI18nConfig = getSanitizedI18nConfig(en || {}); + return `<!doctype html> <html lang="en"> <head> @@ -123,9 +158,9 @@ export function createPage({ allowFileUploads: ${sanitizedAllowFileUploads}, allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}', i18n: { - ${en ? `en: ${JSON.stringify(en)},` : ''} + ${Object.keys(sanitizedI18nConfig).length ? `en: ${JSON.stringify(sanitizedI18nConfig)},` : ''} }, - ${initialMessages.length ? `initialMessages: ${JSON.stringify(initialMessages)},` : ''} + ${sanitizedInitialMessages.length ? `initialMessages: ${JSON.stringify(sanitizedInitialMessages)},` : ''} enableStreaming: ${!!enableStreaming}, }); })();
packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/templates.test.ts+331 −0 added@@ -0,0 +1,331 @@ +import { createPage, getSanitizedInitialMessages, getSanitizedI18nConfig } from '../templates'; + +describe('ChatTrigger Templates Security', () => { + const defaultParams = { + instanceId: 'test-instance', + webhookUrl: 'http://test.com/webhook', + showWelcomeScreen: false, + loadPreviousSession: 'notSupported' as const, + i18n: { + en: {}, + }, + mode: 'test' as const, + authentication: 'none' as const, + allowFileUploads: false, + allowedFilesMimeTypes: '', + customCss: '', + enableStreaming: false, + }; + + describe('XSS Prevention in initialMessages', () => { + it('should prevent script injection through script context breakout', () => { + const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>'; + + const result = createPage({ + ...defaultParams, + initialMessages: maliciousInput, + }); + + // Should not contain the malicious script + expect(result).not.toContain('<script>alert(document.cookie)</script>'); + expect(result).not.toContain('</script>"%09<script>'); + expect(result).not.toContain('alert(document.cookie)'); + + // Should contain initialMessages (the exact format is less important than security) + expect(result).toContain('initialMessages:'); + // Should contain the tab character but not the dangerous script tags + expect(result).toContain('%09'); + }); + + it('should sanitize common XSS payloads', () => { + const xssPayloads = [ + { input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] }, + { input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] }, + { input: 'javascript:alert(1)', dangerous: ['javascript:'] }, + { + input: '<iframe src="javascript:alert(1)"></iframe>', + dangerous: ['<iframe', 'javascript:'], + }, + ]; + + xssPayloads.forEach(({ input, dangerous }) => { + const result = createPage({ + ...defaultParams, + initialMessages: input, + }); + + // Should not contain dangerous HTML elements or protocols + dangerous.forEach((dangerousContent) => { + expect(result).not.toContain(dangerousContent); + }); + }); + }); + + it('should preserve legitimate messages', () => { + const legitimateMessages = [ + 'Hello, how can I help you?', + 'Welcome to our chat service!', + 'Please describe your issue.', + 'Multi-line\nmessage content\nwith breaks', + ]; + + legitimateMessages.forEach((message) => { + const result = createPage({ + ...defaultParams, + initialMessages: message, + }); + + // Should contain the sanitized legitimate content + const expectedLines = message + .split('\n') + .filter((line) => line) + .map((line) => line.trim()); + + expect(result).toContain(`initialMessages: ${JSON.stringify(expectedLines)}`); + }); + }); + + it('should handle empty initialMessages', () => { + const result = createPage({ + ...defaultParams, + initialMessages: '', + }); + + // Should not include initialMessages property when empty + expect(result).not.toContain('initialMessages:'); + }); + + it('should handle whitespace-only initialMessages', () => { + const result = createPage({ + ...defaultParams, + initialMessages: ' \n\n\t \n ', + }); + + // Should not include initialMessages property when only whitespace + expect(result).not.toContain('initialMessages:'); + }); + + it('should filter empty lines and trim content', () => { + const result = createPage({ + ...defaultParams, + initialMessages: ' First message \n\n \n Second message \n', + }); + + // Should only include non-empty, trimmed lines + expect(result).toContain('initialMessages: ["First message","Second message"]'); + }); + }); + + describe('General Security', () => { + it('should not expose raw user input in HTML comments or other locations', () => { + const maliciousInput = '</script><script>alert("XSS")</script>'; + + const result = createPage({ + ...defaultParams, + initialMessages: maliciousInput, + }); + + // Should not appear anywhere in the HTML outside of the sanitized JSON + const lines = result.split('\n'); + const unsafeLines = lines.filter( + (line) => + line.includes('<script>alert("XSS")</script>') && !line.includes('initialMessages: ['), + ); + + expect(unsafeLines).toHaveLength(0); + }); + }); + + describe('I18n XSS Prevention', () => { + it('should prevent script injection through i18n config values', () => { + const maliciousInput = '</script><script>alert(document.cookie)</script>'; + + const result = createPage({ + ...defaultParams, + initialMessages: '', + i18n: { + en: { + title: maliciousInput, + subtitle: maliciousInput, + getStarted: maliciousInput, + inputPlaceholder: maliciousInput, + }, + }, + }); + + // Should not contain the malicious script + expect(result).not.toContain('<script>alert(document.cookie)</script>'); + expect(result).not.toContain('</script><script>'); + expect(result).not.toContain('alert(document.cookie)'); + + // Should contain i18n config but sanitized + expect(result).toContain('i18n:'); + }); + + it('should sanitize individual i18n fields', () => { + const xssPayload = '<img src=x onerror=alert(1)>'; + const fields = ['title', 'subtitle', 'getStarted', 'inputPlaceholder']; + + fields.forEach((field) => { + const config = { [field]: xssPayload }; + + const result = createPage({ + ...defaultParams, + initialMessages: '', + i18n: { en: config }, + }); + + // Should not contain dangerous HTML + expect(result).not.toContain('onerror='); + expect(result).not.toContain('<img'); + expect(result).not.toContain('alert(1)'); + }); + }); + + it('should preserve legitimate i18n content', () => { + const legitimateConfig = { + title: 'Welcome to Chat', + subtitle: 'How can we help you today?', + getStarted: 'Start Conversation', + inputPlaceholder: 'Type your message...', + }; + + const result = createPage({ + ...defaultParams, + initialMessages: '', + i18n: { en: legitimateConfig }, + }); + + // Should contain the legitimate content + expect(result).toContain(JSON.stringify(legitimateConfig)); + }); + + it('should handle empty i18n config', () => { + const result = createPage({ + ...defaultParams, + initialMessages: '', + i18n: { en: {} }, + }); + + // Should still have i18n structure but no en property in the i18n config + expect(result).toContain('i18n: {'); + expect(result).not.toContain('en: {'); + }); + }); + + describe('getSanitizedInitialMessages function', () => { + it('should sanitize XSS payloads', () => { + const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>'; + const result = getSanitizedInitialMessages(maliciousInput); + + expect(result).toEqual(['"%09']); + expect(result.join('')).not.toContain('<script>'); + expect(result.join('')).not.toContain('alert'); + }); + + it('should remove dangerous protocols', () => { + const inputs = [ + 'javascript:alert(1)', + 'data:text/html,<script>alert(1)</script>', + 'vbscript:msgbox(1)', + ]; + + inputs.forEach((input) => { + const result = getSanitizedInitialMessages(input); + const joined = result.join(''); + expect(joined).not.toContain('javascript:'); + expect(joined).not.toContain('data:'); + expect(joined).not.toContain('vbscript:'); + }); + }); + + it('should preserve legitimate content', () => { + const input = 'Hello world!\nHow are you?\nGoodbye!'; + const result = getSanitizedInitialMessages(input); + + expect(result).toEqual(['Hello world!', 'How are you?', 'Goodbye!']); + }); + + it('should handle empty and whitespace-only input', () => { + expect(getSanitizedInitialMessages('')).toEqual([]); + expect(getSanitizedInitialMessages(' \n\n \t \n ')).toEqual([]); + }); + + it('should trim and filter empty lines', () => { + const input = ' First message \n\n \n Second message \n'; + const result = getSanitizedInitialMessages(input); + + expect(result).toEqual(['First message', 'Second message']); + }); + }); + + describe('getSanitizedI18nConfig function', () => { + it('should sanitize XSS payloads in all values', () => { + const maliciousInput = '</script><script>alert(document.cookie)</script>'; + const input = { + title: maliciousInput, + subtitle: maliciousInput, + getStarted: maliciousInput, + inputPlaceholder: maliciousInput, + }; + + const result = getSanitizedI18nConfig(input); + + Object.values(result).forEach((value) => { + expect(value).not.toContain('<script>'); + expect(value).not.toContain('alert'); + expect(value).not.toContain('</script>'); + }); + }); + + it('should remove dangerous protocols', () => { + const input = { + title: 'javascript:alert(1)', + subtitle: 'data:text/html,<script>alert(1)</script>', + getStarted: 'vbscript:msgbox(1)', + }; + + const result = getSanitizedI18nConfig(input); + + Object.values(result).forEach((value) => { + expect(value).not.toContain('javascript:'); + expect(value).not.toContain('data:'); + expect(value).not.toContain('vbscript:'); + }); + }); + + it('should preserve legitimate content', () => { + const input = { + title: 'Welcome to Chat', + subtitle: 'How can we help you today?', + getStarted: 'Start Conversation', + inputPlaceholder: 'Type your message...', + }; + + const result = getSanitizedI18nConfig(input); + + expect(result).toEqual(input); + }); + + it('should handle empty object', () => { + const result = getSanitizedI18nConfig({}); + expect(result).toEqual({}); + }); + + it('should handle non-string values gracefully', () => { + const input = { + title: 'Valid title', + count: 123, + enabled: true, + obj: { test: 1 }, + } as any; + + const result = getSanitizedI18nConfig(input); + + expect(result.title).toBe('Valid title'); + expect(result.count).toBe('123'); + expect(result.enabled).toBe(''); + expect(result.obj).toBe(''); + }); + }); +});
packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/types.ts+18 −1 modified@@ -1,2 +1,19 @@ +import type { INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +const validOptions = ['notSupported', 'memory', 'manually'] as const; export type AuthenticationChatOption = 'none' | 'basicAuth' | 'n8nUserAuth'; -export type LoadPreviousSessionChatOption = 'manually' | 'memory' | 'notSupported'; +export type LoadPreviousSessionChatOption = (typeof validOptions)[number]; + +function isValidLoadPreviousSessionOption(value: unknown): value is LoadPreviousSessionChatOption { + return typeof value === 'string' && (validOptions as readonly string[]).includes(value); +} + +export function assertValidLoadPreviousSessionOption( + value: string | undefined, + node: INode, +): asserts value is LoadPreviousSessionChatOption | undefined { + if (value && !isValidLoadPreviousSessionOption(value)) { + throw new NodeOperationError(node, `Invalid loadPreviousSession option: ${value}`); + } +}
packages/nodes-base/nodes/Beeminder/Beeminder.node.ts+125 −136 modified@@ -10,6 +10,10 @@ import { NodeConnectionTypes, NodeOperationError, jsonParse, + assertParamIsString, + validateNodeParameters, + assertParamIsNumber, + assertParamIsArray, } from 'n8n-workflow'; import type { Datapoint } from './Beeminder.node.functions'; @@ -34,12 +38,6 @@ import { getUser, } from './Beeminder.node.functions'; import { beeminderApiRequest } from './GenericFunctions'; -import { - assertIsString, - assertIsNodeParameters, - assertIsNumber, - assertIsArray, -} from '../../utils/types'; export class Beeminder implements INodeType { description: INodeTypeDescription = { @@ -1042,7 +1040,7 @@ export class Beeminder implements INodeType { if (resource === 'datapoint') { const goalName = this.getNodeParameter('goalName', i); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, this.getNode()); results = await executeDatapointOperations(this, operation, goalName, i, timezone); } else if (resource === 'charge') { results = await executeChargeOperations(this, operation, i); @@ -1091,22 +1089,22 @@ async function executeDatapointCreate( timezone: string, ): Promise<JsonObject[]> { const value = context.getNodeParameter('value', itemIndex); - assertIsNumber('value', value); + assertParamIsNumber('value', value, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); if (options.timestamp) { options.timestamp = moment.tz(options.timestamp, timezone).unix(); } - assertIsNodeParameters<{ - comment?: string; - timestamp?: number; - requestid?: string; - }>(options, { - comment: { type: 'string', optional: true }, - timestamp: { type: 'number', optional: true }, - requestid: { type: 'string', optional: true }, - }); + validateNodeParameters( + options, + { + comment: { type: 'string' }, + timestamp: { type: 'number' }, + requestid: { type: 'string' }, + }, + context.getNode(), + ); const data = { value, @@ -1124,15 +1122,15 @@ async function executeDatapointGetAll( ): Promise<JsonObject[]> { const returnAll = context.getNodeParameter('returnAll', itemIndex); const options = context.getNodeParameter('options', itemIndex); - assertIsNodeParameters<{ - sort?: string; - page?: number; - per?: number; - }>(options, { - sort: { type: 'string', optional: true }, - page: { type: 'number', optional: true }, - per: { type: 'number', optional: true }, - }); + validateNodeParameters( + options, + { + sort: { type: 'string' }, + page: { type: 'number' }, + per: { type: 'number' }, + }, + context.getNode(), + ); const data = { goalName, @@ -1150,21 +1148,21 @@ async function executeDatapointUpdate( timezone: string, ): Promise<JsonObject[]> { const datapointId = context.getNodeParameter('datapointId', itemIndex); - assertIsString('datapointId', datapointId); + assertParamIsString('datapointId', datapointId, context.getNode()); const options = context.getNodeParameter('updateFields', itemIndex); if (options.timestamp) { options.timestamp = moment.tz(options.timestamp, timezone).unix(); } - assertIsNodeParameters<{ - value?: number; - comment?: string; - timestamp?: number; - }>(options, { - value: { type: 'number', optional: true }, - comment: { type: 'string', optional: true }, - timestamp: { type: 'number', optional: true }, - }); + validateNodeParameters( + options, + { + value: { type: 'number' }, + comment: { type: 'string' }, + timestamp: { type: 'number' }, + }, + context.getNode(), + ); const data = { goalName, @@ -1181,7 +1179,7 @@ async function executeDatapointDelete( itemIndex: number, ): Promise<JsonObject[]> { const datapointId = context.getNodeParameter('datapointId', itemIndex); - assertIsString('datapointId', datapointId); + assertParamIsString('datapointId', datapointId, context.getNode()); const data = { goalName, datapointId, @@ -1196,10 +1194,11 @@ async function executeDatapointCreateAll( ): Promise<JsonObject[]> { const datapoints = context.getNodeParameter('datapoints', itemIndex); const parsedDatapoints = typeof datapoints === 'string' ? jsonParse(datapoints) : datapoints; - assertIsArray<Datapoint>( + assertParamIsArray<Datapoint>( 'datapoints', parsedDatapoints, (val): val is Datapoint => typeof val === 'object' && val !== null && 'value' in val, + context.getNode(), ); const data = { @@ -1215,7 +1214,7 @@ async function executeDatapointGet( itemIndex: number, ): Promise<JsonObject[]> { const datapointId = context.getNodeParameter('datapointId', itemIndex); - assertIsString('datapointId', datapointId); + assertParamIsString('datapointId', datapointId, context.getNode()); const data = { goalName, datapointId, @@ -1255,15 +1254,16 @@ async function executeChargeOperations( ): Promise<JsonObject[]> { if (operation === 'create') { const amount = context.getNodeParameter('amount', itemIndex); - assertIsNumber('amount', amount); + assertParamIsNumber('amount', amount, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - note?: string; - dryrun?: boolean; - }>(options, { - note: { type: 'string', optional: true }, - dryrun: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + note: { type: 'string' }, + dryrun: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { amount, ...options, @@ -1280,13 +1280,13 @@ async function executeGoalCreate( timezone: string, ): Promise<JsonObject[]> { const slug = context.getNodeParameter('slug', itemIndex); - assertIsString('slug', slug); + assertParamIsString('slug', slug, context.getNode()); const title = context.getNodeParameter('title', itemIndex); - assertIsString('title', title); + assertParamIsString('title', title, context.getNode()); const goalType = context.getNodeParameter('goal_type', itemIndex); - assertIsString('goalType', goalType); + assertParamIsString('goalType', goalType, context.getNode()); const gunits = context.getNodeParameter('gunits', itemIndex); - assertIsString('gunits', gunits); + assertParamIsString('gunits', gunits, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); if ('tags' in options && typeof options.tags === 'string') { options.tags = jsonParse(options.tags); @@ -1295,27 +1295,21 @@ async function executeGoalCreate( options.goaldate = moment.tz(options.goaldate, timezone).unix(); } - assertIsNodeParameters<{ - goaldate?: number; - goalval?: number; - rate?: number; - initval?: number; - secret?: boolean; - datapublic?: boolean; - datasource?: string; - dryrun?: boolean; - tags?: string[]; - }>(options, { - goaldate: { type: 'number', optional: true }, - goalval: { type: 'number', optional: true }, - rate: { type: 'number', optional: true }, - initval: { type: 'number', optional: true }, - secret: { type: 'boolean', optional: true }, - datapublic: { type: 'boolean', optional: true }, - datasource: { type: 'string', optional: true }, - dryrun: { type: 'boolean', optional: true }, - tags: { type: 'string[]', optional: true }, - }); + validateNodeParameters( + options, + { + goaldate: { type: 'number' }, + goalval: { type: 'number' }, + rate: { type: 'number' }, + initval: { type: 'number' }, + secret: { type: 'boolean' }, + datapublic: { type: 'boolean' }, + datasource: { type: 'string' }, + dryrun: { type: 'boolean' }, + tags: { type: 'string[]' }, + }, + context.getNode(), + ); const data = { slug, @@ -1333,15 +1327,16 @@ async function executeGoalGet( itemIndex: number, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - datapoints?: boolean; - emaciated?: boolean; - }>(options, { - datapoints: { type: 'boolean', optional: true }, - emaciated: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + datapoints: { type: 'boolean' }, + emaciated: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { goalName, ...options, @@ -1355,11 +1350,13 @@ async function executeGoalGetAll( itemIndex: number, ): Promise<JsonObject[]> { const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - emaciated?: boolean; - }>(options, { - emaciated: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + emaciated: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { ...options }; return await getAllGoals.call(context, data); @@ -1370,11 +1367,13 @@ async function executeGoalGetArchived( itemIndex: number, ): Promise<JsonObject[]> { const options = context.getNodeParameter('additionalFields', itemIndex); - assertIsNodeParameters<{ - emaciated?: boolean; - }>(options, { - emaciated: { type: 'boolean', optional: true }, - }); + validateNodeParameters( + options, + { + emaciated: { type: 'boolean' }, + }, + context.getNode(), + ); const data = { ...options }; return await getArchivedGoals.call(context, data); @@ -1386,45 +1385,37 @@ async function executeGoalUpdate( timezone: string, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const options = context.getNodeParameter('updateFields', itemIndex); if ('tags' in options && typeof options.tags === 'string') { options.tags = jsonParse(options.tags); } if ('roadall' in options && typeof options.roadall === 'string') { options.roadall = jsonParse(options.roadall); } - console.log('roadall', typeof options.roadall, options.roadall); - assertIsNodeParameters<{ - title?: string; - yaxis?: string; - tmin?: string; - tmax?: string; - goaldate?: number; - secret?: boolean; - datapublic?: boolean; - roadall?: object; - datasource?: string; - tags?: string[]; - }>(options, { - title: { type: 'string', optional: true }, - yaxis: { type: 'string', optional: true }, - tmin: { type: 'string', optional: true }, - tmax: { type: 'string', optional: true }, - secret: { type: 'boolean', optional: true }, - datapublic: { type: 'boolean', optional: true }, - roadall: { type: 'object', optional: true }, - datasource: { type: 'string', optional: true }, - tags: { type: 'string[]', optional: true }, - }); + if ('goaldate' in options && options.goaldate) { + options.goaldate = moment.tz(options.goaldate, timezone).unix(); + } + validateNodeParameters( + options, + { + title: { type: 'string' }, + yaxis: { type: 'string' }, + tmin: { type: 'string' }, + tmax: { type: 'string' }, + goaldate: { type: 'number' }, + secret: { type: 'boolean' }, + datapublic: { type: 'boolean' }, + roadall: { type: 'object' }, + datasource: { type: 'string' }, + tags: { type: 'string[]' }, + }, + context.getNode(), + ); const data = { goalName, ...options, }; - - if (data.goaldate) { - data.goaldate = moment.tz(data.goaldate, timezone).unix(); - } return await updateGoal.call(context, data); } @@ -1433,7 +1424,7 @@ async function executeGoalRefresh( itemIndex: number, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, }; @@ -1445,7 +1436,7 @@ async function executeGoalShortCircuit( itemIndex: number, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, @@ -1458,7 +1449,7 @@ async function executeGoalStepDown( itemIndex: number, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, @@ -1471,7 +1462,7 @@ async function executeGoalCancelStepDown( itemIndex: number, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, }; @@ -1483,7 +1474,7 @@ async function executeGoalUncle( itemIndex: number, ): Promise<JsonObject[]> { const goalName = context.getNodeParameter('goalName', itemIndex); - assertIsString('goalName', goalName); + assertParamIsString('goalName', goalName, context.getNode()); const data = { goalName, }; @@ -1534,19 +1525,17 @@ async function executeUserOperations( if (options.diff_since) { options.diff_since = moment.tz(options.diff_since, timezone).unix(); } - assertIsNodeParameters<{ - associations?: boolean; - diff_since?: number; - skinny?: boolean; - emaciated?: boolean; - datapoints_count?: number; - }>(options, { - associations: { type: 'boolean', optional: true }, - diff_since: { type: 'number', optional: true }, - skinny: { type: 'boolean', optional: true }, - emaciated: { type: 'boolean', optional: true }, - datapoints_count: { type: 'number', optional: true }, - }); + validateNodeParameters( + options, + { + associations: { type: 'boolean' }, + diff_since: { type: 'number' }, + skinny: { type: 'boolean' }, + emaciated: { type: 'boolean' }, + datapoints_count: { type: 'number' }, + }, + context.getNode(), + ); const data = { ...options }; return await getUser.call(context, data);
packages/nodes-base/utils/types.ts+0 −94 removed@@ -1,94 +0,0 @@ -import { assert } from 'n8n-workflow'; - -function assertIsType<T>( - parameterName: string, - value: unknown, - type: 'string' | 'number' | 'boolean', -): asserts value is T { - assert(typeof value === type, `Parameter "${parameterName}" is not ${type}`); -} - -export function assertIsNumber(parameterName: string, value: unknown): asserts value is number { - assertIsType<number>(parameterName, value, 'number'); -} - -export function assertIsString(parameterName: string, value: unknown): asserts value is string { - assertIsType<string>(parameterName, value, 'string'); -} - -export function assertIsArray<T>( - parameterName: string, - value: unknown, - validator: (val: unknown) => val is T, -): asserts value is T[] { - assert(Array.isArray(value), `Parameter "${parameterName}" is not an array`); - assert( - value.every(validator), - `Parameter "${parameterName}" has elements that don't match expected types`, - ); -} - -export function assertIsNodeParameters<T>( - value: unknown, - parameters: Record< - string, - { - type: - | 'string' - | 'boolean' - | 'number' - | 'resource-locator' - | 'string[]' - | 'number[]' - | 'boolean[]' - | 'object'; - optional?: boolean; - } - >, -): asserts value is T { - assert(typeof value === 'object' && value !== null, 'Value is not a valid object'); - - const obj = value as Record<string, unknown>; - - Object.keys(parameters).forEach((key) => { - const param = parameters[key]; - const paramValue = obj[key]; - - if (!param.optional && paramValue === undefined) { - assert(false, `Required parameter "${key}" is missing`); - } - - if (paramValue !== undefined) { - if (param.type === 'resource-locator') { - assert( - typeof paramValue === 'object' && - paramValue !== null && - '__rl' in paramValue && - 'mode' in paramValue && - 'value' in paramValue, - `Parameter "${key}" is not a valid resource locator object`, - ); - } else if (param.type === 'object') { - assert( - typeof paramValue === 'object' && paramValue !== null, - `Parameter "${key}" is not a valid object`, - ); - } else if (param.type.endsWith('[]')) { - const baseType = param.type.slice(0, -2); - const elementType = - baseType === 'string' || baseType === 'number' || baseType === 'boolean' - ? baseType - : 'string'; - assert(Array.isArray(paramValue), `Parameter "${key}" is not an array`); - paramValue.forEach((item, index) => { - assert( - typeof item === elementType, - `Parameter "${key}[${index}]" is not a valid ${elementType}`, - ); - }); - } else { - assert(typeof paramValue === param.type, `Parameter "${key}" is not a valid ${param.type}`); - } - } - }); -}
packages/workflow/src/index.ts+1 −0 modified@@ -67,6 +67,7 @@ export { ExpressionExtensions } from './extensions'; export * as ExpressionParser from './extensions/expression-parser'; export { NativeMethods } from './native-methods'; export * from './node-parameters/filter-parameter'; +export * from './node-parameters/parameter-type-validation'; export * from './evaluation-helpers'; export type {
packages/workflow/src/node-parameters/parameter-type-validation.ts+253 −0 added@@ -0,0 +1,253 @@ +import { NodeOperationError } from '../errors'; +import type { INode } from '../interfaces'; +import { assert } from '../utils'; + +type ParameterType = + | 'string' + | 'boolean' + | 'number' + | 'resource-locator' + | 'string[]' + | 'number[]' + | 'boolean[]' + | 'object'; + +function assertUserInput<T>(condition: T, message: string, node: INode): asserts condition { + try { + assert(condition, message); + } catch (e: unknown) { + if (e instanceof Error) { + // Use level 'info' to prevent reporting to Sentry (only 'error' and 'fatal' levels are reported) + const nodeError = new NodeOperationError(node, e.message, { level: 'info' }); + nodeError.stack = e.stack; + throw nodeError; + } + + throw e; + } +} + +function assertParamIsType<T>( + parameterName: string, + value: unknown, + type: 'string' | 'number' | 'boolean', + node: INode, +): asserts value is T { + assertUserInput(typeof value === type, `Parameter "${parameterName}" is not ${type}`, node); +} + +export function assertParamIsNumber( + parameterName: string, + value: unknown, + node: INode, +): asserts value is number { + assertParamIsType<number>(parameterName, value, 'number', node); +} + +export function assertParamIsString( + parameterName: string, + value: unknown, + node: INode, +): asserts value is string { + assertParamIsType<string>(parameterName, value, 'string', node); +} + +export function assertParamIsBoolean( + parameterName: string, + value: unknown, + node: INode, +): asserts value is boolean { + assertParamIsType<boolean>(parameterName, value, 'boolean', node); +} + +export function assertParamIsArray<T>( + parameterName: string, + value: unknown, + validator: (val: unknown) => val is T, + node: INode, +): asserts value is T[] { + assertUserInput(Array.isArray(value), `Parameter "${parameterName}" is not an array`, node); + + // Use for loop instead of .every() to properly handle sparse arrays + // .every() skips empty/sparse indices, which could allow invalid arrays to pass + for (let i = 0; i < value.length; i++) { + if (!validator(value[i])) { + assertUserInput( + false, + `Parameter "${parameterName}" has elements that don't match expected types`, + node, + ); + } + } +} + +function assertIsValidObject( + value: unknown, + node: INode, +): asserts value is Record<string, unknown> { + assertUserInput(typeof value === 'object' && value !== null, 'Value is not a valid object', node); +} + +function assertIsRequiredParameter( + parameterName: string, + value: unknown, + isRequired: boolean, + node: INode, +): void { + if (isRequired && value === undefined) { + assertUserInput(false, `Required parameter "${parameterName}" is missing`, node); + } +} + +function assertIsResourceLocator(parameterName: string, value: unknown, node: INode): void { + assertUserInput( + typeof value === 'object' && + value !== null && + '__rl' in value && + 'mode' in value && + 'value' in value, + `Parameter "${parameterName}" is not a valid resource locator object`, + node, + ); +} + +function assertParamIsObject(parameterName: string, value: unknown, node: INode): void { + assertUserInput( + typeof value === 'object' && value !== null, + `Parameter "${parameterName}" is not a valid object`, + node, + ); +} + +function createElementValidator<T extends 'string' | 'number' | 'boolean'>(elementType: T) { + return ( + val: unknown, + ): val is T extends 'string' ? string : T extends 'number' ? number : boolean => + typeof val === elementType; +} + +function assertParamIsArrayOfType( + parameterName: string, + value: unknown, + arrayType: string, + node: INode, +): void { + const baseType = arrayType.slice(0, -2); + const elementType = + baseType === 'string' || baseType === 'number' || baseType === 'boolean' ? baseType : 'string'; + + const validator = createElementValidator(elementType); + assertParamIsArray(parameterName, value, validator, node); +} + +function assertParamIsPrimitive( + parameterName: string, + value: unknown, + type: string, + node: INode, +): void { + assertUserInput( + typeof value === type, + `Parameter "${parameterName}" is not a valid ${type}`, + node, + ); +} + +function validateParameterType( + parameterName: string, + value: unknown, + type: ParameterType, + node: INode, +): boolean { + try { + if (type === 'resource-locator') { + assertIsResourceLocator(parameterName, value, node); + } else if (type === 'object') { + assertParamIsObject(parameterName, value, node); + } else if (type.endsWith('[]')) { + assertParamIsArrayOfType(parameterName, value, type, node); + } else { + assertParamIsPrimitive(parameterName, value, type, node); + } + return true; + } catch { + return false; + } +} + +function validateParameterAgainstTypes( + parameterName: string, + value: unknown, + types: ParameterType[], + node: INode, +): void { + let isValid = false; + + for (const type of types) { + if (validateParameterType(parameterName, value, type, node)) { + isValid = true; + break; + } + } + + if (!isValid) { + const typeList = types.join(' or '); + assertUserInput( + false, + `Parameter "${parameterName}" does not match any of the expected types: ${typeList}`, + node, + ); + } +} + +type InferParameterType<T extends ParameterType | ParameterType[]> = T extends ParameterType[] + ? InferSingleParameterType<T[number]> + : T extends ParameterType + ? InferSingleParameterType<T> + : never; + +type InferSingleParameterType<T extends ParameterType> = T extends 'string' + ? string + : T extends 'boolean' + ? boolean + : T extends 'number' + ? number + : T extends 'resource-locator' + ? Record<string, unknown> + : T extends 'string[]' + ? string[] + : T extends 'number[]' + ? number[] + : T extends 'boolean[]' + ? boolean[] + : T extends 'object' + ? Record<string, unknown> + : unknown; + +export function validateNodeParameters< + T extends Record<string, { type: ParameterType | ParameterType[]; required?: boolean }>, +>( + value: unknown, + parameters: T, + node: INode, +): asserts value is { + [K in keyof T]: T[K]['required'] extends true + ? InferParameterType<T[K]['type']> + : InferParameterType<T[K]['type']> | undefined; +} { + assertIsValidObject(value, node); + + Object.keys(parameters).forEach((key) => { + const param = parameters[key]; + const paramValue = value[key]; + + assertIsRequiredParameter(key, paramValue, param.required ?? false, node); + + // If required, value cannot be undefined and must be validated + // If not required, value can be undefined but should be validated when present + if (param.required || paramValue !== undefined) { + const types = Array.isArray(param.type) ? param.type : [param.type]; + validateParameterAgainstTypes(key, paramValue, types, node); + } + }); +}
packages/workflow/test/node-parameters/parameter-type-validation.test.ts+660 −0 added@@ -0,0 +1,660 @@ +import { + validateNodeParameters, + assertParamIsString, + assertParamIsNumber, + assertParamIsBoolean, + assertParamIsArray, +} from '../../src/node-parameters/parameter-type-validation'; +import type { INode } from '../../src/interfaces'; + +describe('Type assertion functions', () => { + const mockNode: INode = { + id: 'test-node-id', + name: 'TestNode', + type: 'n8n-nodes-base.testNode', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + describe('assertIsNodeParameters', () => { + it('should pass for valid object with all required parameters', () => { + const value = { + name: 'test', + age: 25, + active: true, + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + age: { type: 'number' as const, required: true }, + active: { type: 'boolean' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid object with optional parameters present', () => { + const value = { + name: 'test', + description: 'optional description', + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + description: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid object with optional parameters missing', () => { + const value = { + name: 'test', + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + description: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid array parameters', () => { + const value = { + tags: ['tag1', 'tag2'], + numbers: [1, 2, 3], + flags: [true, false], + }; + + const parameters = { + tags: { type: 'string[]' as const, required: true }, + numbers: { type: 'number[]' as const, required: true }, + flags: { type: 'boolean[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid resource-locator parameter', () => { + const value = { + resource: { + __rl: true, + mode: 'list', + value: 'some-value', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for valid object parameter', () => { + const value = { + config: { + setting1: 'value1', + setting2: 42, + }, + }; + + const parameters = { + config: { type: 'object' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should pass for parameter with multiple allowed types', () => { + const value = { + multiType: 'string value', + }; + + const parameters = { + multiType: { type: ['string', 'number'] as Array<'string' | 'number'>, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + + // Test with number value + const value2 = { + multiType: 42, + }; + + expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow(); + }); + + it('should throw for null value', () => { + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(null, parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + }); + + it('should throw for non-object value', () => { + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters('not an object', parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + expect(() => validateNodeParameters(123, parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + expect(() => validateNodeParameters(true, parameters, mockNode)).toThrow( + 'Value is not a valid object', + ); + }); + + it('should throw for missing required parameter', () => { + const value = { + // name is missing + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Required parameter "name" is missing', + ); + }); + + it('should throw for parameter with wrong type', () => { + const value = { + name: 123, // should be string + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "name" does not match any of the expected types: string', + ); + }); + + it('should throw for invalid array parameter', () => { + const value = { + tags: 'not an array', + }; + + const parameters = { + tags: { type: 'string[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "tags" does not match any of the expected types: string[]', + ); + }); + + it('should throw for array with wrong element type', () => { + const value = { + tags: ['valid', 123, 'also valid'], // 123 is not a string + }; + + const parameters = { + tags: { type: 'string[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "tags" does not match any of the expected types: string[]', + ); + }); + + it('should throw for invalid resource-locator parameter', () => { + const value = { + resource: { + // missing required properties + mode: 'list', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "resource" does not match any of the expected types: resource-locator', + ); + }); + + it('should throw for invalid object parameter', () => { + const value = { + config: 'not an object', + }; + + const parameters = { + config: { type: 'object' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "config" does not match any of the expected types: object', + ); + }); + + it('should throw for parameter that matches none of the allowed types', () => { + const value = { + multiType: true, // should be string or number + }; + + const parameters = { + multiType: { type: ['string', 'number'] as Array<'string' | 'number'>, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "multiType" does not match any of the expected types: string or number', + ); + }); + + it('should handle empty parameter definition', () => { + const value = { + extra: 'should be ignored', + }; + + const parameters = {}; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle complex nested scenarios', () => { + const value = { + name: 'test', + tags: ['tag1', 'tag2'], + config: { + enabled: true, + timeout: 5000, + }, + resource: { + __rl: true, + mode: 'id', + value: '12345', + }, + optionalField: undefined, + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + tags: { type: 'string[]' as const, required: true }, + config: { type: 'object' as const, required: true }, + resource: { type: 'resource-locator' as const, required: true }, + optionalField: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle empty arrays', () => { + const value = { + emptyTags: [], + }; + + const parameters = { + emptyTags: { type: 'string[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle null values for optional parameters', () => { + const value = { + name: 'test', + optionalField: null, + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + optionalField: { type: 'string' as const }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "optionalField" does not match any of the expected types: string', + ); + }); + + it('should handle resource-locator with additional properties', () => { + const value = { + resource: { + __rl: true, + mode: 'list', + value: 'some-value', + extraProperty: 'ignored', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + }); + + describe('assertParamIsBoolean', () => { + it('should pass for valid boolean values', () => { + expect(() => assertParamIsBoolean('testParam', true, mockNode)).not.toThrow(); + expect(() => assertParamIsBoolean('testParam', false, mockNode)).not.toThrow(); + }); + + it('should throw for non-boolean values', () => { + expect(() => assertParamIsBoolean('testParam', 'true', mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', 1, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', 0, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', null, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + expect(() => assertParamIsBoolean('testParam', undefined, mockNode)).toThrow( + 'Parameter "testParam" is not boolean', + ); + }); + }); + + describe('assertIsString', () => { + it('should pass for valid string', () => { + expect(() => assertParamIsString('testParam', 'hello', mockNode)).not.toThrow(); + }); + + it('should throw for non-string values', () => { + expect(() => assertParamIsString('testParam', 123, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertParamIsString('testParam', true, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertParamIsString('testParam', null, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertParamIsString('testParam', undefined, mockNode)).toThrow( + 'Parameter "testParam" is not string', + ); + }); + }); + + describe('assertIsNumber', () => { + it('should pass for valid number', () => { + expect(() => assertParamIsNumber('testParam', 123, mockNode)).not.toThrow(); + expect(() => assertParamIsNumber('testParam', 0, mockNode)).not.toThrow(); + expect(() => assertParamIsNumber('testParam', -5.5, mockNode)).not.toThrow(); + }); + + it('should throw for non-number values', () => { + expect(() => assertParamIsNumber('testParam', '123', mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertParamIsNumber('testParam', true, mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertParamIsNumber('testParam', null, mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertParamIsNumber('testParam', undefined, mockNode)).toThrow( + 'Parameter "testParam" is not number', + ); + }); + }); + + describe('assertIsArray', () => { + const isString = (val: unknown): val is string => typeof val === 'string'; + const isNumber = (val: unknown): val is number => typeof val === 'number'; + + it('should pass for valid array with correct element types', () => { + expect(() => + assertParamIsArray('testParam', ['a', 'b', 'c'], isString, mockNode), + ).not.toThrow(); + expect(() => assertParamIsArray('testParam', [1, 2, 3], isNumber, mockNode)).not.toThrow(); + expect(() => assertParamIsArray('testParam', [], isString, mockNode)).not.toThrow(); // empty array + }); + + it('should throw for non-array values', () => { + expect(() => assertParamIsArray('testParam', 'not array', isString, mockNode)).toThrow( + 'Parameter "testParam" is not an array', + ); + expect(() => assertParamIsArray('testParam', { length: 3 }, isString, mockNode)).toThrow( + 'Parameter "testParam" is not an array', + ); + }); + + it('should throw for array with incorrect element types', () => { + expect(() => assertParamIsArray('testParam', ['a', 1, 'c'], isString, mockNode)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + expect(() => assertParamIsArray('testParam', [1, 'b', 3], isNumber, mockNode)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + }); + }); + + describe('Edge cases and additional scenarios', () => { + describe('validateNodeParameters edge cases', () => { + it('should handle NaN values correctly', () => { + const value = { + number: NaN, + }; + + const parameters = { + number: { type: 'number' as const, required: true }, + }; + + // NaN is of type 'number' in JavaScript + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle Infinity values correctly', () => { + const value = { + number: Infinity, + }; + + const parameters = { + number: { type: 'number' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle mixed array types correctly', () => { + const value = { + mixed: [1, '2', 3], // Invalid: mixed types in array + }; + + const parameters = { + mixed: { type: 'number[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "mixed" does not match any of the expected types: number[]', + ); + }); + + it('should handle nested arrays', () => { + const value = { + nested: [ + [1, 2], + [3, 4], + ], // Array of arrays + }; + + const parameters = { + nested: { type: 'object' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle resource-locator with false __rl property', () => { + const value = { + resource: { + __rl: false, // Should still be valid as it has the property + mode: 'list', + value: 'some-value', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle resource-locator missing __rl property', () => { + const value = { + resource: { + mode: 'list', + value: 'some-value', + // __rl is missing + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).toThrow( + 'Parameter "resource" does not match any of the expected types: resource-locator', + ); + }); + + it('should handle empty string as valid string parameter', () => { + const value = { + name: '', + }; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle zero as valid number parameter', () => { + const value = { + count: 0, + }; + + const parameters = { + count: { type: 'number' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle arrays with only false values', () => { + const value = { + flags: [false, false, false], + }; + + const parameters = { + flags: { type: 'boolean[]' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + + it('should handle three or more type unions', () => { + const value = { + multiType: 'string value', + }; + + const parameters = { + multiType: { + type: ['string', 'number', 'boolean'] as Array<'string' | 'number' | 'boolean'>, + required: true, + }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + + // Test with boolean value + const value2 = { + multiType: true, + }; + + expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow(); + }); + + it('should handle array types in multi-type parameters', () => { + const value = { + flexParam: ['a', 'b', 'c'], + }; + + const parameters = { + flexParam: { + type: ['string', 'string[]'] as Array<'string' | 'string[]'>, + required: true, + }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + + // Test with single string + const value2 = { + flexParam: 'single string', + }; + + expect(() => validateNodeParameters(value2, parameters, mockNode)).not.toThrow(); + }); + + it('should handle object with null prototype', () => { + const value = Object.create(null); + value.name = 'test'; + + const parameters = { + name: { type: 'string' as const, required: true }, + }; + + expect(() => validateNodeParameters(value, parameters, mockNode)).not.toThrow(); + }); + }); + + describe('assertParamIsArray edge cases', () => { + const isString = (val: unknown): val is string => typeof val === 'string'; + + it('should handle array-like objects', () => { + const arrayLike = { 0: 'a', 1: 'b', length: 2 }; + + expect(() => assertParamIsArray('testParam', arrayLike, isString, mockNode)).toThrow( + 'Parameter "testParam" is not an array', + ); + }); + + it('should handle sparse arrays', () => { + const sparse = new Array(3); + sparse[0] = 'a'; + sparse[2] = 'c'; + // sparse[1] is undefined + + // For loop implementation properly validates sparse arrays and throws on undefined elements + expect(() => assertParamIsArray('testParam', sparse, isString, mockNode)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + }); + + it('should handle arrays with explicit undefined values', () => { + const arrayWithUndefined = ['a', undefined, 'c']; + + expect(() => + assertParamIsArray('testParam', arrayWithUndefined, isString, mockNode), + ).toThrow('Parameter "testParam" has elements that don\'t match expected types'); + }); + + it('should handle very large arrays efficiently', () => { + const largeArray = new Array(1000).fill('test'); + + expect(() => assertParamIsArray('testParam', largeArray, isString, mockNode)).not.toThrow(); + }); + }); + }); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-mvh4-2cm2-6hpgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-58177ghsaADVISORY
- docs.n8n.io/hosting/securing/blocking-nodesghsaWEB
- github.com/n8n-io/n8n/commit/d4ef191be0b39b65efa68559a3b8d5dad2e102b2ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/pull/18148ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/security/advisories/GHSA-mvh4-2cm2-6hpgghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.