mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(Chat Trigger Node): Prevent XSS vulnerabilities and improve parameter validation (#18148)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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},
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user