diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 695ef79472..6bad1a02d8 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -371,6 +371,12 @@ {{/if}} + {{#if isHtml}} +
+ {{{html}}} +
+ {{/if}} + {{#if isTextarea}}
diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index dc0d8bf076..67cf3515d1 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -2,6 +2,12 @@ import type { INodeProperties } from 'n8n-workflow'; import { appendAttributionOption } from '../../utils/descriptions'; +export const placeholder: string = ` + + + +`.trimStart(); + export const webhookPath: INodeProperties = { displayName: 'Form Path', name: 'path', @@ -36,9 +42,9 @@ export const formDescription: INodeProperties = { }; export const formFields: INodeProperties = { - displayName: 'Form Fields', + displayName: 'Form Elements', name: 'formFields', - placeholder: 'Add Form Field', + placeholder: 'Add Form Element', type: 'fixedCollection', default: { values: [{ label: '', fieldType: 'text' }] }, typeOptions: { @@ -60,12 +66,16 @@ export const formFields: INodeProperties = { required: true, }, { - displayName: 'Field Type', + displayName: 'Element Type', name: 'fieldType', type: 'options', default: 'text', description: 'The type of field to add to the form', options: [ + { + name: 'Custom HTML', + value: 'html', + }, { name: 'Date', value: 'date', @@ -109,7 +119,7 @@ export const formFields: INodeProperties = { default: '', displayOptions: { hide: { - fieldType: ['dropdown', 'date', 'file'], + fieldType: ['dropdown', 'date', 'file', 'html'], }, }, }, @@ -158,6 +168,21 @@ export const formFields: INodeProperties = { }, }, }, + { + displayName: 'HTML Template', + name: 'html', + typeOptions: { + editor: 'htmlEditor', + }, + type: 'string', + default: placeholder, + description: 'HTML template to render', + displayOptions: { + show: { + fieldType: ['html'], + }, + }, + }, { displayName: 'Multiple Files', name: 'multipleFiles', @@ -190,6 +215,23 @@ export const formFields: INodeProperties = { name: 'formatDate', type: 'notice', default: '', + displayOptions: { + show: { + fieldType: ['date'], + }, + }, + }, + { + displayName: + 'Does not accept <style> <script> or <input> tags.', + name: 'htmlTips', + type: 'notice', + default: '', + displayOptions: { + show: { + fieldType: ['html'], + }, + }, }, { displayName: 'Required Field', @@ -198,6 +240,11 @@ export const formFields: INodeProperties = { default: false, description: 'Whether to require the user to enter a value for this field before submitting the form', + displayOptions: { + hide: { + fieldType: ['html'], + }, + }, }, ], }, diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 65decaa285..4d1bf39c36 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -15,6 +15,7 @@ import { prepareFormReturnItem, resolveRawData, isFormConnected, + sanitizeHtml, } from '../utils'; describe('FormTrigger, parseFormDescription', () => { @@ -42,6 +43,29 @@ describe('FormTrigger, parseFormDescription', () => { }); }); +describe('FormTrigger, sanitizeHtml', () => { + it('should remove forbidden HTML tags', () => { + const givenHtml = [ + { + html: '', + expected: '', + }, + { + html: '', + expected: '', + }, + { + html: '', + expected: '', + }, + ]; + + givenHtml.forEach(({ html, expected }) => { + expect(sanitizeHtml(html)).toBe(expected); + }); + }); +}); + describe('FormTrigger, formWebhook', () => { const executeFunctions = mock(); executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); @@ -80,6 +104,12 @@ describe('FormTrigger, formWebhook', () => { acceptFileTypes: '.pdf,.doc', multipleFiles: false, }, + { + fieldLabel: 'Custom HTML', + fieldType: 'html', + html: '
Test HTML
', + requiredField: false, + }, ]; executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); @@ -134,6 +164,16 @@ describe('FormTrigger, formWebhook', () => { multipleFiles: '', placeholder: undefined, }, + { + id: 'field-4', + errorId: 'error-field-4', + label: 'Custom HTML', + inputRequired: '', + defaultValue: '', + placeholder: undefined, + html: '
Test HTML
', + isHtml: true, + }, ], formSubmittedText: 'Your response has been recorded', formTitle: 'Test Form', diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index e4b46c72fd..2510be56ac 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -24,11 +24,16 @@ import { getResolvables } from '../../utils/utilities'; import { WebhookAuthorizationError } from '../Webhook/error'; import { validateWebhookAuthentication } from '../Webhook/utils'; -function sanitizeHtml(text: string) { +export function sanitizeHtml(text: string) { return sanitize(text, { allowedTags: [ 'b', + 'div', 'i', + 'iframe', + 'img', + 'video', + 'source', 'em', 'strong', 'a', @@ -48,8 +53,18 @@ function sanitizeHtml(text: string) { ], allowedAttributes: { a: ['href', 'target', 'rel'], + img: ['src', 'alt', 'width', 'height'], + video: ['*'], + iframe: ['*'], + source: ['*'], + }, + transformTags: { + iframe: sanitize.simpleTransform('iframe', { + sandbox: '', + referrerpolicy: 'strict-origin-when-cross-origin', + allow: 'fullscreen; autoplay; encrypted-media', + }), }, - nonBooleanAttributes: ['*'], }); } @@ -149,6 +164,9 @@ export function prepareFormData({ input.selectOptions = fieldOptions.map((e) => e.option); } else if (fieldType === 'textarea') { input.isTextarea = true; + } else if (fieldType === 'html') { + input.isHtml = true; + input.html = field.html as string; } else { input.isInput = true; input.type = fieldType as 'text' | 'number' | 'date' | 'email'; @@ -409,7 +427,14 @@ export async function formWebhook( } const mode = context.getMode() === 'manual' ? 'test' : 'production'; - const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter; + const formFields = (context.getNodeParameter('formFields.values', []) as FormFieldsParameter).map( + (field) => { + if (field.fieldType === 'html') { + field.html = sanitizeHtml(field.html as string); + } + return field; + }, + ); const method = context.getRequestObject().method; checkResponseModeConfiguration(context); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 10fc8e9b11..dd6c0ef1ce 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2684,6 +2684,7 @@ export type FormFieldsParameter = Array<{ multipleFiles?: boolean; acceptFileTypes?: string; formatDate?: string; + html?: string; placeholder?: string; }>;