diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index ee868b072f..695ef79472 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -2,6 +2,11 @@ + + + + +

{{formTitle}}

-

{{formDescription}}

+

{{{formDescription}}}

diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index 299284c59f..dc0d8bf076 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -29,7 +29,7 @@ export const formDescription: INodeProperties = { default: '', placeholder: "e.g. We'll get back to you soon", description: - 'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.', + 'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form. Accepts HTML.', typeOptions: { rows: 2, }, diff --git a/packages/nodes-base/nodes/Form/interfaces.ts b/packages/nodes-base/nodes/Form/interfaces.ts index 1cf5f64c92..b04d30d1d3 100644 --- a/packages/nodes-base/nodes/Form/interfaces.ts +++ b/packages/nodes-base/nodes/Form/interfaces.ts @@ -22,6 +22,7 @@ export type FormTriggerData = { validForm: boolean; formTitle: string; formDescription?: string; + formDescriptionMetadata?: string; formSubmittedHeader?: string; formSubmittedText?: string; redirectUrl?: string; diff --git a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts index 63dbfd4986..9c69179066 100644 --- a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts +++ b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts @@ -50,6 +50,7 @@ describe('FormTrigger', () => { appendAttribution: false, buttonLabel: 'Submit', formDescription: 'Test Description', + formDescriptionMetadata: 'Test Description', formFields: [ { defaultValue: '', diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index b37a3993e8..65decaa285 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -10,19 +10,58 @@ import type { import { formWebhook, + createDescriptionMetadata, prepareFormData, prepareFormReturnItem, resolveRawData, isFormConnected, } from '../utils'; +describe('FormTrigger, parseFormDescription', () => { + it('should remove HTML tags and truncate to 150 characters', () => { + const descriptions = [ + { description: '

This is a test description

', expected: 'This is a test description' }, + { description: 'Test description', expected: 'Test description' }, + { + description: + 'Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and soothing song.', + expected: + 'Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and so', + }, + { + description: + '

Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and soothing song.

', + expected: + 'Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and so', + }, + ]; + + descriptions.forEach(({ description, expected }) => { + expect(createDescriptionMetadata(description)).toBe(expected); + }); + }); +}); + describe('FormTrigger, formWebhook', () => { + const executeFunctions = mock(); + executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); + executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); + executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form'); + executeFunctions.getNodeParameter + .calledWith('formDescription') + .mockReturnValue('Test Description'); + executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); + executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any); + executeFunctions.getMode.mockReturnValue('manual'); + executeFunctions.getInstanceId.mockReturnValue('instanceId'); + executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} }); + executeFunctions.getChildNodes.mockReturnValue([]); + beforeEach(() => { jest.clearAllMocks(); }); it('should call response render', async () => { - const executeFunctions = mock(); const mockRender = jest.fn(); const formFields: FormFieldsParameter = [ @@ -43,20 +82,8 @@ describe('FormTrigger, formWebhook', () => { }, ]; - executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); - executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); - executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form'); - executeFunctions.getNodeParameter - .calledWith('formDescription') - .mockReturnValue('Test Description'); - executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any); - executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any); - executeFunctions.getMode.mockReturnValue('manual'); - executeFunctions.getInstanceId.mockReturnValue('instanceId'); - executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} }); - executeFunctions.getChildNodes.mockReturnValue([]); await formWebhook(executeFunctions); @@ -64,6 +91,7 @@ describe('FormTrigger, formWebhook', () => { appendAttribution: true, buttonLabel: 'Submit', formDescription: 'Test Description', + formDescriptionMetadata: 'Test Description', formFields: [ { defaultValue: '', @@ -117,8 +145,55 @@ describe('FormTrigger, formWebhook', () => { }); }); + it('should sanitize form descriptions', async () => { + const mockRender = jest.fn(); + + const formDescription = [ + { description: 'Test Description', expected: 'Test Description' }, + { description: 'hello', expected: 'hello' }, + { description: '', expected: '' }, + ]; + const formFields: FormFieldsParameter = [ + { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, + ]; + + executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); + executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any); + + for (const { description, expected } of formDescription) { + executeFunctions.getNodeParameter.calledWith('formDescription').mockReturnValue(description); + + await formWebhook(executeFunctions); + + expect(mockRender).toHaveBeenCalledWith('form-trigger', { + appendAttribution: true, + buttonLabel: 'Submit', + formDescription: expected, + formDescriptionMetadata: createDescriptionMetadata(expected), + formFields: [ + { + defaultValue: '', + errorId: 'error-field-0', + id: 'field-0', + inputRequired: 'form-required', + isInput: true, + label: 'Name', + placeholder: undefined, + type: 'text', + }, + ], + formSubmittedText: 'Your response has been recorded', + formTitle: 'Test Form', + n8nWebsiteLink: + 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId', + testRun: true, + useResponseData: false, + validForm: true, + }); + } + }); + it('should return workflowData on POST request', async () => { - const executeFunctions = mock(); const mockStatus = jest.fn(); const mockEnd = jest.fn(); @@ -132,15 +207,9 @@ describe('FormTrigger, formWebhook', () => { 'field-1': '30', }; - executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); - executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); - executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); - executeFunctions.getChildNodes.mockReturnValue([]); executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any); executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any); - executeFunctions.getMode.mockReturnValue('manual'); - executeFunctions.getInstanceId.mockReturnValue('instanceId'); executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} }); const result = await formWebhook(executeFunctions); @@ -213,6 +282,7 @@ describe('FormTrigger, prepareFormData', () => { validForm: true, formTitle: 'Test Form', formDescription: 'This is a test form', + formDescriptionMetadata: 'This is a test form', formSubmittedText: 'Thank you for your submission', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=test-instance', @@ -292,6 +362,7 @@ describe('FormTrigger, prepareFormData', () => { validForm: true, formTitle: 'Test Form', formDescription: 'This is a test form', + formDescriptionMetadata: 'This is a test form', formSubmittedText: 'Your response has been recorded', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger', formFields: [ diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 6fc414f622..e4b46c72fd 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -16,6 +16,7 @@ import { WAIT_NODE_TYPE, jsonParse, } from 'n8n-workflow'; +import sanitize from 'sanitize-html'; import type { FormTriggerData, FormTriggerInput } from './interfaces'; import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; @@ -23,6 +24,41 @@ import { getResolvables } from '../../utils/utilities'; import { WebhookAuthorizationError } from '../Webhook/error'; import { validateWebhookAuthentication } from '../Webhook/utils'; +function sanitizeHtml(text: string) { + return sanitize(text, { + allowedTags: [ + 'b', + 'i', + 'em', + 'strong', + 'a', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'u', + 'sub', + 'sup', + 'code', + 'pre', + 'span', + 'br', + ], + allowedAttributes: { + a: ['href', 'target', 'rel'], + }, + nonBooleanAttributes: ['*'], + }); +} + +export function createDescriptionMetadata(description: string) { + return description === '' + ? 'n8n form' + : description.replace(/^\s*\n+|<\/?[^>]+(>|$)/g, '').slice(0, 150); +} + export function prepareFormData({ formTitle, formDescription, @@ -63,6 +99,7 @@ export function prepareFormData({ validForm, formTitle, formDescription, + formDescriptionMetadata: createDescriptionMetadata(formDescription), formSubmittedHeader, formSubmittedText, n8nWebsiteLink, @@ -380,7 +417,7 @@ export async function formWebhook( //Show the form on GET request if (method === 'GET') { const formTitle = context.getNodeParameter('formTitle', '') as string; - const formDescription = context.getNodeParameter('formDescription', '') as string; + const formDescription = sanitizeHtml(context.getNodeParameter('formDescription', '') as string); const responseMode = context.getNodeParameter('responseMode', '') as string; let formSubmittedText; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9a42eae713..309d93c310 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -841,6 +841,7 @@ "@types/nodemailer": "^6.4.14", "@types/promise-ftp": "^1.3.4", "@types/rfc2047": "^2.0.1", + "@types/sanitize-html": "^2.11.0", "@types/showdown": "^1.9.4", "@types/snowflake-sdk": "^1.6.24", "@types/ssh2-sftp-client": "^5.1.0", @@ -906,6 +907,7 @@ "rhea": "1.0.24", "rrule": "2.8.1", "rss-parser": "3.13.0", + "sanitize-html": "2.12.1", "semver": "7.5.4", "showdown": "2.1.0", "simple-git": "3.17.0", diff --git a/packages/nodes-base/utils/sendAndWait/test/util.test.ts b/packages/nodes-base/utils/sendAndWait/test/util.test.ts index 5194887f2f..39a6f16859 100644 --- a/packages/nodes-base/utils/sendAndWait/test/util.test.ts +++ b/packages/nodes-base/utils/sendAndWait/test/util.test.ts @@ -240,6 +240,7 @@ describe('Send and Wait utils tests', () => { validForm: true, formTitle: '', formDescription: 'Test message', + formDescriptionMetadata: 'Test message', formSubmittedHeader: 'Got it, thanks', formSubmittedText: 'This page can be closed now', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger', @@ -318,6 +319,7 @@ describe('Send and Wait utils tests', () => { validForm: true, formTitle: 'Test title', formDescription: 'Test description', + formDescriptionMetadata: 'Test description', formSubmittedHeader: 'Got it, thanks', formSubmittedText: 'This page can be closed now', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 593b2451d1..af9b0caa4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1851,6 +1851,9 @@ importers: rss-parser: specifier: 3.13.0 version: 3.13.0 + sanitize-html: + specifier: 2.12.1 + version: 2.12.1 semver: specifier: ^7.5.4 version: 7.6.0 @@ -1936,6 +1939,9 @@ importers: '@types/rfc2047': specifier: ^2.0.1 version: 2.0.1 + '@types/sanitize-html': + specifier: ^2.11.0 + version: 2.11.0 '@types/showdown': specifier: ^1.9.4 version: 1.9.4