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; 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 { 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 * `/`, 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 { 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/` 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; 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; /** * @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, }); } }