mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Matsu <matias.huhta@n8n.io> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1702 lines
56 KiB
TypeScript
1702 lines
56 KiB
TypeScript
import { Container } from '@n8n/di';
|
|
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
|
import type { Request, Response } from 'express';
|
|
import { rm } from 'fs/promises';
|
|
import isbot from 'isbot';
|
|
import jwt from 'jsonwebtoken';
|
|
import { DateTime } from 'luxon';
|
|
import { getHtmlSandboxCSP, InstanceSettings, isFormHtmlSandboxingDisabled } from 'n8n-core';
|
|
import type {
|
|
CredentialCheckStatus,
|
|
INode,
|
|
INodeExecutionData,
|
|
IUser,
|
|
MultiPartFormData,
|
|
IDataObject,
|
|
IWebhookFunctions,
|
|
FormFieldsParameter,
|
|
NodeTypeAndVersion,
|
|
CredentialCheckResult,
|
|
} from 'n8n-workflow';
|
|
import {
|
|
FORM_NODE_TYPE,
|
|
FORM_TRIGGER_NODE_TYPE,
|
|
NodeOperationError,
|
|
WAIT_NODE_TYPE,
|
|
WorkflowConfigurationError,
|
|
jsonParse,
|
|
tryToParseUrl,
|
|
BINARY_MODE_COMBINED,
|
|
tryToParseJsonToFormFields,
|
|
UnexpectedError,
|
|
buildCredentialConnectionsRequiredResponse,
|
|
} from 'n8n-workflow';
|
|
import * as a from 'node:assert';
|
|
import sanitize from 'sanitize-html';
|
|
|
|
import { getResolvables } from '../../../utils/utilities';
|
|
import { WebhookAuthorizationError } from '../../Webhook/error';
|
|
import {
|
|
generateFormPostBasicAuthToken,
|
|
isIpAllowed,
|
|
validateWebhookAuthentication,
|
|
} from '../../Webhook/utils';
|
|
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
|
|
import type { FormTriggerData, FormField } from '../interfaces';
|
|
|
|
/**
|
|
* Time-to-live for the form auth token embedded in n8nUserAuth forms.
|
|
* Long enough for users to fill out a form, short enough to limit damage
|
|
* if the token leaks (e.g. via malicious HTML in form fields).
|
|
*/
|
|
const FORM_USER_AUTH_TOKEN_TTL_SECONDS = 60 * 60;
|
|
|
|
export const getNodeReference = (nodeName: string) => `$(${JSON.stringify(nodeName)})`;
|
|
|
|
type FormUserAuthClaims = {
|
|
sub: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
nid: string;
|
|
wid: string;
|
|
/** Workflow the token was minted for. */
|
|
wfid?: string;
|
|
/** Execution the token was minted for, when one was already running. */
|
|
eid?: string;
|
|
};
|
|
|
|
/** Binding claims tying a form auth token to the run that minted it. */
|
|
export type FormUserAuthTokenBinding = {
|
|
workflowId?: string;
|
|
executionId?: string;
|
|
};
|
|
|
|
function isOptionalString(value: unknown): boolean {
|
|
return value === undefined || typeof value === 'string';
|
|
}
|
|
|
|
function isFormUserAuthClaims(value: unknown): value is FormUserAuthClaims {
|
|
if (typeof value !== 'object' || value === null) return false;
|
|
const c = value as Record<string, unknown>;
|
|
return (
|
|
typeof c.sub === 'string' &&
|
|
typeof c.email === 'string' &&
|
|
typeof c.firstName === 'string' &&
|
|
typeof c.lastName === 'string' &&
|
|
typeof c.nid === 'string' &&
|
|
typeof c.wid === 'string' &&
|
|
isOptionalString(c.wfid) &&
|
|
isOptionalString(c.eid)
|
|
);
|
|
}
|
|
|
|
function userFromFormUserAuthClaims(claims: FormUserAuthClaims): IUser {
|
|
return {
|
|
id: claims.sub,
|
|
email: claims.email,
|
|
firstName: claims.firstName,
|
|
lastName: claims.lastName,
|
|
};
|
|
}
|
|
|
|
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',
|
|
'table',
|
|
'thead',
|
|
'tbody',
|
|
'tfoot',
|
|
'td',
|
|
'tr',
|
|
'th',
|
|
'br',
|
|
],
|
|
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'],
|
|
td: ['colspan', 'rowspan', 'scope', 'headers'],
|
|
th: ['colspan', 'rowspan', 'scope', 'headers'],
|
|
},
|
|
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',
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replaces `\n` strings with actual newline characters.
|
|
* Also replaces `\\n` strings with `\n` string
|
|
* @param text - The text to replace newlines in
|
|
* @returns Updated text
|
|
*/
|
|
export const handleNewlines = (text: string) => {
|
|
return text.replace(/\\n|\\\\n/g, (match) => (match === '\\\\n' ? '\\n' : '\n'));
|
|
};
|
|
|
|
export const prepareFormFields = (fields: FormFieldsParameter) => {
|
|
return fields.map((field) => {
|
|
if (field.fieldType === 'html' && field.html) {
|
|
field.html = sanitizeHtml(field.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;
|
|
|
|
const sanitized = sanitize(css, {
|
|
allowedTags: [],
|
|
allowedAttributes: {},
|
|
});
|
|
|
|
// Restore only the entities needed for valid CSS after tag stripping.
|
|
// > → > is needed for CSS child combinator selectors (div > p).
|
|
// & → & is needed for CSS values, but NOT when followed by lt;/gt;/amp;
|
|
// to prevent cascading decode of double-encoded entities.
|
|
// < is never decoded — < is not valid in CSS and would enable tag injection.
|
|
return sanitized.replace(/>/g, '>').replace(/&(?!(?:lt|gt|amp);)/g, '&');
|
|
}
|
|
|
|
/**
|
|
* Validates that a URL uses a safe scheme.
|
|
* Returns the normalized URL if valid, or null if invalid.
|
|
*/
|
|
export function validateSafeRedirectUrl(url: string | undefined): string | null {
|
|
if (!url) return null;
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return null;
|
|
|
|
try {
|
|
return tryToParseUrl(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function createDescriptionMetadata(description: string) {
|
|
return description === ''
|
|
? 'n8n form'
|
|
: description.replace(/^\s*\n+|<\/?[^>]+(>|$)/g, '').slice(0, 150);
|
|
}
|
|
|
|
/**
|
|
* Gets the field identifier to use based on node version.
|
|
* For v2.4+, uses fieldName as the primary identifier.
|
|
* For earlier versions, falls back to fieldLabel.
|
|
*/
|
|
function getFieldIdentifier(field: FormFieldsParameter[number], nodeVersion?: number): string {
|
|
if (nodeVersion && nodeVersion >= 2.4 && field.fieldName) {
|
|
return field.fieldName;
|
|
}
|
|
|
|
return field.fieldLabel ?? field.fieldName ?? '';
|
|
}
|
|
|
|
/** Target of the "Form automated with n8n" attribution footer. */
|
|
export function getN8nWebsiteLink(instanceId?: string) {
|
|
const utm_campaign = instanceId ? `&utm_campaign=${encodeURIComponent(instanceId)}` : '';
|
|
return `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
|
|
}
|
|
|
|
export function prepareFormData({
|
|
formTitle,
|
|
formDescription,
|
|
formSubmittedHeader,
|
|
formSubmittedText,
|
|
redirectUrl,
|
|
formFields,
|
|
testRun,
|
|
query,
|
|
instanceId,
|
|
useResponseData,
|
|
appendAttribution = true,
|
|
buttonLabel,
|
|
customCss,
|
|
nodeVersion,
|
|
authToken,
|
|
shellInner,
|
|
hasAuthenticatedSubmitter,
|
|
hostNavigationPath,
|
|
}: {
|
|
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;
|
|
nodeVersion?: number;
|
|
authToken?: string;
|
|
shellInner?: boolean;
|
|
hasAuthenticatedSubmitter?: boolean;
|
|
hostNavigationPath?: string;
|
|
}) {
|
|
const n8nWebsiteLink = getN8nWebsiteLink(instanceId);
|
|
|
|
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),
|
|
authToken,
|
|
// Only set inside the hosting shell, so the plain form's render data is unchanged.
|
|
shellInner: shellInner || undefined,
|
|
// Only set for forms that can be gated, so the rest never ship the client handling.
|
|
hasAuthenticatedSubmitter: hasAuthenticatedSubmitter || undefined,
|
|
hostNavigationPath: hostNavigationPath || undefined,
|
|
};
|
|
|
|
if (redirectUrl) {
|
|
const safeUrl = validateSafeRedirectUrl(redirectUrl);
|
|
if (safeUrl) {
|
|
formData.redirectUrl = safeUrl;
|
|
}
|
|
}
|
|
|
|
for (const [index, field] of formFields.entries()) {
|
|
const { fieldType, requiredField, multiselect, placeholder, defaultValue } = field;
|
|
const queryParam = getFieldIdentifier(field, nodeVersion);
|
|
|
|
const input: FormField = {
|
|
id: `field-${index}`,
|
|
errorId: `error-field-${index}`,
|
|
label: field.fieldLabel,
|
|
inputRequired: requiredField ? 'form-required' : '',
|
|
defaultValue: query[queryParam] ?? defaultValue ?? '',
|
|
placeholder,
|
|
};
|
|
|
|
if (multiselect || (fieldType && ['radio', 'checkbox'].includes(fieldType))) {
|
|
input.isMultiSelect = true;
|
|
input.multiSelectOptions =
|
|
field.fieldOptions?.values.map((e, i) => ({
|
|
id: `option${i}_${input.id}`,
|
|
label: e.option,
|
|
})) ?? [];
|
|
|
|
if (fieldType === 'radio') {
|
|
input.radioSelect = 'radio';
|
|
} else if (field.limitSelection === 'exact') {
|
|
input.exactSelectedOptions = field.numberOfSelections;
|
|
} else if (field.limitSelection === 'range') {
|
|
input.minSelectedOptions = field.minSelections;
|
|
input.maxSelectedOptions = field.maxSelections;
|
|
}
|
|
} 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);
|
|
}
|
|
|
|
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 WorkflowConfigurationError(
|
|
context.getNode(),
|
|
new Error('Unused Respond to Webhook node found in the workflow'),
|
|
{
|
|
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,
|
|
nodeVersion?: number,
|
|
) {
|
|
for (const [index, field] of formFields.entries()) {
|
|
const key = `field-${index}`;
|
|
const name = getFieldIdentifier(field, nodeVersion);
|
|
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' || field.fieldType === 'email') {
|
|
value = String(value).trim();
|
|
}
|
|
if (
|
|
(field.multiselect || field.fieldType === 'checkbox' || field.fieldType === 'radio') &&
|
|
typeof value === 'string'
|
|
) {
|
|
value = jsonParse(value);
|
|
|
|
if (field.fieldType === 'radio' && Array.isArray(value)) {
|
|
value = value[0];
|
|
}
|
|
}
|
|
if (field.fieldType === 'date' && value && field.formatDate) {
|
|
const datetime = DateTime.fromFormat(String(value), 'yyyy-mm-dd');
|
|
value = datetime.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,
|
|
authedUser?: IUser,
|
|
) {
|
|
const req = context.getRequestObject() as MultiPartFormData.Request;
|
|
a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
|
|
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
|
|
const files = (context.getBodyData().files as IDataObject) ?? {};
|
|
const { binaryMode } = context.getWorkflowSettings();
|
|
|
|
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] =
|
|
binaryMode === BINARY_MODE_COMBINED
|
|
? []
|
|
: filesInput.map((file) => ({
|
|
filename: file.originalFilename,
|
|
mimetype: file.mimetype,
|
|
size: file.size,
|
|
}));
|
|
processFiles.push(...filesInput);
|
|
multiFile = true;
|
|
} else {
|
|
bodyData[key] =
|
|
binaryMode === BINARY_MODE_COMBINED
|
|
? {}
|
|
: {
|
|
filename: filesInput.originalFilename,
|
|
mimetype: filesInput.mimetype,
|
|
size: filesInput.size,
|
|
};
|
|
processFiles.push(filesInput);
|
|
}
|
|
|
|
const entryIndex = Number(key.replace(/field-/g, ''));
|
|
const field = isNaN(entryIndex) ? null : formFields[entryIndex];
|
|
const fieldLabel = field ? getFieldIdentifier(field, context.getNode().typeVersion) : key;
|
|
|
|
let fileCount = 0;
|
|
for (const file of processFiles) {
|
|
const binaryData = await context.nodeHelpers.copyBinaryFile(
|
|
file.filepath,
|
|
file.originalFilename ?? file.newFilename,
|
|
file.mimetype,
|
|
);
|
|
|
|
if (binaryMode === BINARY_MODE_COMBINED) {
|
|
if (Array.isArray(bodyData[key])) {
|
|
(bodyData[key] as IDataObject[]).push(binaryData);
|
|
} else {
|
|
bodyData[key] = binaryData;
|
|
}
|
|
} else {
|
|
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
|
|
|
|
if (multiFile) {
|
|
binaryPropertyName += `_${fileCount++}`;
|
|
}
|
|
|
|
returnItem.binary![binaryPropertyName] = binaryData;
|
|
}
|
|
|
|
// Delete original file to prevent tmp directory from growing too large
|
|
await rm(file.filepath, { force: true });
|
|
}
|
|
}
|
|
|
|
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, context.getNode().typeVersion);
|
|
|
|
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
|
|
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
|
|
|
|
returnItem.json.formMode = mode;
|
|
|
|
if (context.getNodeParameter('options.showHeaders', false)) {
|
|
returnItem.json.headers = context.getHeaderData();
|
|
}
|
|
|
|
if (
|
|
context.getNode().type === FORM_TRIGGER_NODE_TYPE &&
|
|
Object.keys(context.getRequestObject().query || {}).length
|
|
) {
|
|
returnItem.json.formQueryParameters = context.getRequestObject().query;
|
|
}
|
|
|
|
if (authedUser) {
|
|
returnItem.json.user = {
|
|
id: authedUser.id,
|
|
email: authedUser.email,
|
|
firstName: authedUser.firstName,
|
|
lastName: authedUser.lastName,
|
|
};
|
|
}
|
|
|
|
return returnItem;
|
|
}
|
|
|
|
export function renderForm({
|
|
context,
|
|
res,
|
|
formTitle,
|
|
formDescription,
|
|
formFields,
|
|
responseMode,
|
|
mode,
|
|
formSubmittedText,
|
|
redirectUrl,
|
|
appendAttribution,
|
|
buttonLabel,
|
|
customCss,
|
|
authToken,
|
|
shellInner,
|
|
hasAuthenticatedSubmitter,
|
|
}: {
|
|
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;
|
|
authToken?: string;
|
|
shellInner?: boolean;
|
|
hasAuthenticatedSubmitter?: boolean;
|
|
}) {
|
|
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 triggerRef = getNodeReference(trigger.name);
|
|
const triggerQueryParameters = context.evaluateExpression(
|
|
`{{ ${triggerRef}.first().json.formQueryParameters }}`,
|
|
) as IDataObject;
|
|
|
|
if (triggerQueryParameters) {
|
|
query = triggerQueryParameters;
|
|
}
|
|
} catch (error) {}
|
|
}
|
|
|
|
formFields = prepareFormFields(formFields);
|
|
|
|
// A follow-up page the shell navigated its frame to is the shell's inner render
|
|
// just like the flagged page-1 request — it must render the readiness listener
|
|
// and leave the submit button's enabled state to the shell's connect panel.
|
|
const inShell = isHostedInFormShell(context.getRequestObject(), shellInner);
|
|
|
|
const data = prepareFormData({
|
|
formTitle,
|
|
formDescription,
|
|
formSubmittedText,
|
|
redirectUrl,
|
|
formFields,
|
|
testRun: mode === 'test',
|
|
query,
|
|
instanceId,
|
|
useResponseData,
|
|
appendAttribution,
|
|
buttonLabel,
|
|
customCss,
|
|
nodeVersion: context.getNode().typeVersion,
|
|
authToken,
|
|
shellInner: inShell,
|
|
hasAuthenticatedSubmitter,
|
|
hostNavigationPath: getHostNavigationPath(context, inShell),
|
|
});
|
|
|
|
if (!isFormHtmlSandboxingDisabled()) {
|
|
res.setHeader('Content-Security-Policy', getHtmlSandboxCSP());
|
|
}
|
|
res.render('form-trigger', data);
|
|
}
|
|
|
|
/**
|
|
* Build the absolute URL the user originally requested, honouring
|
|
* `x-forwarded-*` headers so it survives behind a reverse proxy. The full URL
|
|
* is required so the post-signin redirect uses `window.location.href = redirect`
|
|
* in SigninView.vue (the `router.push` branch only handles SPA routes, and the
|
|
* public form is served outside the Vue app).
|
|
*
|
|
* Security note: `x-forwarded-host` / `x-forwarded-proto` are attacker-controlled
|
|
* unless the deployer puts a trusted proxy in front (recommended). The redirect
|
|
* value flows through SigninView.vue's `isRedirectSafe()`, which enforces a
|
|
* same-origin check (`url.origin === window.location.origin`). So a spoofed
|
|
* Host header can at worst break the post-signin redirect for that user — it
|
|
* cannot redirect to an attacker-controlled origin.
|
|
*/
|
|
function buildAbsoluteFormUrl(req: Request): string {
|
|
const headerValue = (name: string) => {
|
|
const raw = req.headers[name];
|
|
return typeof raw === 'string' ? raw.trim() : undefined;
|
|
};
|
|
const protocol = headerValue('x-forwarded-proto') ?? req.protocol ?? 'http';
|
|
const host = headerValue('x-forwarded-host') ?? req.headers.host ?? '';
|
|
return `${protocol}://${host}${req.originalUrl}`;
|
|
}
|
|
|
|
/**
|
|
* True when this render is the hosting shell's frame — either the shell's own
|
|
* inner render (flagged in the URL) or a follow-up page the shell navigated its
|
|
* frame to. Only then may the form hand its page navigations to the host.
|
|
*
|
|
* A follow-up page is recognised by the `n8nShellHosted` flag the shell appends
|
|
* to every URL it moves its frame to. Being told outright, rather than inferred
|
|
* from `Sec-Fetch-*`, keeps delegation working where those headers never arrive
|
|
* (a proxy that strips them) and keeps a same-origin frame that is *not* the
|
|
* shell from being treated as one.
|
|
*
|
|
* Where those headers do arrive they still have to agree — a same-origin frame —
|
|
* so a top-level document, or a form embedded on someone else's site, keeps
|
|
* navigating itself even with the flag in its URL.
|
|
*
|
|
* Nothing rests on the flag being unguessable: a frame that sets it on itself
|
|
* gains only the ability to ask its own parent to move it, and the shell accepts
|
|
* that ask for this form's own pages alone.
|
|
*/
|
|
function isHostedInFormShell(req: Request, shellInner?: boolean): boolean {
|
|
if (shellInner) return true;
|
|
const secFetchDest = req.headers?.['sec-fetch-dest'];
|
|
const secFetchSite = req.headers?.['sec-fetch-site'];
|
|
return (
|
|
req.query?.n8nShellHosted === '1' &&
|
|
(secFetchDest === undefined || secFetchDest === 'iframe') &&
|
|
(secFetchSite === undefined || secFetchSite === 'same-origin')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The path prefix this render may ask the hosting shell to navigate to, or
|
|
* `undefined` when the render isn't in the shell's frame and so keeps navigating
|
|
* itself.
|
|
*/
|
|
export function getHostNavigationPath(
|
|
context: IWebhookFunctions,
|
|
shellInner?: boolean,
|
|
): string | undefined {
|
|
if (!isHostedInFormShell(context.getRequestObject(), shellInner)) return undefined;
|
|
return getFormWaitingBasePath(context) ?? undefined;
|
|
}
|
|
|
|
export const isFormConnected = (nodes: NodeTypeAndVersion[]) => {
|
|
return nodes.some(
|
|
(n) =>
|
|
n.type === FORM_NODE_TYPE || (n.type === WAIT_NODE_TYPE && n.parameters?.resume === 'form'),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Submit-time credential readiness gate, shared by the trigger's POST and the
|
|
* Form node's POST. Answers 428 with the missing-credential list when the
|
|
* submitter's required accounts aren't all connected, 503 when the check itself
|
|
* fails. Returns true when a response was sent and the submission must not
|
|
* proceed. A no-op (`undefined` readiness) outside the dynamic-credentials flow.
|
|
*/
|
|
export async function respondIfCredentialsNotReady(
|
|
context: IWebhookFunctions,
|
|
res: Response,
|
|
): Promise<boolean> {
|
|
let readiness: CredentialCheckResult | undefined;
|
|
try {
|
|
readiness = await context.checkTriggerCredentialStatus();
|
|
} catch (error) {
|
|
// Fail closed. Throwing here is swallowed by the webhook layer, which then
|
|
// enqueues the execution anyway — exactly the doomed run we're preventing.
|
|
context.logger.error('Form submit credential readiness check failed', { error });
|
|
res.status(503).json({ status: 'credential_readiness_check_failed' });
|
|
return true;
|
|
}
|
|
|
|
if (readiness && !readiness.readyToExecute) {
|
|
// 428, matching the webhook trigger's gate (webhook-helpers.ts) so all
|
|
// trigger paths answer an unconnected credential the same way.
|
|
res.status(428).json(buildCredentialConnectionsRequiredResponse(readiness));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Generate a form auth token for n8nUserAuth. The token embeds the user info
|
|
* in a signed JWT so the POST handler can authenticate the submission without
|
|
* relying on the `n8n-auth` cookie (cookies aren't sent on fetch requests from
|
|
* the sandboxed form page because the document has a null origin and the
|
|
* cookie is `SameSite=Lax`).
|
|
*
|
|
* The `nid` and `wid` claims bind the token to a specific node + webhook,
|
|
* preventing replay across forms. `wfid`/`eid` additionally bind it to the
|
|
* workflow and — for tokens minted while a run is in progress — that run, which
|
|
* is what lets the same token be accepted as the form page auth cookie. Signed
|
|
* with HS256 using the instance's hmac signature secret.
|
|
*/
|
|
export function generateFormUserAuthToken(
|
|
node: INode,
|
|
user: IUser,
|
|
binding: FormUserAuthTokenBinding,
|
|
): string {
|
|
const secret = Container.get(InstanceSettings).hmacSignatureSecret;
|
|
const payload: FormUserAuthClaims = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
nid: node.id,
|
|
wid: node.webhookId ?? '',
|
|
...(binding.workflowId ? { wfid: binding.workflowId } : {}),
|
|
...(binding.executionId ? { eid: binding.executionId } : {}),
|
|
};
|
|
return jwt.sign(payload, secret, {
|
|
algorithm: 'HS256',
|
|
expiresIn: FORM_USER_AUTH_TOKEN_TTL_SECONDS,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verify a form auth token issued by `generateFormUserAuthToken` and presented
|
|
* in the `x-auth-token` header. Returns the encoded user on success or `null` on
|
|
* any failure (bad format, expired, wrong signature, wrong node, wrong
|
|
* execution). The caller decides how to surface the failure.
|
|
*
|
|
* A token carrying an `eid` is only accepted for that execution. Tokens minted
|
|
* before a run existed carry none and stay valid for the node they name.
|
|
*/
|
|
export function verifyFormUserAuthToken(
|
|
token: string,
|
|
node: INode,
|
|
executionId?: string,
|
|
): IUser | null {
|
|
const claims = decodeFormUserAuthClaims(token);
|
|
if (!claims) return null;
|
|
if (claims.nid !== node.id) return null;
|
|
if (claims.wid !== (node.webhookId ?? '')) return null;
|
|
if (claims.eid !== undefined && claims.eid !== executionId) return null;
|
|
return userFromFormUserAuthClaims(claims);
|
|
}
|
|
|
|
function decodeFormUserAuthClaims(token: string): FormUserAuthClaims | null {
|
|
const secret = Container.get(InstanceSettings).hmacSignatureSecret;
|
|
let claims: unknown;
|
|
try {
|
|
claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
|
|
} catch {
|
|
return null;
|
|
}
|
|
return isFormUserAuthClaims(claims) ? claims : null;
|
|
}
|
|
|
|
/**
|
|
* Verify a form auth token presented as the form page auth cookie. Unlike the
|
|
* `x-auth-token` variant this is not bound to a single node: the cookie rides
|
|
* along a navigation to whichever node serves the next page of the same form,
|
|
* so it is bound to the workflow (`wfid`) and, once a run exists, to that run
|
|
* (`eid`). Tokens predating those claims are not accepted here.
|
|
*/
|
|
function verifyFormPageAuthCookie(
|
|
token: string,
|
|
workflowId: string | undefined,
|
|
executionId: string | undefined,
|
|
): IUser | null {
|
|
const claims = decodeFormUserAuthClaims(token);
|
|
if (!claims) return null;
|
|
if (!claims.wfid || !workflowId || claims.wfid !== workflowId) return null;
|
|
if (claims.eid !== undefined && claims.eid !== executionId) return null;
|
|
return userFromFormUserAuthClaims(claims);
|
|
}
|
|
|
|
function trimTrailingSlash(url: string): string {
|
|
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
}
|
|
|
|
// Carries the OAuth2 access token across the single same-site redirect from the
|
|
// provider callback to the clean form URL, so `code`/`state` never reach the
|
|
// sandboxed form page. The token is otherwise already embedded in the form HTML
|
|
// (the page sends it back as `x-auth-token` on POST), so this is not a new exposure.
|
|
const FORM_OAUTH_COOKIE_NAME = 'n8n-form-oauth';
|
|
|
|
/**
|
|
* Derive `secure` from the request scheme (honouring x-forwarded-proto, as
|
|
* buildAbsoluteFormUrl does) rather than config, so form cookies are actually
|
|
* sent back over http in dev while staying Secure over https.
|
|
*/
|
|
function isSecureRequest(req: Request): boolean {
|
|
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
const proto = (typeof forwardedProto === 'string' ? forwardedProto.trim() : '') || req.protocol;
|
|
return proto === 'https';
|
|
}
|
|
|
|
function formOAuthCookieOptions(req: Request, resourceUrl: string) {
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: 'lax' as const, // must be Lax: sent on our own top-level 302 → GET
|
|
secure: isSecureRequest(req),
|
|
path: new URL(resourceUrl).pathname, // scope the bearer token to this form
|
|
};
|
|
}
|
|
|
|
function setFormOAuthToken(res: Response, req: Request, resourceUrl: string, token: string): void {
|
|
res.cookie(FORM_OAUTH_COOKIE_NAME, token, {
|
|
...formOAuthCookieOptions(req, resourceUrl),
|
|
maxAge: 60_000, // one redirect hop; short by design
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decode a cookie value, or `null` when it isn't valid percent-encoding. A value
|
|
* we can't read is treated as no cookie at all — the caller's other checks still
|
|
* get their turn — rather than throwing out of the request.
|
|
*/
|
|
function decodeCookieValue(value: string): string | null {
|
|
try {
|
|
return decodeURIComponent(value.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readFormOAuthToken(req: Request): string | null {
|
|
const match = (req.headers.cookie ?? '').match(/(?:^|;\s*)n8n-form-oauth=([^;]+)/);
|
|
return match ? decodeCookieValue(match[1]) : null;
|
|
}
|
|
|
|
function clearFormOAuthToken(res: Response, req: Request, resourceUrl: string): void {
|
|
res.clearCookie(FORM_OAUTH_COOKIE_NAME, formOAuthCookieOptions(req, resourceUrl));
|
|
}
|
|
|
|
// Carries the submitter's identity to the follow-up pages of a multi-page form.
|
|
// Those pages are reached by a navigation, which can present neither the
|
|
// `x-auth-token` header nor — from a sandboxed, opaque-origin form document —
|
|
// the `n8n-auth` session cookie.
|
|
//
|
|
// The name carries the token's binding, so forms open in other tabs don't
|
|
// overwrite each other's cookie: pages of a run use the run's id, and the
|
|
// trigger — rendered before any run exists — uses the workflow's. Duplicated in
|
|
// `packages/cli/src/constants.ts` (prefix only); keep both sides in step.
|
|
const FORM_AUTH_COOKIE_PREFIX = 'n8n-form-auth';
|
|
|
|
function getFormAuthCookieName(binding: FormUserAuthTokenBinding): string {
|
|
return binding.executionId
|
|
? `${FORM_AUTH_COOKIE_PREFIX}-ex-${binding.executionId}`
|
|
: `${FORM_AUTH_COOKIE_PREFIX}-wf-${binding.workflowId ?? ''}`;
|
|
}
|
|
|
|
/**
|
|
* Value of the request's cookie with exactly this name, or `null`. Parses the
|
|
* raw header (the webhook path may bypass cookie-parser) by splitting rather
|
|
* than a regex, since the name embeds a workflow or execution id.
|
|
*/
|
|
function readCookieValue(req: Request, name: string): string | null {
|
|
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
const separator = part.indexOf('=');
|
|
if (separator === -1 || part.slice(0, separator).trim() !== name) continue;
|
|
return decodeCookieValue(part.slice(separator + 1));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Path the form-waiting endpoint is served under, or `null` when it can't be
|
|
* derived. It is configurable and the instance may sit behind a path prefix, so
|
|
* derive it rather than assume it: `$execution.resumeFormUrl` is
|
|
* `<formWaitingBaseUrl>/<executionId>`, and dropping the last segment leaves the
|
|
* base path.
|
|
*/
|
|
function getFormWaitingBasePath(context: IWebhookFunctions): string | null {
|
|
try {
|
|
const resumeFormUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string;
|
|
const { pathname } = new URL(resumeFormUrl);
|
|
return pathname.slice(0, pathname.lastIndexOf('/')) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hand the follow-up pages of a multi-page form their own auth token, scoped to
|
|
* the form-waiting path. Re-set on every page render, so a long form's later
|
|
* pages get a token that is still within its TTL.
|
|
*/
|
|
export function setFormAuthCookie(
|
|
context: IWebhookFunctions,
|
|
token: string,
|
|
binding: FormUserAuthTokenBinding,
|
|
): void {
|
|
const req = context.getRequestObject();
|
|
context.getResponseObject().cookie(getFormAuthCookieName(binding), token, {
|
|
httpOnly: true,
|
|
// Lax, and only ever sent on a navigation the shell (or the top-level page)
|
|
// initiates from the real origin.
|
|
sameSite: 'lax',
|
|
secure: isSecureRequest(req),
|
|
// Nothing narrower is derivable when the base path is unknown; the cookie is
|
|
// httpOnly and expires with the token either way.
|
|
path: getFormWaitingBasePath(context) ?? '/',
|
|
maxAge: FORM_USER_AUTH_TOKEN_TTL_SECONDS * 1000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve the submitter from the form page auth cookie, or `null` when it is
|
|
* absent or doesn't verify for the workflow/execution being served.
|
|
*/
|
|
function readFormAuthCookieUser(context: IWebhookFunctions): IUser | null {
|
|
const req = context.getRequestObject();
|
|
const workflowId = context.getWorkflow().id;
|
|
const executionId = context.getExecutionId();
|
|
if (!workflowId) return null;
|
|
// The run's own cookie first; the trigger's workflow-scoped one covers the
|
|
// first hop into the run, made before the run existed.
|
|
const candidateNames = [
|
|
getFormAuthCookieName({ workflowId, executionId }),
|
|
getFormAuthCookieName({ workflowId }),
|
|
];
|
|
for (const name of candidateNames) {
|
|
const token = readCookieValue(req, name);
|
|
if (!token) continue;
|
|
const user = verifyFormPageAuthCookie(token, workflowId, executionId);
|
|
if (user) return user;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* True when the request also carries a session that belongs to someone other
|
|
* than the form cookie's user — a sign-out and sign-in as a different person in
|
|
* the same browser. The live session wins: the form cookie is dropped and the
|
|
* request re-authenticates through the normal path. An unusable session cookie
|
|
* names nobody, so it leaves the form cookie alone.
|
|
*/
|
|
async function isSessionForDifferentUser(
|
|
context: IWebhookFunctions,
|
|
formCookieUser: IUser,
|
|
): Promise<boolean> {
|
|
const req = context.getRequestObject();
|
|
const match = (req.headers.cookie ?? '').match(/(?:^|;\s*)n8n-auth=([^;]+)/);
|
|
if (!match) return false;
|
|
try {
|
|
const sessionUser = await context.validateCookieAuth(match[1].trim());
|
|
return sessionUser.id !== formCookieUser.id;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticate an `n8nUserAuth` request via:
|
|
* 1. the `n8n-auth` cookie (sent on top-level GET when the user is logged in), or
|
|
* 2. the `x-auth-token` form auth token (used on POST and multi-step page
|
|
* navigations from the sandboxed form page that can't send cookies).
|
|
*
|
|
* On success returns the user. On failure sends the appropriate response
|
|
* (302 to `/signin` on GET, 401 on POST) and returns `null` — the caller
|
|
* must abort with `noWebhookResponse`.
|
|
*/
|
|
async function authenticateFormUserOrRespond(
|
|
context: IWebhookFunctions,
|
|
runOAuth2Flow: boolean = false,
|
|
): Promise<{ user: IUser; token: string | null } | null> {
|
|
const req = context.getRequestObject();
|
|
|
|
if (runOAuth2Flow) {
|
|
const res = context.getResponseObject();
|
|
const url = context.getWebhookResourceUrl('default');
|
|
if (!url) {
|
|
throw new UnexpectedError('Webhook URL not found for the node');
|
|
}
|
|
const resourceUrl = trimTrailingSlash(url);
|
|
if (req.method === 'GET') {
|
|
const { code, state } = req.query;
|
|
|
|
if (typeof req.query.error === 'string') {
|
|
// The provider returned an error (e.g. the user denied consent). Restarting the
|
|
// flow here would loop straight back to the same denial, so stop and report.
|
|
context.logger.warn('Form OAuth2 authorization was denied or failed', {
|
|
error: req.query.error,
|
|
error_description: req.query.error_description,
|
|
});
|
|
res.status(403).send('Access denied');
|
|
res.end();
|
|
return null;
|
|
}
|
|
|
|
// handle OAuth2 callback from the provider (code + state query params)
|
|
if (typeof code === 'string' && typeof state === 'string') {
|
|
try {
|
|
const authorizationResult = await context.completeN8nOAuth2Flow(code, state);
|
|
if (authorizationResult.valid) {
|
|
// TODO: exchange the OAuth2 token to a specficly scoped token.
|
|
// Don't render the form here: the callback URL still carries `code`/`state`,
|
|
// which must never reach the sandboxed form page. Stash the token in a
|
|
// one-hop cookie and redirect to the clean resource URL — the follow-up GET
|
|
// (below) picks up the cookie and renders the form.
|
|
setFormOAuthToken(res, req, resourceUrl, authorizationResult.token);
|
|
// Re-append the original query params (dropped by the first provider
|
|
// redirect, stashed against this flow's `state` on the fresh GET) so the
|
|
// follow-up GET restores field prefill and `formQueryParameters`.
|
|
const preservedQuery = authorizationResult.metadata?.query;
|
|
res.writeHead(302, {
|
|
Location: preservedQuery ? `${resourceUrl}?${preservedQuery}` : resourceUrl,
|
|
});
|
|
res.end();
|
|
return null;
|
|
}
|
|
// We fall through to the redirect below to restart the OAuth2 flow if the callback is invalid.
|
|
context.logger.warn('Form OAuth2 flow failed, restarting', {
|
|
reason: authorizationResult.reason,
|
|
});
|
|
} catch (error) {
|
|
// Ignore errors and fall through to the redirect below
|
|
context.logger.warn('Form OAuth2 flow failed, restarting', {
|
|
error,
|
|
});
|
|
}
|
|
} else {
|
|
// Not a provider callback. If we just completed the flow, the token rides in a
|
|
// one-hop cookie set on the redirect above. Consume it once and render.
|
|
const cookieToken = readFormOAuthToken(req);
|
|
if (cookieToken) {
|
|
clearFormOAuthToken(res, req, resourceUrl);
|
|
const validation = await context.validateN8nOAuth2Token(cookieToken, resourceUrl);
|
|
if (validation.valid) {
|
|
return { user: validation.user, token: cookieToken };
|
|
}
|
|
// Stale/invalid cookie — fall through to restart the OAuth2 flow.
|
|
}
|
|
}
|
|
|
|
// Stash the original query params against this flow's OAuth `state` so we can
|
|
// restore them after the bounce. Only on a genuine fresh GET — a callback
|
|
// fall-through (invalid completion) carries `code`/`state`, which we must not
|
|
// preserve as the form's query, and whose original query was consumed with the
|
|
// now-invalid state, so a restart begins clean.
|
|
const isCallback = typeof code === 'string' && typeof state === 'string';
|
|
const originalQuery = isCallback ? '' : req.originalUrl.split('?').slice(1).join('?');
|
|
|
|
try {
|
|
// start authentication flow by redirecting to the OAuth2 provider's authorization URL
|
|
const authorizationUrl = await context.beginN8nOAuth2Flow(
|
|
resourceUrl,
|
|
originalQuery ? { query: originalQuery } : undefined,
|
|
);
|
|
res.writeHead(302, {
|
|
Location: authorizationUrl,
|
|
});
|
|
res.end();
|
|
} catch (error) {
|
|
// Can't build the authorization URL — nothing to redirect to, so abort.
|
|
context.logger.warn('Form OAuth2 flow failed', {
|
|
error,
|
|
});
|
|
throw new UnexpectedError('Form OAuth2 flow failed');
|
|
}
|
|
return null;
|
|
} else {
|
|
// For POST requests, the OAuth2 flow is not applicable. We fall through to the cookie and token checks below.
|
|
const formToken = req.headers['x-auth-token'];
|
|
if (typeof formToken === 'string' && formToken) {
|
|
const validation = await context.validateN8nOAuth2Token(formToken, resourceUrl);
|
|
if (validation.valid) {
|
|
await context.establishTriggerIdentity(formToken, resourceUrl); // seeds the run
|
|
return {
|
|
user: validation.user,
|
|
token: null,
|
|
};
|
|
}
|
|
}
|
|
res.status(401).send();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Parse the raw Cookie header rather than `req.cookies` because the webhook
|
|
// path may bypass cookie-parser middleware in some deployments.
|
|
const cookieMatch = (req.headers.cookie ?? '').match(/(?:^|;\s*)n8n-auth=([^;]+)/);
|
|
if (cookieMatch) {
|
|
try {
|
|
return {
|
|
user: await context.validateCookieAuth(cookieMatch[1].trim()),
|
|
token: null,
|
|
};
|
|
} catch {}
|
|
}
|
|
|
|
const formToken = req.headers['x-auth-token'];
|
|
if (typeof formToken === 'string' && formToken) {
|
|
const user = verifyFormUserAuthToken(formToken, context.getNode(), context.getExecutionId());
|
|
if (user) return { user, token: null };
|
|
}
|
|
|
|
const res = context.getResponseObject();
|
|
if (req.method === 'GET') {
|
|
res.writeHead(302, {
|
|
Location: `/signin?redirect=${encodeURIComponent(buildAbsoluteFormUrl(req))}`,
|
|
});
|
|
res.end();
|
|
} else {
|
|
res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"');
|
|
res.status(401).send();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Multi-step Form/Wait nodes inherit `authentication` from the upstream
|
|
* Form Trigger. This wrapper short-circuits when n8nUserAuth isn't in use.
|
|
*
|
|
* A page is reached by navigating to it, which cannot carry an `x-auth-token`
|
|
* header, so a GET is authenticated from the form page auth cookie first. Every
|
|
* other request — and any GET whose cookie is missing, doesn't verify, or names
|
|
* someone other than the session on the same request — falls through to the
|
|
* session cookie / `x-auth-token` checks, unchanged.
|
|
*
|
|
* Form pages never take the OAuth2 branch: the OAuth2 token's audience is the
|
|
* trigger's URL, which a page's own resource URL is not.
|
|
*
|
|
* Returns `{ authedUser }` on success, `{ responded: true }` after sending a
|
|
* 302/401 on failure, or `{}` if the trigger doesn't require auth.
|
|
*/
|
|
export async function validateFormPageAuth(
|
|
context: IWebhookFunctions,
|
|
triggerAuthentication: string,
|
|
): Promise<{ authedUser?: IUser; responded?: boolean }> {
|
|
if (triggerAuthentication !== 'n8nUserAuth') return {};
|
|
|
|
if (context.getRequestObject().method === 'GET') {
|
|
const cookieUser = readFormAuthCookieUser(context);
|
|
if (cookieUser && !(await isSessionForDifferentUser(context, cookieUser))) {
|
|
return { authedUser: cookieUser };
|
|
}
|
|
}
|
|
|
|
const { user } = (await authenticateFormUserOrRespond(context, false)) ?? {};
|
|
return user ? { authedUser: user } : { responded: true };
|
|
}
|
|
|
|
/**
|
|
* Render the trusted hosting shell: an n8n-controlled page on the real origin
|
|
* that shows the author's form inside a sandboxed (null-origin) iframe with a
|
|
* credential-connect panel beside it. The form's submit stays disabled while any
|
|
* required credential is missing; enforcement is server-side on POST regardless.
|
|
*
|
|
* The shell holds the connect panel — with the real per-credential authorize
|
|
* links — OUTSIDE the author-scriptable iframe DOM (the security win over
|
|
* connecting inside the form). It carries a live session, so it refuses framing.
|
|
*/
|
|
function renderFormShell({
|
|
res,
|
|
req,
|
|
formTitle,
|
|
resourceUrl,
|
|
credentials,
|
|
submitterEmail,
|
|
formWaitingPath,
|
|
}: {
|
|
res: Response;
|
|
req: Request;
|
|
formTitle: string;
|
|
resourceUrl: string;
|
|
credentials: CredentialCheckStatus[];
|
|
submitterEmail?: string;
|
|
formWaitingPath?: string;
|
|
}) {
|
|
// The iframe loads the same form via a real URL (so the form's relative POST
|
|
// works exactly as it does top-level today) flagged as the inner render.
|
|
const inner = new URL(buildAbsoluteFormUrl(req));
|
|
inner.searchParams.set('n8nShellInner', '1');
|
|
|
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
|
res.render('form-shell', {
|
|
formTitle,
|
|
iframeSrc: `${inner.pathname}${inner.search}`,
|
|
resourceUrl,
|
|
// The only paths the shell will move its frame to on the form's request.
|
|
formWaitingPath,
|
|
...buildFormShellViewModel(credentials, submitterEmail),
|
|
});
|
|
}
|
|
|
|
export type FormShellCredentialRow = {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
status: CredentialCheckStatus['status'];
|
|
connected: boolean;
|
|
/** Letter tile, used whenever the provider icon doesn't resolve. */
|
|
initial: string;
|
|
iconUrl?: string;
|
|
authorizationUrl?: string;
|
|
revokeUrl?: string;
|
|
resolverId?: string;
|
|
account?: string;
|
|
usedBy?: string;
|
|
};
|
|
|
|
export type FormShellViewModel = {
|
|
credentials: FormShellCredentialRow[];
|
|
total: number;
|
|
connectedCount: number;
|
|
useDialog: boolean;
|
|
allConnected: boolean;
|
|
summaryText: string;
|
|
footerText: string;
|
|
submitterEmail?: string;
|
|
};
|
|
|
|
/** Submitter-facing copy talks about accounts, never credentials — and never `account(s)`. */
|
|
const accountsLabel = (count: number) => (count === 1 ? 'account' : 'accounts');
|
|
|
|
/**
|
|
* The one-line state of the connect panel for two or more accounts. Mirrored by
|
|
* the shell's client-side `summaryText()`, which re-renders it in place as rows
|
|
* connect (a reload would bounce the submitter back through consent).
|
|
*/
|
|
export function formShellSummaryText(total: number, connectedCount: number): string {
|
|
const remaining = total - connectedCount;
|
|
if (remaining <= 0) return `All ${total} accounts connected · ready to submit`;
|
|
if (connectedCount === 0) return `${total} accounts needed to submit this form`;
|
|
return `${remaining} more ${accountsLabel(remaining)} needed to submit this form`;
|
|
}
|
|
|
|
/**
|
|
* View model for `form-shell.handlebars`. Two shapes: a single required account
|
|
* gets its own row with a Connect button that opens the OAuth popup directly;
|
|
* two or more collapse behind a summary line plus the "Connect your accounts"
|
|
* dialog.
|
|
*/
|
|
export function buildFormShellViewModel(
|
|
credentials: CredentialCheckStatus[],
|
|
submitterEmail?: string,
|
|
): FormShellViewModel {
|
|
const initialOf = (name: string) => (name.trim().charAt(0) || '?').toUpperCase();
|
|
const rows: FormShellCredentialRow[] = credentials.map((c) => ({
|
|
id: c.credentialId,
|
|
name: c.credentialName,
|
|
type: c.credentialType,
|
|
status: c.status,
|
|
connected: c.status === 'configured',
|
|
initial: initialOf(c.credentialName),
|
|
iconUrl: c.iconUrl,
|
|
authorizationUrl: c.authorizationUrl,
|
|
revokeUrl: c.revokeUrl,
|
|
resolverId: c.resolverId,
|
|
// The connected identity shown as "Connected as …". For the system (n8n)
|
|
// resolver this is the submitter themselves; surfacing the exact OAuth
|
|
// provider account (when it differs) is a backend-enrichment follow-up.
|
|
account: c.status === 'configured' ? submitterEmail : undefined,
|
|
// `usedBy` (owning node) comes from the backend-enrichment follow-up.
|
|
usedBy: undefined,
|
|
}));
|
|
|
|
const total = rows.length;
|
|
const connectedCount = rows.filter((r) => r.connected).length;
|
|
|
|
return {
|
|
credentials: rows,
|
|
total,
|
|
connectedCount,
|
|
// Design: one account connects directly from its row; two or more collapse.
|
|
useDialog: total >= 2,
|
|
allConnected: total > 0 && connectedCount === total,
|
|
summaryText: formShellSummaryText(total, connectedCount),
|
|
footerText: `${connectedCount} of ${total} ${accountsLabel(total)} connected`,
|
|
submitterEmail,
|
|
};
|
|
}
|
|
|
|
export async function formWebhook(
|
|
context: IWebhookFunctions,
|
|
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
|
|
) {
|
|
const node = context.getNode();
|
|
const options = context.getNodeParameter('options', {}) as {
|
|
ignoreBots?: boolean;
|
|
ipWhitelist?: string;
|
|
respondWithOptions?: {
|
|
values: {
|
|
respondWith: 'text' | 'redirect';
|
|
formSubmittedText: string;
|
|
redirectUrl: string;
|
|
};
|
|
};
|
|
formSubmittedText?: string;
|
|
useWorkflowTimezone?: boolean;
|
|
appendAttribution?: boolean;
|
|
buttonLabel?: string;
|
|
customCss?: string;
|
|
includeUserInOutput?: boolean;
|
|
};
|
|
const res = context.getResponseObject();
|
|
const req = context.getRequestObject();
|
|
|
|
// Check IP allowlist first (before bot detection and authentication)
|
|
if (!isIpAllowed(options.ipWhitelist, req.ips, req.ip)) {
|
|
res.writeHead(403);
|
|
res.end('IP is not allowed to access this form!');
|
|
return { noWebhookResponse: true };
|
|
}
|
|
|
|
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
|
|
res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"');
|
|
res.status(401).send();
|
|
return { noWebhookResponse: true };
|
|
}
|
|
|
|
const authentication = context.getNodeParameter(authProperty, 'none') as string;
|
|
let authedUser: IUser | undefined;
|
|
let oAuth2Token: string | undefined;
|
|
if (node.typeVersion > 1) {
|
|
if (authentication === 'n8nUserAuth') {
|
|
const { user, token } = (await authenticateFormUserOrRespond(context, true)) ?? {};
|
|
if (!user) return { noWebhookResponse: true };
|
|
authedUser = user;
|
|
oAuth2Token = token ?? undefined;
|
|
} else {
|
|
try {
|
|
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;
|
|
|
|
for (const field of formFields) {
|
|
if (field.fieldType === 'html') {
|
|
let html = field.html ?? '';
|
|
for (const resolvable of getResolvables(html)) {
|
|
html = html.replace(resolvable, context.evaluateExpression(resolvable) as string);
|
|
}
|
|
field.html = html;
|
|
}
|
|
}
|
|
|
|
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 = handleNewlines(
|
|
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';
|
|
}
|
|
|
|
// A multi-page form hops to `/form-waiting/<execution>` by navigating, which
|
|
// carries neither the `x-auth-token` header nor — from the hosting shell's
|
|
// opaque-origin frame — the `n8n-auth` session cookie. Give the follow-up pages
|
|
// their own path-scoped token so they can authenticate the same submitter.
|
|
if (authentication === 'n8nUserAuth' && authedUser && hasNextPage) {
|
|
const binding = { workflowId: context.getWorkflow().id };
|
|
setFormAuthCookie(context, generateFormUserAuthToken(node, authedUser, binding), binding);
|
|
}
|
|
|
|
// The inner render is the form loaded inside the hosting-shell iframe. Honor the
|
|
// flag only for iframe navigations (per Sec-Fetch-Dest, absent on older browsers)
|
|
// so a hand-typed URL can't skip the connect UI; the POST gate enforces regardless.
|
|
const secFetchDest = req.headers?.['sec-fetch-dest'];
|
|
const shellInner =
|
|
req.query.n8nShellInner === '1' && (secFetchDest === undefined || secFetchDest === 'iframe');
|
|
|
|
// Connect-before-submit: when the workflow needs the submitter's own
|
|
// (private) credentials, establish their identity from the OAuth2 token and
|
|
// check readiness server-side. If anything is still missing — and we're not
|
|
// already rendering the inner iframe — wrap the form in the hosting shell
|
|
// (form + connect panel, submit disabled). No-op unless OAuth2 form auth and
|
|
// the dynamic-credentials module are both active.
|
|
if (authentication === 'n8nUserAuth' && authedUser && oAuth2Token && !shellInner) {
|
|
// Must name the endpoint actually being served (test vs production) — the
|
|
// OAuth2 token is bound to that resource, same as in the auth path above.
|
|
const resourceUrl = trimTrailingSlash(context.getWebhookResourceUrl('default') ?? '');
|
|
if (resourceUrl) {
|
|
await context.establishTriggerIdentity(oAuth2Token, resourceUrl);
|
|
const credentialStatus = await context.checkTriggerCredentialStatus();
|
|
// Gate on "needs end-user accounts", NOT on readiness: a fully connected
|
|
// submitter must keep the panel — which accounts the form uses, which
|
|
// identity, and Disconnect — otherwise it would vanish on the reload right
|
|
// after connecting. Forms with no end-user accounts fall through to the
|
|
// plain render below, unchanged.
|
|
if (credentialStatus?.credentials.length) {
|
|
// Hand the OAuth2 token to the same-site iframe GET via the one-hop
|
|
// cookie the OAuth2 flow already uses, so the inner form authenticates
|
|
// without re-running the provider redirect inside the frame.
|
|
setFormOAuthToken(res, req, resourceUrl, oAuth2Token);
|
|
// Pass ALL required credentials (connected + missing) so the card shows
|
|
// each one's state and the "{n} of {m} connected" progress.
|
|
renderFormShell({
|
|
res,
|
|
req,
|
|
formTitle,
|
|
resourceUrl,
|
|
credentials: credentialStatus.credentials,
|
|
submitterEmail: authedUser?.email,
|
|
formWaitingPath: getFormWaitingBasePath(context) ?? undefined,
|
|
});
|
|
return { noWebhookResponse: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
let authToken: string | undefined;
|
|
if (node.typeVersion > 1) {
|
|
if (authentication === 'n8nUserAuth' && authedUser) {
|
|
// Cookies aren't sent on POST from the sandboxed form page (null origin +
|
|
// SameSite=Lax), so the POST re-authenticates off this token.
|
|
authToken = oAuth2Token;
|
|
} else {
|
|
authToken = await generateFormPostBasicAuthToken(context, authProperty);
|
|
}
|
|
}
|
|
|
|
renderForm({
|
|
context,
|
|
res,
|
|
formTitle,
|
|
formDescription,
|
|
formFields,
|
|
responseMode,
|
|
mode,
|
|
formSubmittedText,
|
|
redirectUrl,
|
|
appendAttribution,
|
|
buttonLabel,
|
|
customCss: options.customCss,
|
|
authToken,
|
|
shellInner,
|
|
hasAuthenticatedSubmitter: authentication === 'n8nUserAuth',
|
|
});
|
|
|
|
return {
|
|
noWebhookResponse: true,
|
|
};
|
|
}
|
|
|
|
// Submit-time readiness gate, and the only real enforcement: the hosting shell's
|
|
// disabled submit button is UX, re-enablable by author script inside the form's
|
|
// iframe. It also works off the state at render time, so a required credential can
|
|
// be revoked — or the page simply left open — between render and submit. Re-check
|
|
// here, after identity establishment and before the execution is enqueued, so a
|
|
// stale page can't spawn a run that dies at credential resolution. The Form node
|
|
// runs the same gate on its own POST (Form.node.ts); the Wait node shares
|
|
// `formWebhook`, so scope this call site to the trigger.
|
|
if (node.type === FORM_TRIGGER_NODE_TYPE) {
|
|
if (await respondIfCredentialsNotReady(context, res)) {
|
|
return { noWebhookResponse: true };
|
|
}
|
|
}
|
|
|
|
let { useWorkflowTimezone } = options;
|
|
|
|
if (useWorkflowTimezone === undefined && node.typeVersion > 2) {
|
|
useWorkflowTimezone = true;
|
|
}
|
|
|
|
const userForOutput = options.includeUserInOutput === false ? undefined : authedUser;
|
|
const returnItem = await prepareFormReturnItem(
|
|
context,
|
|
formFields,
|
|
mode,
|
|
useWorkflowTimezone,
|
|
userForOutput,
|
|
);
|
|
|
|
return {
|
|
webhookResponse: { status: 200 },
|
|
workflowData: [[returnItem]],
|
|
};
|
|
}
|
|
|
|
type ExpressionResolutionContext = Pick<IWebhookFunctions, 'evaluateExpression'>;
|
|
|
|
export function resolveRawData(context: ExpressionResolutionContext, rawData: string) {
|
|
if (!rawData) return rawData;
|
|
|
|
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;
|
|
}
|
|
|
|
type ParseFormFieldsOptions = {
|
|
defineForm: 'json' | 'fields';
|
|
fieldsParameterName: string;
|
|
mode?: 'test' | 'production';
|
|
};
|
|
|
|
export function parseFormFields(context: IWebhookFunctions, options: ParseFormFieldsOptions) {
|
|
let fields: FormFieldsParameter = [];
|
|
if (options.defineForm === 'json') {
|
|
const getJsonOutput = () =>
|
|
context.getNodeParameter(options.fieldsParameterName, '', {
|
|
rawExpressions: true,
|
|
}) as string;
|
|
|
|
fields = parseJsonFormFields(context, getJsonOutput, options.mode);
|
|
} else {
|
|
fields = context.getNodeParameter(options.fieldsParameterName, []) as FormFieldsParameter;
|
|
for (const field of fields) {
|
|
if (field.fieldType === 'html') {
|
|
let html = field.html ?? '';
|
|
for (const resolvable of getResolvables(html)) {
|
|
html = html.replace(resolvable, context.evaluateExpression(resolvable) as string);
|
|
}
|
|
field.html = html;
|
|
}
|
|
}
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
type ParseJsonFormFieldsCtx = Pick<IWebhookFunctions, 'evaluateExpression' | 'getNode'>;
|
|
|
|
/**
|
|
* @throws {NodeOperationError} if the JSON is invalid or cannot be parsed into form fields
|
|
*/
|
|
export function parseJsonFormFields(
|
|
context: ParseJsonFormFieldsCtx,
|
|
getJsonOutput: () => string,
|
|
mode?: 'test' | 'production',
|
|
) {
|
|
try {
|
|
const jsonOutput = getJsonOutput();
|
|
|
|
return tryToParseJsonToFormFields(resolveRawData(context, jsonOutput));
|
|
} catch (e) {
|
|
const error = ensureError(e);
|
|
throw new NodeOperationError(context.getNode(), error.message, {
|
|
description: error.message,
|
|
type: mode === 'test' ? 'manual-form-test' : undefined,
|
|
});
|
|
}
|
|
}
|