fix: Form trigger and Wait Form mode basic authentication fix for form POST request (#23795)

Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
Co-authored-by: Dimitri Lavrenük <dimitri.lavrenuek@n8n.io>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
This commit is contained in:
Michael Kret 2026-01-14 12:33:17 +02:00 committed by Irénée
parent 2d0a8dd4b9
commit 60615eb43a
No known key found for this signature in database
12 changed files with 540 additions and 13 deletions

View File

@ -98,6 +98,13 @@ describe('WaitingWebhooks', () => {
await expect(promise).rejects.toThrowError(ConflictError);
});
describe('findAccessControlOptions', () => {
it('should return * as allowed origins', async () => {
const options = await waitingWebhooks.findAccessControlOptions();
expect(options).toEqual({ allowedOrigins: '*' });
});
});
describe('validateSignatureInRequest', () => {
const EXAMPLE_HOST = 'example.com';
const generateValidSignature = (host = EXAMPLE_HOST) =>

View File

@ -54,6 +54,14 @@ export class WaitingWebhooks implements IWebhookManager {
// TODO: implement `getWebhookMethods` for CORS support
async findAccessControlOptions() {
// waiting webhooks do not support cors configuration options
// allow all origins because waiting webhook forms are always submitted in cors mode due to sandbox CSP
return {
allowedOrigins: '*',
};
}
protected logReceivedWebhook(method: string, executionId: string) {
this.logger.debug(`Received waiting-webhook "${method}" for execution "${executionId}"`);
}

View File

@ -21,7 +21,7 @@ export interface IWebhookManager {
getWebhookMethods?: (path: string) => Promise<IHttpRequestMethods[]>;
/** Find the CORS options matching a path and method */
findAccessControlOptions?: (
findAccessControlOptions: (
path: string,
httpMethod: IHttpRequestMethods,
) => Promise<WebhookAccessControlOptions | undefined>;

View File

@ -993,9 +993,16 @@
postUrl = window.location.search;
}
const authToken = '{{{ authToken }}}';
const headers = {};
if(authToken) {
headers['x-auth-token'] = authToken;
}
fetch(postUrl, {
method: 'POST',
body: formData,
headers,
})
.then(async function (response) {
const useResponseData = document.getElementById("useResponseData").value;

View File

@ -48,6 +48,7 @@ export type FormTriggerData = {
appendAttribution?: boolean;
buttonLabel?: string;
dangerousCustomCss?: string;
authToken?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View File

@ -1,3 +1,4 @@
import crypto from 'crypto';
import { mock } from 'jest-mock-extended';
import { NodeOperationError, type INode } from 'n8n-workflow';
@ -37,6 +38,7 @@ describe('FormTrigger', () => {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
options: {
appendAttribution: false,
@ -145,6 +147,7 @@ describe('FormTrigger', () => {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
},
},
@ -219,13 +222,13 @@ describe('FormTrigger', () => {
});
});
it('should throw on invalid webhook authentication', async () => {
it('webhook execution not successful when token is invalid', async () => {
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const { responseData, response } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'manual',
node: {
parameters: {
@ -236,15 +239,75 @@ describe('FormTrigger', () => {
authentication: 'basicAuth',
},
},
request: { method: 'POST' },
request: { method: 'POST', query: {}, headers: {} },
credential: {
user: 'testuser',
password: 'testpass',
},
});
expect(responseData).toEqual({ noWebhookResponse: true });
expect(response.status).toHaveBeenCalledWith(401);
expect(response.setHeader).toHaveBeenCalledWith(
'WWW-Authenticate',
'Basic realm="Enter credentials"',
);
});
it('should validate POST requests with correct authentication token', async () => {
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const nodeId = 'test-node-id';
const webhookId = 'test-webhook-id';
const credentials = { user: 'testuser', password: 'testpass' };
const token = crypto
.createHmac('sha256', `${credentials.user}:${credentials.password}`)
.update(`${nodeId}-${webhookId}`)
.digest('hex');
const bodyData = {
data: {
'field-0': 'John Doe',
'field-1': '30',
},
};
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'manual',
node: {
id: nodeId,
webhookId,
parameters: {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
formFields: { values: formFields },
authentication: 'basicAuth',
},
},
request: {
method: 'POST',
contentType: 'multipart/form-data',
headers: { 'content-type': 'multipart/form-data', 'x-auth-token': token },
},
bodyData,
credential: credentials,
});
expect(responseData).toEqual({
webhookResponse: { status: 200 },
workflowData: [
[
{
json: {
Name: 'John Doe',
Age: 30,
submittedAt: expect.any(String),
formMode: 'test',
},
},
],
],
});
});
it('should apply customCss property to form render', async () => {
@ -258,6 +321,7 @@ describe('FormTrigger', () => {
formTitle: 'Custom CSS Test',
formDescription: 'Testing custom CSS',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
options: {
customCss: '.form-input { border-color: red; }',
@ -318,6 +382,7 @@ describe('FormTrigger', () => {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
},
},

View File

@ -354,6 +354,7 @@ describe('FormTrigger, formWebhook', () => {
.calledWith('formDescription')
.mockReturnValue('Test Description');
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getNodeParameter.calledWith('authentication').mockReturnValue('none');
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');

View File

@ -25,7 +25,7 @@ import sanitize from 'sanitize-html';
import { getResolvables } from '../../../utils/utilities';
import { WebhookAuthorizationError } from '../../Webhook/error';
import { validateWebhookAuthentication } from '../../Webhook/utils';
import { generateFormPostBasicAuthToken, validateWebhookAuthentication } from '../../Webhook/utils';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
import type { FormTriggerData, FormField } from '../interfaces';
@ -185,6 +185,7 @@ export function prepareFormData({
buttonLabel,
customCss,
nodeVersion,
authToken,
}: {
formTitle: string;
formDescription: string;
@ -200,6 +201,7 @@ export function prepareFormData({
formSubmittedHeader?: string;
customCss?: string;
nodeVersion?: number;
authToken?: string;
}) {
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
@ -221,6 +223,7 @@ export function prepareFormData({
appendAttribution,
buttonLabel,
dangerousCustomCss: sanitizeCustomCss(customCss),
authToken,
};
if (redirectUrl) {
@ -477,6 +480,7 @@ export function renderForm({
appendAttribution,
buttonLabel,
customCss,
authToken,
}: {
context: IWebhookFunctions;
res: Response;
@ -490,6 +494,7 @@ export function renderForm({
appendAttribution?: boolean;
buttonLabel?: string;
customCss?: string;
authToken?: string;
}) {
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/<br>/g, '\n');
const instanceId = context.getInstanceId();
@ -532,6 +537,7 @@ export function renderForm({
buttonLabel,
customCss,
nodeVersion: context.getNode().typeVersion,
authToken,
});
res.setHeader('Content-Security-Policy', getWebhookSandboxCSP());
@ -633,6 +639,11 @@ export async function formWebhook(
responseMode = 'responseNode';
}
let authToken: string | undefined;
if (node.typeVersion > 1) {
authToken = await generateFormPostBasicAuthToken(context, authProperty);
}
renderForm({
context,
res,
@ -646,6 +657,7 @@ export async function formWebhook(
appendAttribution,
buttonLabel,
customCss: options.customCss,
authToken,
});
return {

View File

@ -5,12 +5,16 @@ import {
type INodeExecutionData,
type IDataObject,
type MultiPartFormData,
type INode,
type ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import type { WebhookParameters } from '../utils';
import {
checkResponseModeConfiguration,
configuredOutputs,
generateBasicAuthToken,
generateFormPostBasicAuthToken,
getResponseCode,
getResponseData,
handleFormData,
@ -18,6 +22,7 @@ import {
setupOutputConnection,
validateWebhookAuthentication,
} from '../utils';
import { mock } from 'jest-mock-extended';
jest.mock('jsonwebtoken', () => ({
verify: jest.fn(),
@ -350,6 +355,51 @@ describe('Webhook Utils', () => {
).rejects.toThrowError('Authorization is required!');
});
it('should successfully pass if basicAuth is enabled and provided basic auth data is correct', async () => {
const headers = {
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
};
const ctx: Partial<IWebhookFunctions> = {
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
getCredentials: jest.fn().mockResolvedValue({
user: 'admin',
password: 'password',
}),
getRequestObject: jest.fn().mockReturnValue({
headers,
}),
getHeaderData: jest.fn().mockReturnValue(headers),
};
const authPropertyName = 'authentication';
await validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName);
});
it('should successfully pass if basicAuth is enabled and provided auth token data is correct', async () => {
const node = {
id: 'node-789',
webhookId: 'webhook-456',
type: 'n8n-nodes-base.formTrigger',
} as INode;
const credentials = {
user: (Math.random() * 10000).toString(),
password: (Math.random() * 10000).toString(),
};
const headers = {
'x-auth-token': generateBasicAuthToken(node, credentials),
};
const ctx: Partial<IWebhookFunctions> = {
getNode: jest.fn().mockReturnValue(node),
getCredentials: jest.fn().mockResolvedValue(credentials),
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
getRequestObject: jest.fn().mockReturnValue({
headers,
}),
getHeaderData: jest.fn().mockReturnValue(headers),
};
const authPropertyName = 'authentication';
await validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName);
});
it('should throw an error if headerAuth is enabled but no authentication data is defined on the node', async () => {
const ctx: Partial<IWebhookFunctions> = {
getNodeParameter: jest.fn().mockReturnValue('headerAuth'),
@ -627,3 +677,127 @@ describe('Webhook Utils', () => {
});
});
});
describe('Auth token generation', () => {
describe('generateFormPostBasicAuthToken', () => {
let webhookFunctions: ReturnType<typeof mock<IWebhookFunctions>>;
beforeEach(() => {
webhookFunctions = mock<IWebhookFunctions>();
});
it('should use authentication property for Form Trigger nodes', async () => {
webhookFunctions.getNode.mockReturnValue({
type: 'n8n-nodes-base.formTrigger',
} as INode);
webhookFunctions.getNodeParameter.mockReturnValue('basicAuth');
await generateFormPostBasicAuthToken(webhookFunctions, 'authentication');
expect(webhookFunctions.getNodeParameter).toHaveBeenCalledWith('authentication');
});
it('should use passed authentication key', async () => {
webhookFunctions.getNode.mockReturnValue({
type: 'n8n-nodes-base.wait',
} as INode);
webhookFunctions.getNodeParameter.mockReturnValue('basicAuth');
await generateFormPostBasicAuthToken(webhookFunctions, 'incomingAuthentication');
expect(webhookFunctions.getNodeParameter).toHaveBeenCalledWith('incomingAuthentication');
});
it('should handle "none" authentication', async () => {
webhookFunctions.getNode.mockReturnValue({
type: 'n8n-nodes-base.formTrigger',
} as INode);
webhookFunctions.getNodeParameter.mockReturnValue('none');
const result = await generateFormPostBasicAuthToken(webhookFunctions, 'authentication');
expect(result).toBeUndefined();
});
});
describe('generateBasicAuthToken', () => {
let testNode: INode;
let randomCredentials: ICredentialDataDecryptedObject & {
user: string;
password: string;
};
beforeEach(() => {
testNode = {
id: new Date().getMilliseconds().toString(),
webhookId: 'webhook-456',
type: 'n8n-nodes-base.formTrigger',
} as INode;
randomCredentials = {
user: (Math.random() * 100000).toString(),
password: (Math.random() * 100000).toString(),
};
});
it('should return undefined when credentials are empty', () => {
const result = generateBasicAuthToken(testNode, undefined);
expect(result).toBeUndefined();
});
it('should generate valid HMAC token using credentials', () => {
const result = generateBasicAuthToken(testNode, randomCredentials);
expect(result).toBeDefined();
expect(typeof result).toBe('string');
expect(result?.length).toBe(64);
});
it('should generate deterministic token for same inputs', () => {
const token1 = generateBasicAuthToken(testNode, randomCredentials);
const token2 = generateBasicAuthToken(testNode, randomCredentials);
expect(token1).toBe(token2);
});
it('should generate different tokens for different credentials', () => {
const token1 = generateBasicAuthToken(testNode, { user: 'user1', password: 'password' });
const token2 = generateBasicAuthToken(testNode, { user: 'user2', password: 'password' });
const token3 = generateBasicAuthToken(testNode, { user: 'user1', password: 'passwOrd' });
expect(token1).not.toBe(token2);
expect(token1).not.toBe(token3);
});
it('should generate different tokens for different node IDs', () => {
const token1 = generateBasicAuthToken(
{
id: 'node-789',
webhookId: 'webhook-456',
type: 'n8n-nodes-base.formTrigger',
} as INode,
randomCredentials,
);
const token2 = generateBasicAuthToken(
{
id: 'node-678',
webhookId: 'webhook-456',
type: 'n8n-nodes-base.formTrigger',
} as INode,
randomCredentials,
);
const token3 = generateBasicAuthToken(
{
id: 'node-789',
webhookId: 'webhook-459',
type: 'n8n-nodes-base.formTrigger',
} as INode,
randomCredentials,
);
expect(token1).not.toBe(token2);
expect(token1).not.toBe(token3);
});
});
});

View File

@ -8,8 +8,10 @@ import type {
IDataObject,
ICredentialDataDecryptedObject,
MultiPartFormData,
INode,
} from 'n8n-workflow';
import * as a from 'node:assert';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { BlockList } from 'node:net';
import { WebhookAuthorizationError } from './error';
@ -220,9 +222,25 @@ export async function validateWebhookAuthentication(
const providedAuth = basicAuth(req);
// Authorization data is missing
if (!providedAuth) throw new WebhookAuthorizationError(401);
if (!providedAuth) {
const authToken = headers['x-auth-token'];
if (!authToken) {
throw new WebhookAuthorizationError(401);
}
if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) {
const expectedAuthToken = generateBasicAuthToken(ctx.getNode(), expectedAuth);
if (
!expectedAuthToken ||
typeof authToken !== 'string' ||
expectedAuthToken.length !== authToken.length ||
!timingSafeEqual(Buffer.from(expectedAuthToken), Buffer.from(authToken))
) {
throw new WebhookAuthorizationError(403);
}
} else if (
providedAuth.name !== expectedAuth.user ||
providedAuth.pass !== expectedAuth.password
) {
// Provided authentication data is wrong
throw new WebhookAuthorizationError(403);
}
@ -367,3 +385,36 @@ export async function handleFormData(
return { workflowData: prepareOutput(returnItem) };
}
export async function generateFormPostBasicAuthToken(
context: IWebhookFunctions,
authPropertyName: string,
) {
const node = context.getNode();
const authentication = context.getNodeParameter(authPropertyName);
if (authentication === 'none') return;
let credentials: ICredentialDataDecryptedObject | undefined;
try {
credentials = await context.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
} catch {}
return generateBasicAuthToken(node, credentials);
}
export function generateBasicAuthToken(
node: INode,
credentials: ICredentialDataDecryptedObject | undefined,
) {
if (!credentials || !credentials.user || !credentials.password) {
return;
}
const token = createHmac('sha256', `${credentials.user}:${credentials.password}`)
.update(`${node.id}-${node.webhookId}`)
.digest('hex');
return token;
}

View File

@ -192,7 +192,7 @@ export async function testWebhookTriggerNode(
getMode: () => options.mode ?? 'trigger',
getInstanceId: () => 'instanceId',
getBodyData: () => options.bodyData ?? {},
getHeaderData: () => options.headerData ?? {},
getHeaderData: () => options.headerData ?? request.headers ?? {},
getInputConnectionData: async () => ({}),
getNodeWebhookUrl: (name) => `/test-webhook-url/${name}`,
getParamsData: () => ({}),

View File

@ -1,3 +1,5 @@
import type { IWorkflowBase } from 'n8n-workflow';
import { test, expect } from '../../../fixtures/base';
test.describe('Form Trigger', () => {
@ -120,4 +122,203 @@ test.describe('Form Trigger', () => {
// Close the form page
await formPage.close();
});
test.describe('form execution with basic auth', () => {
const password = new Date().toDateString();
test.use({
httpCredentials: {
username: 'test',
password,
},
});
test('form submission works with basic auth', async ({ api, n8n }) => {
const { id, name } = await api.credentials.createCredential({
name: 'Basic Auth test:test',
type: 'httpBasicAuth',
data: {
user: 'test',
password,
},
});
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
authentication: 'basicAuth',
formTitle: 'Test',
options: {
respondWithOptions: {
values: {
formSubmittedText: 'This worked',
},
},
},
},
type: 'n8n-nodes-base.formTrigger',
typeVersion: 2.5,
position: [0, 0],
id: '49b31a69-3fc9-43d0-944e-990783330e7a',
name: 'On form submission',
webhookId: '17eae80c-039e-4779-be68-08cd5afc5f65',
credentials: {
httpBasicAuth: {
id,
name,
},
},
},
],
connections: {},
pinData: {},
meta: {
instanceId: 'acd7615bcc3af421bbd7517e305cc16505176a6a47045dbe39e25d904c940573',
},
};
const { workflowId } = await api.workflows.createWorkflowFromDefinition(workflow, {
makeUnique: true,
});
await n8n.page.goto(`/workflow/${workflowId}`);
// Start the workflow execution so it's waiting for form submissions
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
// Get the form test URL from the NDV
await n8n.canvas.openNode('On form submission');
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
const formUrl = await formUrlLocator.textContent();
// Open form URL in a new browser tab
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
// Submit the form
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('This worked')).toBeVisible();
});
test('multi-step form submission works with basic auth', async ({ api, n8n }) => {
const { id, name } = await api.credentials.createCredential({
name: 'Basic Auth test:test',
type: 'httpBasicAuth',
data: {
user: 'test',
password,
},
});
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
authentication: 'basicAuth',
formTitle: 'Test',
},
type: 'n8n-nodes-base.formTrigger',
typeVersion: 2.5,
position: [0, 0],
id: '49b31a69-3fc9-43d0-944e-990783330e7a',
name: 'On form submission',
webhookId: '17eae80c-039e-4779-be68-08cd5afc5f64',
credentials: {
httpBasicAuth: {
id,
name,
},
},
},
{
parameters: {
options: {
formDescription: 'Step 2',
},
},
type: 'n8n-nodes-base.form',
typeVersion: 2.5,
position: [208, 0],
id: 'e748b959-faeb-4476-aa30-1c7a6434843a',
name: 'Form',
webhookId: '1e1a6d32-d3a4-4150-886b-2119cc4072bc',
},
{
parameters: {
operation: 'completion',
completionTitle: 'Success',
completionMessage: 'This worked',
options: {},
},
type: 'n8n-nodes-base.form',
typeVersion: 2.5,
position: [416, 0],
id: '2e52c834-e08a-4848-bd86-be1f7909a956',
name: 'Form1',
webhookId: '1839391a-f07d-4bee-858f-678c1b45252b',
},
],
connections: {
'On form submission': {
main: [
[
{
node: 'Form',
type: 'main',
index: 0,
},
],
],
},
Form: {
main: [
[
{
node: 'Form1',
type: 'main',
index: 0,
},
],
],
},
},
pinData: {},
meta: {
templateCredsSetupCompleted: true,
instanceId: 'acd7615bcc3af421bbd7517e305cc16505176a6a47045dbe39e25d904c940573',
},
};
const { workflowId } = await api.workflows.createWorkflowFromDefinition(workflow, {
makeUnique: true,
});
await n8n.page.goto(`/workflow/${workflowId}`);
// Start the workflow execution so it's waiting for form submissions
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
// Get the form test URL from the NDV
await n8n.canvas.openNode('On form submission');
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
const formUrl = await formUrlLocator.textContent();
// Open form URL in a new browser tab
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
// Submit first page
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('Step 2')).toBeVisible();
// submit second page
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('This worked')).toBeVisible();
});
});
});