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: '
Cell content
', + expected: '
Cell content
', + }, + { + html: '
Header
Data
', + expected: + '
Header
Data
', + }, + { + html: '
NameAge
John30
Jane25
', + expected: + '
NameAge
John30
Jane25
', + }, + ]; + + tableTestCases.forEach(({ html, expected }) => { + expect(sanitizeHtml(html)).toBe(expected); + }); + }); + + it('should allow tfoot elements and preserve table footer structure', () => { + const tfootTestCases = [ + { + html: '
Footer content
', + expected: '
Footer content
', + }, + { + html: '
Header
Data
Footer
', + expected: + '
Header
Data
Footer
', + }, + { + html: '
Total$100
', + expected: '
Total$100
', + }, + ]; + + tfootTestCases.forEach(({ html, expected }) => { + expect(sanitizeHtml(html)).toBe(expected); + }); + }); + + it('should preserve table cell attributes (colspan, rowspan, scope, headers)', () => { + const cellAttributeTestCases = [ + { + html: '
Spanning cell
', + expected: '
Spanning cell
', + }, + { + html: '
HeaderData 1
Data 2
Data 3
', + expected: + '
HeaderData 1
Data 2
Data 3
', + }, + { + html: '
Column Header
Row HeaderData
', + expected: + '
Column Header
Row HeaderData
', + }, + { + html: '
NameAge
John30
', + expected: + '
NameAge
John30
', + }, + { + html: '
Complex cellSimple cell
', + expected: + '
Complex cellSimple cell
', + }, + ]; + + 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
ProductRevenue
Widget A$1,000
Widget B$2,000
Total$3,000
', + expected: + '
Sales Report
ProductRevenue
Widget A$1,000
Widget B$2,000
Total$3,000
', + }, + { + html: '
Multi-row cellCell 1
Cell 2
Footer spans both columns
', + expected: + '
Multi-row cellCell 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: '
Content
', + expected: '
Content
', + }, + { + html: 'Header', + 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: '
Bold and italic text
', + expected: '
Bold and italic text
', + }, + { + html: '
Link Header
', + expected: + '
Link Header
', + }, + { + html: '
  • Item 1
  • Item 2
', + expected: + '
  • Item 1
  • Item 2
', + }, + { + 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: '
Cell without row
', + expected: '
Cell without row
', + }, + { + html: 'Header without table', + expected: 'Header without table', + }, + { + html: 'Unclosed table', + expected: 'Unclosed table', + }, + { + html: 'Mixed headerand data', + expected: 'Mixed headerand data', + }, + ]; + + malformedTableCases.forEach(({ html, expected }) => { + expect(sanitizeHtml(html)).toBe(expected); + }); + }); + + it('should prevent XSS attacks through table elements', () => { + const xssTableCases = [ + { + html: '
Safe content
', + expected: '
Safe content
', + }, + { + 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: '
ProductPriceStock
Widget A$10.9950
Widget B$15.9925
', + expected: + '
ProductPriceStock
Widget A$10.9950
Widget B$15.9925
', + }, + { + html: '
Q1Q2Q3Q4
100150200175
', + expected: + '
Q1Q2Q3Q4
100150200175
', + }, + ]; + + 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: {