mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 11:01:15 +00:00
fix(n8n Form Node): Prevent XSS with video and source tags (#16329)
This commit is contained in:
619
packages/nodes-base/nodes/Form/utils/utils.ts
Normal file
619
packages/nodes-base/nodes/Form/utils/utils.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import type { Response } from 'express';
|
||||
import isbot from 'isbot';
|
||||
import { DateTime } from 'luxon';
|
||||
import type {
|
||||
INodeExecutionData,
|
||||
MultiPartFormData,
|
||||
IDataObject,
|
||||
IWebhookFunctions,
|
||||
FormFieldsParameter,
|
||||
NodeTypeAndVersion,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
FORM_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
NodeOperationError,
|
||||
WAIT_NODE_TYPE,
|
||||
jsonParse,
|
||||
} from 'n8n-workflow';
|
||||
import sanitize from 'sanitize-html';
|
||||
|
||||
import { getResolvables } from '../../../utils/utilities';
|
||||
import { WebhookAuthorizationError } from '../../Webhook/error';
|
||||
import { validateWebhookAuthentication } from '../../Webhook/utils';
|
||||
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
|
||||
import type { FormTriggerData, FormTriggerInput } from '../interfaces';
|
||||
|
||||
export function sanitizeHtml(text: string) {
|
||||
return sanitize(text, {
|
||||
allowedTags: [
|
||||
'b',
|
||||
'div',
|
||||
'i',
|
||||
'iframe',
|
||||
'img',
|
||||
'video',
|
||||
'source',
|
||||
'em',
|
||||
'strong',
|
||||
'a',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'u',
|
||||
'sub',
|
||||
'sup',
|
||||
'code',
|
||||
'pre',
|
||||
'span',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'p',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target', 'rel'],
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
video: ['controls', 'autoplay', 'loop', 'muted', 'poster', 'width', 'height'],
|
||||
iframe: [
|
||||
'src',
|
||||
'width',
|
||||
'height',
|
||||
'frameborder',
|
||||
'allow',
|
||||
'allowfullscreen',
|
||||
'referrerpolicy',
|
||||
],
|
||||
source: ['src', 'type'],
|
||||
},
|
||||
allowedSchemes: ['https', 'http'],
|
||||
allowedSchemesByTag: {
|
||||
source: ['https', 'http'],
|
||||
iframe: ['https', 'http'],
|
||||
},
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
iframe: sanitize.simpleTransform('iframe', {
|
||||
sandbox: '',
|
||||
referrerpolicy: 'strict-origin-when-cross-origin',
|
||||
allow: 'fullscreen; autoplay; encrypted-media',
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const prepareFormFields = (context: IWebhookFunctions, fields: FormFieldsParameter) => {
|
||||
return fields.map((field) => {
|
||||
if (field.fieldType === 'html') {
|
||||
let { html } = field;
|
||||
|
||||
if (!html) return field;
|
||||
|
||||
for (const resolvable of getResolvables(html)) {
|
||||
html = html.replace(resolvable, context.evaluateExpression(resolvable) as string);
|
||||
}
|
||||
|
||||
field.html = sanitizeHtml(html);
|
||||
}
|
||||
|
||||
if (field.fieldType === 'hiddenField') {
|
||||
field.fieldLabel = field.fieldName as string;
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
export function sanitizeCustomCss(css: string | undefined): string | undefined {
|
||||
if (!css) return undefined;
|
||||
|
||||
// Use sanitize-html with custom settings for CSS
|
||||
return sanitize(css, {
|
||||
allowedTags: [], // No HTML tags allowed
|
||||
allowedAttributes: {}, // No attributes allowed
|
||||
// This ensures we're only keeping the text content
|
||||
// which should be the CSS, while removing any HTML/script tags
|
||||
});
|
||||
}
|
||||
|
||||
export function createDescriptionMetadata(description: string) {
|
||||
return description === ''
|
||||
? 'n8n form'
|
||||
: description.replace(/^\s*\n+|<\/?[^>]+(>|$)/g, '').slice(0, 150);
|
||||
}
|
||||
|
||||
export function prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader,
|
||||
formSubmittedText,
|
||||
redirectUrl,
|
||||
formFields,
|
||||
testRun,
|
||||
query,
|
||||
instanceId,
|
||||
useResponseData,
|
||||
appendAttribution = true,
|
||||
buttonLabel,
|
||||
customCss,
|
||||
}: {
|
||||
formTitle: string;
|
||||
formDescription: string;
|
||||
formSubmittedText: string | undefined;
|
||||
redirectUrl: string | undefined;
|
||||
formFields: FormFieldsParameter;
|
||||
testRun: boolean;
|
||||
query: IDataObject;
|
||||
instanceId?: string;
|
||||
useResponseData?: boolean;
|
||||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
formSubmittedHeader?: string;
|
||||
customCss?: string;
|
||||
}) {
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
|
||||
|
||||
if (formSubmittedText === undefined) {
|
||||
formSubmittedText = 'Your response has been recorded';
|
||||
}
|
||||
|
||||
const formData: FormTriggerData = {
|
||||
testRun,
|
||||
formTitle,
|
||||
formDescription,
|
||||
formDescriptionMetadata: createDescriptionMetadata(formDescription),
|
||||
formSubmittedHeader,
|
||||
formSubmittedText,
|
||||
n8nWebsiteLink,
|
||||
formFields: [],
|
||||
useResponseData,
|
||||
appendAttribution,
|
||||
buttonLabel,
|
||||
dangerousCustomCss: sanitizeCustomCss(customCss),
|
||||
};
|
||||
|
||||
if (redirectUrl) {
|
||||
if (!redirectUrl.includes('://')) {
|
||||
redirectUrl = `http://${redirectUrl}`;
|
||||
}
|
||||
formData.redirectUrl = redirectUrl;
|
||||
}
|
||||
|
||||
for (const [index, field] of formFields.entries()) {
|
||||
const { fieldType, requiredField, multiselect, placeholder } = field;
|
||||
|
||||
const input: IDataObject = {
|
||||
id: `field-${index}`,
|
||||
errorId: `error-field-${index}`,
|
||||
label: field.fieldLabel,
|
||||
inputRequired: requiredField ? 'form-required' : '',
|
||||
defaultValue: query[field.fieldLabel] ?? '',
|
||||
placeholder,
|
||||
};
|
||||
|
||||
if (multiselect) {
|
||||
input.isMultiSelect = true;
|
||||
input.multiSelectOptions =
|
||||
field.fieldOptions?.values.map((e, i) => ({
|
||||
id: `option${i}_${input.id}`,
|
||||
label: e.option,
|
||||
})) ?? [];
|
||||
} else if (fieldType === 'file') {
|
||||
input.isFileInput = true;
|
||||
input.acceptFileTypes = field.acceptFileTypes;
|
||||
input.multipleFiles = field.multipleFiles ? 'multiple' : '';
|
||||
} else if (fieldType === 'dropdown') {
|
||||
input.isSelect = true;
|
||||
const fieldOptions = field.fieldOptions?.values ?? [];
|
||||
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 if (fieldType === 'hiddenField') {
|
||||
input.isHidden = true;
|
||||
input.hiddenName = field.fieldName as string;
|
||||
input.hiddenValue =
|
||||
input.defaultValue === '' ? (field.fieldValue as string) : input.defaultValue;
|
||||
} else {
|
||||
input.isInput = true;
|
||||
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
|
||||
}
|
||||
|
||||
formData.formFields.push(input as FormTriggerInput);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
export const validateResponseModeConfiguration = (context: IWebhookFunctions) => {
|
||||
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
|
||||
const connectedNodes = context.getChildNodes(context.getNode().name);
|
||||
const nodeVersion = context.getNode().typeVersion;
|
||||
|
||||
const isRespondToWebhookConnected = connectedNodes.some(
|
||||
(node) => node.type === 'n8n-nodes-base.respondToWebhook',
|
||||
);
|
||||
|
||||
if (!isRespondToWebhookConnected && responseMode === 'responseNode') {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
new Error('No Respond to Webhook node found in the workflow'),
|
||||
{
|
||||
description:
|
||||
'Insert a Respond to Webhook node to your workflow to respond to the form submission or choose another option for the “Respond When” parameter',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isRespondToWebhookConnected && responseMode !== 'responseNode' && nodeVersion <= 2.1) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
new Error(`${context.getNode().name} node not correctly configured`),
|
||||
{
|
||||
description:
|
||||
'Set the “Respond When” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isRespondToWebhookConnected && nodeVersion > 2.1) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
new Error(
|
||||
'The "Respond to Webhook" node is not supported in workflows initiated by the "n8n Form Trigger"',
|
||||
),
|
||||
{
|
||||
description:
|
||||
'To configure your response, add an "n8n Form" node and set the "Page Type" to "Form Ending"',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function addFormResponseDataToReturnItem(
|
||||
returnItem: INodeExecutionData,
|
||||
formFields: FormFieldsParameter,
|
||||
bodyData: IDataObject,
|
||||
) {
|
||||
for (const [index, field] of formFields.entries()) {
|
||||
const key = `field-${index}`;
|
||||
const name = field.fieldLabel ?? field.fieldName;
|
||||
let value = bodyData[key] ?? null;
|
||||
|
||||
if (value === null) {
|
||||
returnItem.json[name] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.fieldType === 'html') {
|
||||
if (field.elementName) {
|
||||
returnItem.json[field.elementName] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.fieldType === 'number') {
|
||||
value = Number(value);
|
||||
}
|
||||
if (field.fieldType === 'text') {
|
||||
value = String(value).trim();
|
||||
}
|
||||
if (field.multiselect && typeof value === 'string') {
|
||||
value = jsonParse(value);
|
||||
}
|
||||
if (field.fieldType === 'date' && value && field.formatDate !== '') {
|
||||
value = DateTime.fromFormat(String(value), 'yyyy-mm-dd').toFormat(field.formatDate as string);
|
||||
}
|
||||
if (field.fieldType === 'file' && field.multipleFiles && !Array.isArray(value)) {
|
||||
value = [value];
|
||||
}
|
||||
|
||||
returnItem.json[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareFormReturnItem(
|
||||
context: IWebhookFunctions,
|
||||
formFields: FormFieldsParameter,
|
||||
mode: 'test' | 'production',
|
||||
useWorkflowTimezone: boolean = false,
|
||||
) {
|
||||
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
|
||||
const files = (context.getBodyData().files as IDataObject) ?? {};
|
||||
|
||||
const returnItem: INodeExecutionData = {
|
||||
json: {},
|
||||
};
|
||||
if (files && Object.keys(files).length) {
|
||||
returnItem.binary = {};
|
||||
}
|
||||
|
||||
for (const key of Object.keys(files)) {
|
||||
const processFiles: MultiPartFormData.File[] = [];
|
||||
let multiFile = false;
|
||||
const filesInput = files[key] as MultiPartFormData.File[] | MultiPartFormData.File;
|
||||
|
||||
if (Array.isArray(filesInput)) {
|
||||
bodyData[key] = filesInput.map((file) => ({
|
||||
filename: file.originalFilename,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
}));
|
||||
processFiles.push(...filesInput);
|
||||
multiFile = true;
|
||||
} else {
|
||||
bodyData[key] = {
|
||||
filename: filesInput.originalFilename,
|
||||
mimetype: filesInput.mimetype,
|
||||
size: filesInput.size,
|
||||
};
|
||||
processFiles.push(filesInput);
|
||||
}
|
||||
|
||||
const entryIndex = Number(key.replace(/field-/g, ''));
|
||||
const fieldLabel = isNaN(entryIndex) ? key : formFields[entryIndex].fieldLabel;
|
||||
|
||||
let fileCount = 0;
|
||||
for (const file of processFiles) {
|
||||
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
|
||||
|
||||
if (multiFile) {
|
||||
binaryPropertyName += `_${fileCount++}`;
|
||||
}
|
||||
|
||||
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
|
||||
file.filepath,
|
||||
file.originalFilename ?? file.newFilename,
|
||||
file.mimetype,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
|
||||
|
||||
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
|
||||
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
|
||||
|
||||
returnItem.json.formMode = mode;
|
||||
|
||||
if (
|
||||
context.getNode().type === FORM_TRIGGER_NODE_TYPE &&
|
||||
Object.keys(context.getRequestObject().query || {}).length
|
||||
) {
|
||||
returnItem.json.formQueryParameters = context.getRequestObject().query;
|
||||
}
|
||||
|
||||
return returnItem;
|
||||
}
|
||||
|
||||
export function renderForm({
|
||||
context,
|
||||
res,
|
||||
formTitle,
|
||||
formDescription,
|
||||
formFields,
|
||||
responseMode,
|
||||
mode,
|
||||
formSubmittedText,
|
||||
redirectUrl,
|
||||
appendAttribution,
|
||||
buttonLabel,
|
||||
customCss,
|
||||
}: {
|
||||
context: IWebhookFunctions;
|
||||
res: Response;
|
||||
formTitle: string;
|
||||
formDescription: string;
|
||||
formFields: FormFieldsParameter;
|
||||
responseMode: string;
|
||||
mode: 'test' | 'production';
|
||||
formSubmittedText?: string;
|
||||
redirectUrl?: string;
|
||||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
customCss?: string;
|
||||
}) {
|
||||
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/<br>/g, '\n');
|
||||
const instanceId = context.getInstanceId();
|
||||
|
||||
const useResponseData = responseMode === 'responseNode';
|
||||
|
||||
let query: IDataObject = {};
|
||||
|
||||
if (context.getNode().type === FORM_TRIGGER_NODE_TYPE) {
|
||||
query = context.getRequestObject().query as IDataObject;
|
||||
} else if (context.getNode().type === FORM_NODE_TYPE) {
|
||||
const parentNodes = context.getParentNodes(context.getNode().name);
|
||||
const trigger = parentNodes.find(
|
||||
(node) => node.type === FORM_TRIGGER_NODE_TYPE,
|
||||
) as NodeTypeAndVersion;
|
||||
try {
|
||||
const triggerQueryParameters = context.evaluateExpression(
|
||||
`{{ $('${trigger?.name}').first().json.formQueryParameters }}`,
|
||||
) as IDataObject;
|
||||
|
||||
if (triggerQueryParameters) {
|
||||
query = triggerQueryParameters;
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
formFields = prepareFormFields(context, formFields);
|
||||
|
||||
const data = prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedText,
|
||||
redirectUrl,
|
||||
formFields,
|
||||
testRun: mode === 'test',
|
||||
query,
|
||||
instanceId,
|
||||
useResponseData,
|
||||
appendAttribution,
|
||||
buttonLabel,
|
||||
customCss,
|
||||
});
|
||||
|
||||
res.render('form-trigger', data);
|
||||
}
|
||||
|
||||
export const isFormConnected = (nodes: NodeTypeAndVersion[]) => {
|
||||
return nodes.some(
|
||||
(n) =>
|
||||
n.type === FORM_NODE_TYPE || (n.type === WAIT_NODE_TYPE && n.parameters?.resume === 'form'),
|
||||
);
|
||||
};
|
||||
|
||||
export async function formWebhook(
|
||||
context: IWebhookFunctions,
|
||||
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
|
||||
) {
|
||||
const node = context.getNode();
|
||||
const options = context.getNodeParameter('options', {}) as {
|
||||
ignoreBots?: boolean;
|
||||
respondWithOptions?: {
|
||||
values: {
|
||||
respondWith: 'text' | 'redirect';
|
||||
formSubmittedText: string;
|
||||
redirectUrl: string;
|
||||
};
|
||||
};
|
||||
formSubmittedText?: string;
|
||||
useWorkflowTimezone?: boolean;
|
||||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
customCss?: string;
|
||||
};
|
||||
const res = context.getResponseObject();
|
||||
const req = context.getRequestObject();
|
||||
|
||||
try {
|
||||
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
if (node.typeVersion > 1) {
|
||||
await validateWebhookAuthentication(context, authProperty);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookAuthorizationError) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"');
|
||||
res.status(401).send();
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mode = context.getMode() === 'manual' ? 'test' : 'production';
|
||||
const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
|
||||
|
||||
const method = context.getRequestObject().method;
|
||||
|
||||
validateResponseModeConfiguration(context);
|
||||
|
||||
//Show the form on GET request
|
||||
if (method === 'GET') {
|
||||
const formTitle = context.getNodeParameter('formTitle', '') as string;
|
||||
const formDescription = sanitizeHtml(context.getNodeParameter('formDescription', '') as string);
|
||||
let responseMode = context.getNodeParameter('responseMode', '') as string;
|
||||
|
||||
let formSubmittedText;
|
||||
let redirectUrl;
|
||||
let appendAttribution = true;
|
||||
|
||||
if (options.respondWithOptions) {
|
||||
const values = (options.respondWithOptions as IDataObject).values as IDataObject;
|
||||
if (values.respondWith === 'text') {
|
||||
formSubmittedText = values.formSubmittedText as string;
|
||||
}
|
||||
if (values.respondWith === 'redirect') {
|
||||
redirectUrl = values.redirectUrl as string;
|
||||
}
|
||||
} else {
|
||||
formSubmittedText = options.formSubmittedText as string;
|
||||
}
|
||||
|
||||
if (options.appendAttribution === false) {
|
||||
appendAttribution = false;
|
||||
}
|
||||
|
||||
let buttonLabel = 'Submit';
|
||||
|
||||
if (options.buttonLabel) {
|
||||
buttonLabel = options.buttonLabel;
|
||||
}
|
||||
|
||||
const connectedNodes = context.getChildNodes(context.getNode().name, {
|
||||
includeNodeParameters: true,
|
||||
});
|
||||
const hasNextPage = isFormConnected(connectedNodes);
|
||||
|
||||
if (hasNextPage) {
|
||||
redirectUrl = undefined;
|
||||
responseMode = 'responseNode';
|
||||
}
|
||||
|
||||
renderForm({
|
||||
context,
|
||||
res,
|
||||
formTitle,
|
||||
formDescription,
|
||||
formFields,
|
||||
responseMode,
|
||||
mode,
|
||||
formSubmittedText,
|
||||
redirectUrl,
|
||||
appendAttribution,
|
||||
buttonLabel,
|
||||
customCss: options.customCss,
|
||||
});
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
let { useWorkflowTimezone } = options;
|
||||
|
||||
if (useWorkflowTimezone === undefined && node.typeVersion > 2) {
|
||||
useWorkflowTimezone = true;
|
||||
}
|
||||
|
||||
const returnItem = await prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone);
|
||||
|
||||
return {
|
||||
webhookResponse: { status: 200 },
|
||||
workflowData: [[returnItem]],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRawData(context: IWebhookFunctions, rawData: string) {
|
||||
const resolvables = getResolvables(rawData);
|
||||
let returnData: string = rawData;
|
||||
|
||||
if (returnData.startsWith('=')) {
|
||||
returnData = returnData.replace(/^=+/, '');
|
||||
} else {
|
||||
return returnData;
|
||||
}
|
||||
|
||||
if (resolvables.length) {
|
||||
for (const resolvable of resolvables) {
|
||||
const resolvedValue = context.evaluateExpression(`${resolvable}`);
|
||||
|
||||
if (typeof resolvedValue === 'object' && resolvedValue !== null) {
|
||||
returnData = returnData.replace(resolvable, JSON.stringify(resolvedValue));
|
||||
} else {
|
||||
returnData = returnData.replace(resolvable, resolvedValue as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
Reference in New Issue
Block a user