mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(n8n Form Node): Add html table tags to allowedTags, CSP headers on form completion, free text sanitization removed (#19446)
This commit is contained in:
@@ -245,7 +245,7 @@ describe('Form Node', () => {
|
||||
message: 'Test Message',
|
||||
redirectUrl: '',
|
||||
title: 'Test Title',
|
||||
responseText: '<div>hey</div>',
|
||||
responseText: '<div>hey</div><script>alert("hi")</script>',
|
||||
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,
|
||||
|
||||
@@ -118,12 +118,13 @@ describe('formCompletionUtils', () => {
|
||||
it('should call sanitizeHtml on completionMessage', async () => {
|
||||
const sanitizeHtmlSpy = jest.spyOn(utils, 'sanitizeHtml');
|
||||
const maliciousMessage = '<script>alert("xss")</script>Safe message<b>bold</b>';
|
||||
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', () => {
|
||||
|
||||
@@ -89,6 +89,259 @@ describe('FormTrigger, sanitizeHtml', () => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow table elements and preserve structure', () => {
|
||||
const tableTestCases = [
|
||||
{
|
||||
html: '<table><tr><td>Cell content</td></tr></table>',
|
||||
expected: '<table><tr><td>Cell content</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody></table>',
|
||||
expected:
|
||||
'<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><thead><tr><th>Name</th><th>Age</th></tr></thead><tbody><tr><td>John</td><td>30</td></tr><tr><td>Jane</td><td>25</td></tr></tbody></table>',
|
||||
expected:
|
||||
'<table><thead><tr><th>Name</th><th>Age</th></tr></thead><tbody><tr><td>John</td><td>30</td></tr><tr><td>Jane</td><td>25</td></tr></tbody></table>',
|
||||
},
|
||||
];
|
||||
|
||||
tableTestCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow tfoot elements and preserve table footer structure', () => {
|
||||
const tfootTestCases = [
|
||||
{
|
||||
html: '<table><tfoot><tr><td>Footer content</td></tr></tfoot></table>',
|
||||
expected: '<table><tfoot><tr><td>Footer content</td></tr></tfoot></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody><tfoot><tr><td>Footer</td></tr></tfoot></table>',
|
||||
expected:
|
||||
'<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody><tfoot><tr><td>Footer</td></tr></tfoot></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tfoot><tr><th>Total</th><td>$100</td></tr></tfoot></table>',
|
||||
expected: '<table><tfoot><tr><th>Total</th><td>$100</td></tr></tfoot></table>',
|
||||
},
|
||||
];
|
||||
|
||||
tfootTestCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve table cell attributes (colspan, rowspan, scope, headers)', () => {
|
||||
const cellAttributeTestCases = [
|
||||
{
|
||||
html: '<table><tr><td colspan="2">Spanning cell</td></tr></table>',
|
||||
expected: '<table><tr><td colspan="2">Spanning cell</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><th rowspan="3">Header</th><td>Data 1</td></tr><tr><td>Data 2</td></tr><tr><td>Data 3</td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><th rowspan="3">Header</th><td>Data 1</td></tr><tr><td>Data 2</td></tr><tr><td>Data 3</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><th scope="col">Column Header</th></tr><tr><th scope="row">Row Header</th><td>Data</td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><th scope="col">Column Header</th></tr><tr><th scope="row">Row Header</th><td>Data</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><th id="header1">Name</th><th id="header2">Age</th></tr><tr><td headers="header1">John</td><td headers="header2">30</td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><th>Name</th><th>Age</th></tr><tr><td headers="header1">John</td><td headers="header2">30</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><td colspan="2" rowspan="2">Complex cell</td><td>Simple cell</td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><td colspan="2" rowspan="2">Complex cell</td><td>Simple cell</td></tr></table>',
|
||||
},
|
||||
];
|
||||
|
||||
cellAttributeTestCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip malicious attributes from table cells while preserving allowed ones', () => {
|
||||
const maliciousCellTestCases = [
|
||||
{
|
||||
html: '<td onclick="alert(\'XSS\')" colspan="2" style="color: red;">Safe content</td>',
|
||||
expected: '<td colspan="2">Safe content</td>',
|
||||
},
|
||||
{
|
||||
html: '<th onmouseover="steal()" rowspan="3" class="malicious" scope="col">Header</th>',
|
||||
expected: '<th rowspan="3" scope="col">Header</th>',
|
||||
},
|
||||
{
|
||||
html: '<td headers="header1" data-evil="payload" onerror="hack()">Data</td>',
|
||||
expected: '<td headers="header1">Data</td>',
|
||||
},
|
||||
{
|
||||
html: '<th colspan="2" rowspan="2" onclick="javascript:alert(\'XSS\')" scope="colgroup">Multi-span header</th>',
|
||||
expected: '<th colspan="2" rowspan="2" scope="colgroup">Multi-span header</th>',
|
||||
},
|
||||
];
|
||||
|
||||
maliciousCellTestCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex table structures with tfoot and cell attributes', () => {
|
||||
const complexTableTestCases = [
|
||||
{
|
||||
html: '<table><thead><tr><th colspan="2" scope="colgroup">Sales Report</th></tr><tr><th scope="col">Product</th><th scope="col">Revenue</th></tr></thead><tbody><tr><th scope="row">Widget A</th><td>$1,000</td></tr><tr><th scope="row">Widget B</th><td>$2,000</td></tr></tbody><tfoot><tr><th scope="row">Total</th><td colspan="1">$3,000</td></tr></tfoot></table>',
|
||||
expected:
|
||||
'<table><thead><tr><th colspan="2" scope="colgroup">Sales Report</th></tr><tr><th scope="col">Product</th><th scope="col">Revenue</th></tr></thead><tbody><tr><th scope="row">Widget A</th><td>$1,000</td></tr><tr><th scope="row">Widget B</th><td>$2,000</td></tr></tbody><tfoot><tr><th scope="row">Total</th><td colspan="1">$3,000</td></tr></tfoot></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tbody><tr><td rowspan="2">Multi-row cell</td><td>Cell 1</td></tr><tr><td>Cell 2</td></tr></tbody><tfoot><tr><td colspan="2">Footer spans both columns</td></tr></tfoot></table>',
|
||||
expected:
|
||||
'<table><tbody><tr><td rowspan="2">Multi-row cell</td><td>Cell 1</td></tr><tr><td>Cell 2</td></tr></tbody><tfoot><tr><td colspan="2">Footer spans both columns</td></tr></tfoot></table>',
|
||||
},
|
||||
];
|
||||
|
||||
complexTableTestCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove malicious attributes from table elements', () => {
|
||||
const maliciousTableCases = [
|
||||
{
|
||||
html: '<table onclick="alert(\'XSS\')" class="malicious"><tr><td>Content</td></tr></table>',
|
||||
expected: '<table><tr><td>Content</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<thead onmouseover="steal()" id="header"><tr><th onclick="hack()">Header</th></tr></thead>',
|
||||
expected: '<thead><tr><th>Header</th></tr></thead>',
|
||||
},
|
||||
{
|
||||
html: '<tbody style="background: red;" data-evil="payload"><tr><td onerror="malicious()">Data</td></tr></tbody>',
|
||||
expected: '<tbody><tr><td>Data</td></tr></tbody>',
|
||||
},
|
||||
{
|
||||
html: '<tr onload="alert(\'XSS\')" class="row"><td onblur="steal()" title="tooltip">Cell</td></tr>',
|
||||
expected: '<tr><td>Cell</td></tr>',
|
||||
},
|
||||
{
|
||||
html: '<th onclick="javascript:alert(\'XSS\')" style="color: red;">Header</th>',
|
||||
expected: '<th>Header</th>',
|
||||
},
|
||||
{
|
||||
html: '<td onmouseover="malicious()" data-payload="evil">Cell Data</td>',
|
||||
expected: '<td>Cell Data</td>',
|
||||
},
|
||||
];
|
||||
|
||||
maliciousTableCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested content within table elements', () => {
|
||||
const nestedTableCases = [
|
||||
{
|
||||
html: '<table><tr><td><strong>Bold</strong> and <em>italic</em> text</td></tr></table>',
|
||||
expected: '<table><tr><td><strong>Bold</strong> and <em>italic</em> text</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><thead><tr><th><a href="https://example.com">Link Header</a></th></tr></thead></table>',
|
||||
expected:
|
||||
'<table><thead><tr><th><a href="https://example.com">Link Header</a></th></tr></thead></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tbody><tr><td><ul><li>Item 1</li><li>Item 2</li></ul></td></tr></tbody></table>',
|
||||
expected:
|
||||
'<table><tbody><tr><td><ul><li>Item 1</li><li>Item 2</li></ul></td></tr></tbody></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><td><code>code snippet</code> and <pre>preformatted text</pre></td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><td><code>code snippet</code> and <pre>preformatted text</pre></td></tr></table>',
|
||||
},
|
||||
];
|
||||
|
||||
nestedTableCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle malformed table structures gracefully', () => {
|
||||
const malformedTableCases = [
|
||||
{
|
||||
html: '<table><td>Cell without row</td></table>',
|
||||
expected: '<table><td>Cell without row</td></table>',
|
||||
},
|
||||
{
|
||||
html: '<thead><th>Header without table</th></thead>',
|
||||
expected: '<thead><th>Header without table</th></thead>',
|
||||
},
|
||||
{
|
||||
html: '<tbody><tr><td>Unclosed table',
|
||||
expected: '<tbody><tr><td>Unclosed table</td></tr></tbody>',
|
||||
},
|
||||
{
|
||||
html: '<tr><th>Mixed header</th><td>and data</td></tr>',
|
||||
expected: '<tr><th>Mixed header</th><td>and data</td></tr>',
|
||||
},
|
||||
];
|
||||
|
||||
malformedTableCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent XSS attacks through table elements', () => {
|
||||
const xssTableCases = [
|
||||
{
|
||||
html: '<table><tr><td><script>alert("XSS")</script>Safe content</td></tr></table>',
|
||||
expected: '<table><tr><td>Safe content</td></tr></table>',
|
||||
},
|
||||
{
|
||||
html: '<thead><tr><th><img src="x" onerror="alert(\'XSS\')">Header</th></tr></thead>',
|
||||
expected: '<thead><tr><th><img src="x" />Header</th></tr></thead>',
|
||||
},
|
||||
{
|
||||
html: '<tbody><tr><td><a href="javascript:alert(\'XSS\')">Malicious Link</a></td></tr></tbody>',
|
||||
expected: '<tbody><tr><td><a>Malicious Link</a></td></tr></tbody>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><td><iframe src="javascript:alert(\'XSS\')"></iframe></td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><td><iframe referrerpolicy="strict-origin-when-cross-origin" allow="fullscreen; autoplay; encrypted-media"></iframe></td></tr></table>',
|
||||
},
|
||||
];
|
||||
|
||||
xssTableCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve complex table layouts', () => {
|
||||
const complexTableCases = [
|
||||
{
|
||||
html: '<table><thead><tr><th>Product</th><th>Price</th><th>Stock</th></tr></thead><tbody><tr><td>Widget A</td><td>$10.99</td><td>50</td></tr><tr><td>Widget B</td><td>$15.99</td><td>25</td></tr></tbody></table>',
|
||||
expected:
|
||||
'<table><thead><tr><th>Product</th><th>Price</th><th>Stock</th></tr></thead><tbody><tr><td>Widget A</td><td>$10.99</td><td>50</td></tr><tr><td>Widget B</td><td>$15.99</td><td>25</td></tr></tbody></table>',
|
||||
},
|
||||
{
|
||||
html: '<table><tr><th>Q1</th><th>Q2</th><th>Q3</th><th>Q4</th></tr><tr><td>100</td><td>150</td><td>200</td><td>175</td></tr></table>',
|
||||
expected:
|
||||
'<table><tr><th>Q1</th><th>Q2</th><th>Q3</th><th>Q4</th></tr><tr><td>100</td><td>150</td><td>200</td><td>175</td></tr></table>',
|
||||
},
|
||||
];
|
||||
|
||||
complexTableCases.forEach(({ html, expected }) => {
|
||||
expect(sanitizeHtml(html)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormTrigger, formWebhook', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user