diff --git a/packages/nodes-base/nodes/Form/test/Form.node.test.ts b/packages/nodes-base/nodes/Form/test/Form.node.test.ts
index 427299eb15..7739cbe9ed 100644
--- a/packages/nodes-base/nodes/Form/test/Form.node.test.ts
+++ b/packages/nodes-base/nodes/Form/test/Form.node.test.ts
@@ -245,7 +245,7 @@ describe('Form Node', () => {
message: 'Test Message',
redirectUrl: '',
title: 'Test Title',
- responseText: '
hey
',
+ responseText: 'hey
',
responseBinary: encodeURIComponent(JSON.stringify('')),
},
},
@@ -292,6 +292,7 @@ describe('Form Node', () => {
const mockResponseObject = {
render: jest.fn(),
redirect: jest.fn(),
+ setHeader: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
@@ -375,6 +376,7 @@ describe('Form Node', () => {
const mockResponseObject = {
render: jest.fn(),
+ setHeader: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
@@ -403,6 +405,7 @@ describe('Form Node', () => {
if (paramName === 'completionMessage') return 'Test Message';
if (paramName === 'redirectUrl') return 'https://n8n.io';
if (paramName === 'formFields.values') return [];
+ if (paramName === 'responseText') return '';
return {};
});
@@ -420,6 +423,7 @@ describe('Form Node', () => {
render: jest.fn(),
redirect: jest.fn(),
send: jest.fn(),
+ setHeader: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
diff --git a/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts b/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts
index 5485dcb0fe..b360b0863e 100644
--- a/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts
+++ b/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts
@@ -118,12 +118,13 @@ describe('formCompletionUtils', () => {
it('should call sanitizeHtml on completionMessage', async () => {
const sanitizeHtmlSpy = jest.spyOn(utils, 'sanitizeHtml');
const maliciousMessage = 'Safe messagebold';
+ const responseText = 'Response text';
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: maliciousMessage,
- responseText: 'Response text',
+ responseText,
options: { formTitle: 'Form Title' },
};
return params[parameterName];
@@ -132,7 +133,7 @@ describe('formCompletionUtils', () => {
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(sanitizeHtmlSpy).toHaveBeenCalledWith(maliciousMessage);
- expect(sanitizeHtmlSpy).toHaveBeenCalledWith('Response text');
+ expect(sanitizeHtmlSpy).toHaveBeenCalledTimes(1);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
@@ -283,6 +284,25 @@ describe('formCompletionUtils', () => {
});
}
});
+
+ it('should set Content-Security-Policy header with sandbox CSP', async () => {
+ mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
+ const params: { [key: string]: any } = {
+ completionTitle: 'Form Completion',
+ completionMessage: 'Form has been submitted successfully',
+ options: { formTitle: 'Form Title' },
+ };
+ return params[parameterName];
+ });
+
+ await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
+
+ expect(mockResponse.setHeader).toHaveBeenCalledWith(
+ 'Content-Security-Policy',
+ 'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols',
+ );
+ expect(mockResponse.render).toHaveBeenCalled();
+ });
});
describe('binaryResponse', () => {
diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts
index f97c24033c..6a7847fc43 100644
--- a/packages/nodes-base/nodes/Form/test/utils.test.ts
+++ b/packages/nodes-base/nodes/Form/test/utils.test.ts
@@ -89,6 +89,259 @@ describe('FormTrigger, sanitizeHtml', () => {
expect(sanitizeHtml(html)).toBe(expected);
});
});
+
+ it('should allow table elements and preserve structure', () => {
+ const tableTestCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ ];
+
+ tableTestCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should allow tfoot elements and preserve table footer structure', () => {
+ const tfootTestCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ {
+ html: '',
+ expected: '',
+ },
+ ];
+
+ tfootTestCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should preserve table cell attributes (colspan, rowspan, scope, headers)', () => {
+ const cellAttributeTestCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ {
+ html: '| Column Header |
|---|
| Row Header | Data |
|---|
',
+ expected:
+ '| Column Header |
|---|
| Row Header | Data |
|---|
',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ ];
+
+ cellAttributeTestCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should strip malicious attributes from table cells while preserving allowed ones', () => {
+ const maliciousCellTestCases = [
+ {
+ html: 'Safe content | ',
+ expected: 'Safe content | ',
+ },
+ {
+ html: 'Header | ',
+ expected: 'Header | ',
+ },
+ {
+ html: 'Data | ',
+ expected: 'Data | ',
+ },
+ {
+ html: 'Multi-span header | ',
+ expected: 'Multi-span header | ',
+ },
+ ];
+
+ maliciousCellTestCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should handle complex table structures with tfoot and cell attributes', () => {
+ const complexTableTestCases = [
+ {
+ html: '| Sales Report |
|---|
| Product | Revenue |
|---|
| Widget A | $1,000 |
|---|
| Widget B | $2,000 |
|---|
| Total | $3,000 |
|---|
',
+ expected:
+ '| Sales Report |
|---|
| Product | Revenue |
|---|
| Widget A | $1,000 |
|---|
| Widget B | $2,000 |
|---|
| Total | $3,000 |
|---|
',
+ },
+ {
+ html: '| Multi-row cell | Cell 1 |
| Cell 2 |
| Footer spans both columns |
',
+ expected:
+ '| Multi-row cell | Cell 1 |
| Cell 2 |
| Footer spans both columns |
',
+ },
+ ];
+
+ complexTableTestCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should remove malicious attributes from table elements', () => {
+ const maliciousTableCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: '',
+ expected: '| Header |
',
+ },
+ {
+ html: '| Data |
',
+ expected: '| Data |
',
+ },
+ {
+ html: '| Cell |
',
+ expected: '| Cell |
',
+ },
+ {
+ html: 'Header | ',
+ expected: 'Header | ',
+ },
+ {
+ html: 'Cell Data | ',
+ expected: 'Cell Data | ',
+ },
+ ];
+
+ maliciousTableCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should handle nested content within table elements', () => {
+ const nestedTableCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ {
+ html: 'code snippet and preformatted text |
',
+ expected:
+ 'code snippet and preformatted text |
',
+ },
+ ];
+
+ nestedTableCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should handle malformed table structures gracefully', () => {
+ const malformedTableCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: 'Header without table | ',
+ expected: 'Header without table | ',
+ },
+ {
+ html: '| Unclosed table',
+ expected: ' |
| Unclosed table |
',
+ },
+ {
+ html: '| Mixed header | and data |
',
+ expected: '| Mixed header | and data |
',
+ },
+ ];
+
+ malformedTableCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should prevent XSS attacks through table elements', () => {
+ const xssTableCases = [
+ {
+ html: '',
+ expected: '',
+ },
+ {
+ html: ' Header |
',
+ expected: ' Header |
',
+ },
+ {
+ html: '| Malicious Link |
',
+ expected: '| Malicious Link |
',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ ];
+
+ xssTableCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
+
+ it('should preserve complex table layouts', () => {
+ const complexTableCases = [
+ {
+ html: '| Product | Price | Stock |
|---|
| Widget A | $10.99 | 50 |
| Widget B | $15.99 | 25 |
',
+ expected:
+ '| Product | Price | Stock |
|---|
| Widget A | $10.99 | 50 |
| Widget B | $15.99 | 25 |
',
+ },
+ {
+ html: '',
+ expected:
+ '',
+ },
+ ];
+
+ complexTableCases.forEach(({ html, expected }) => {
+ expect(sanitizeHtml(html)).toBe(expected);
+ });
+ });
});
describe('FormTrigger, formWebhook', () => {
diff --git a/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts b/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts
index 8fdd9c0a0f..544db0c2fd 100644
--- a/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts
+++ b/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts
@@ -10,6 +10,9 @@ import {
import { sanitizeCustomCss, sanitizeHtml } from './utils';
+const SANDBOX_CSP =
+ 'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols';
+
const getBinaryDataFromNode = (context: IWebhookFunctions, nodeName: string): IDataObject => {
return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as IDataObject;
};
@@ -52,7 +55,7 @@ export const renderFormCompletion = async (
formTitle: string;
customCss?: string;
};
- const responseText = context.getNodeParameter('responseText', '') as string;
+ const responseText = (context.getNodeParameter('responseText', '') as string) ?? '';
const binary =
context.getNodeParameter('respondWith', '') === 'returnBinary'
? await binaryResponse(context)
@@ -66,12 +69,13 @@ export const renderFormCompletion = async (
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
+ res.setHeader('Content-Security-Policy', SANDBOX_CSP);
res.render('form-trigger-completion', {
title: completionTitle,
message: sanitizeHtml(completionMessage),
formTitle: title,
appendAttribution,
- responseText: sanitizeHtml(responseText),
+ responseText,
responseBinary: encodeURIComponent(JSON.stringify(binary)),
dangerousCustomCss: sanitizeCustomCss(options.customCss),
redirectUrl,
diff --git a/packages/nodes-base/nodes/Form/utils/utils.ts b/packages/nodes-base/nodes/Form/utils/utils.ts
index ca1ca3b69f..0449d32d1b 100644
--- a/packages/nodes-base/nodes/Form/utils/utils.ts
+++ b/packages/nodes-base/nodes/Form/utils/utils.ts
@@ -54,6 +54,14 @@ export function sanitizeHtml(text: string) {
'ol',
'li',
'p',
+ 'table',
+ 'thead',
+ 'tbody',
+ 'tfoot',
+ 'td',
+ 'tr',
+ 'th',
+ 'br',
],
allowedAttributes: {
a: ['href', 'target', 'rel'],
@@ -69,6 +77,8 @@ export function sanitizeHtml(text: string) {
'referrerpolicy',
],
source: ['src', 'type'],
+ td: ['colspan', 'rowspan', 'scope', 'headers'],
+ th: ['colspan', 'rowspan', 'scope', 'headers'],
},
allowedSchemes: ['https', 'http'],
allowedSchemesByTag: {