fix(Chat Trigger Node): Prevent XSS vulnerabilities and improve parameter validation (#18148)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour
2025-08-11 12:37:07 +02:00
committed by GitHub
parent f4a04132d9
commit d4ef191be0
9 changed files with 1477 additions and 259 deletions

View File

@@ -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,8 +586,12 @@ 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 {
@@ -588,18 +599,26 @@ export class ChatTrigger extends Node {
};
}
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,

View File

@@ -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('');
});
});
});

View File

@@ -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},
});
})();

View File

@@ -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}`);
}
}