From f167578b3251e553a4d000e731e1bb60348916ad Mon Sep 17 00:00:00 2001
From: Dana <152518854+dana-gill@users.noreply.github.com>
Date: Mon, 20 Jan 2025 16:52:06 +0100
Subject: [PATCH] feat(n8n Form Trigger Node): Form Improvements (#12590)

---
 .../cli/templates/form-trigger.handlebars     |   7 +-
 .../nodes/Form/common.descriptions.ts         |   2 +-
 packages/nodes-base/nodes/Form/interfaces.ts  |   1 +
 .../Form/test/FormTriggerV2.node.test.ts      |   1 +
 .../nodes-base/nodes/Form/test/utils.test.ts  | 111 ++++++++++++++----
 packages/nodes-base/nodes/Form/utils.ts       |  39 +++++-
 packages/nodes-base/package.json              |   2 +
 .../utils/sendAndWait/test/util.test.ts       |   2 +
 pnpm-lock.yaml                                |   6 +
 9 files changed, 148 insertions(+), 23 deletions(-)

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 @@
 	<head>
 		<meta charset='UTF-8' />
 		<meta name='viewport' content='width=device-width, initial-scale=1.0' />
+		<meta name="description" content="{{formDescriptionMetadata}}" />
+		<meta property="og:title" content="{{formTitle}}" />
+		<meta property="og:description" content="{{formDescriptionMetadata}}" />
+		<meta property="og:type" content="website" />
+		<meta property="og:image" content="https://raw.githubusercontent.com/n8n-io/n8n/80be10551eb081cb11bd8cab6c6ff89e44493d2c/assets/og_image.png?raw=true" />
 		<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
 		<link
 			href='https://fonts.googleapis.com/css?family=Open+Sans'
@@ -327,7 +332,7 @@
 					<form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate>
 						<div class='form-header'>
 							<h1>{{formTitle}}</h1>
-							<p style="white-space: pre-line">{{formDescription}} </p>
+							<p style="white-space: pre-line">{{{formDescription}}} </p>
 						</div>
 
 						<div class='inputs-wrapper'>
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: '<p>This is a test description</p>', 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:
+					'<p>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.</p>',
+				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<IWebhookFunctions>();
+	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<IWebhookFunctions>();
 		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: '<i>hello</i>', expected: '<i>hello</i>' },
+			{ description: '<script>alert("hello world")</script>', 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<IWebhookFunctions>();
 		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