feat(Webhook Node): Add "Only Run If" option to filter requests (#28872)

This commit is contained in:
Iván Ovejero 2026-06-22 11:11:51 +02:00 committed by GitHub
parent bbd0500512
commit d64aeb24b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 204 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import type {
INode,
INodeType,
INodeTypes,
IRunExecutionData,
IWebhookData,
IWorkflowExecuteAdditionalData,
Workflow,
@ -79,6 +80,51 @@ describe('WebhookContext', () => {
vi.clearAllMocks();
});
describe('connectionInputData', () => {
it('should expose the HTTP request as input data when there is no execution stack', () => {
const context = new WebhookContext(
workflow,
node,
additionalData,
mode,
webhookData,
[],
null,
);
expect(context.connectionInputData).toEqual([
{
json: {
body: { test: 'body' },
headers: { test: 'header' },
params: { test: 'param' },
query: { test: 'query' },
},
},
]);
});
it('should not throw and should leave input empty when the seeded execution stack has no main data', () => {
const runExecutionDataWithEmptyStack = {
executionData: {
nodeExecutionStack: [{ node, data: { main: [] }, source: null }],
},
} as unknown as IRunExecutionData;
const context = new WebhookContext(
workflow,
node,
additionalData,
mode,
webhookData,
[],
runExecutionDataWithEmptyStack,
);
expect(context.connectionInputData).toEqual([]);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);

View File

@ -47,10 +47,24 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
if (runExecutionData?.executionData !== undefined) {
executionData = runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
connectionInputData = executionData.data.main[0] ?? [];
}
}
if (executionData === undefined && additionalData.httpRequest) {
const req = additionalData.httpRequest;
connectionInputData = [
{
json: {
body: (req.body ?? {}) as IDataObject,
headers: req.headers,
params: req.params as IDataObject,
query: req.query as IDataObject,
},
},
];
}
super(
workflow,
node,

View File

@ -252,6 +252,21 @@ export class Webhook extends Node {
throw error;
}
const node = context.getNode();
const rawOptions = node.parameters?.options as { onlyRunIf?: unknown } | undefined;
const rawOnlyRunIf = rawOptions?.onlyRunIf;
if (typeof rawOnlyRunIf === 'string' && rawOnlyRunIf.startsWith('=')) {
try {
const result = context.evaluateExpression(rawOnlyRunIf.slice(1), 0);
if (!result) return {};
} catch (error) {
context.logger.warn(
`Webhook "Only Run If" expression failed to evaluate; allowing request through. ${(error as Error).message}`,
{ nodeName: node.name },
);
}
}
const prepareOutput = setupOutputConnection(context, requestMethod, {
jwtPayload: validationData,
});

View File

@ -298,6 +298,16 @@ export const optionsProperty: INodeProperties = {
default: false,
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
},
{
displayName: 'Only Run If',
name: 'onlyRunIf',
type: 'string',
default: '',
placeholder: "{{ $json.body.campaign_id === 'user-research-invite' }}",
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json
description:
'Expression evaluated against the incoming request. The workflow will run only if the expression returns true. <code>$json</code> exposes the request as <code>{ body, headers, params, query }</code>. Requests that do not match receive a 200 response, without creating an execution. If the expression fails to evaluate, the request is allowed through and the error is logged.',
},
{
displayName: 'IP(s) Allowlist',
name: 'ipWhitelist',

View File

@ -166,4 +166,122 @@ describe('Test Webhook Node', () => {
expect(node.description.sensitiveOutputFields).not.toContain('headers.content-type');
});
});
describe('onlyRunIf filter', () => {
const node = new Webhook();
let context: ReturnType<typeof mock<IWebhookFunctions>>;
let req: ReturnType<typeof mock<Request>>;
let res: ReturnType<typeof mock<Response>>;
const setup = (
storedOptions: Record<string, unknown>,
runtimeOptions: Record<string, unknown> = storedOptions,
) => {
context = mock<IWebhookFunctions>({ nodeHelpers: mock(), logger: mock() });
req = mock<Request>();
res = mock<Response>();
context.getRequestObject.mockReturnValue(req);
context.getResponseObject.mockReturnValue(res);
context.getChildNodes.mockReturnValue([]);
context.getNode.mockReturnValue({
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
name: 'Webhook',
parameters: { options: storedOptions },
} as any);
context.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'options') return runtimeOptions;
if (paramName === 'responseMode') return 'onReceived';
if (paramName === 'httpMethod') return 'POST';
return undefined;
});
req.headers = { 'content-type': 'application/json' };
req.params = {};
req.query = {};
req.body = { campaign_id: 'user-research-invite' };
Object.defineProperty(req, 'ips', { value: [], configurable: true });
Object.defineProperty(req, 'ip', { value: '127.0.0.1', configurable: true });
};
afterEach(() => vi.clearAllMocks());
it('runs the workflow when the expression evaluates truthy', async () => {
setup({ onlyRunIf: "={{ $json.body.campaign_id === 'user-research-invite' }}" });
context.evaluateExpression.mockReturnValue(true);
const result = await node.webhook(context);
expect(context.evaluateExpression).toHaveBeenCalledWith(
"{{ $json.body.campaign_id === 'user-research-invite' }}",
0,
);
expect(result.workflowData).toBeDefined();
});
it('skips execution when the expression evaluates falsy', async () => {
setup({ onlyRunIf: "={{ $json.body.campaign_id === 'other' }}" });
context.evaluateExpression.mockReturnValue(false);
const result = await node.webhook(context);
expect(result).toEqual({});
});
it('ignores plain-string values (non-expression)', async () => {
setup({ onlyRunIf: "body.campaign_id === 'foo'" });
const result = await node.webhook(context);
expect(context.evaluateExpression).not.toHaveBeenCalled();
expect(result.workflowData).toBeDefined();
});
it('ignores empty filter values', async () => {
setup({ onlyRunIf: '' });
const result = await node.webhook(context);
expect(context.evaluateExpression).not.toHaveBeenCalled();
expect(result.workflowData).toBeDefined();
});
it('ignores a missing filter option entirely', async () => {
setup({});
const result = await node.webhook(context);
expect(context.evaluateExpression).not.toHaveBeenCalled();
expect(result.workflowData).toBeDefined();
});
it('allows the request through and logs a warning when the expression throws', async () => {
setup({ onlyRunIf: '={{ $json.body.nothing.foo === 1 }}' });
context.evaluateExpression.mockImplementation(() => {
throw new Error('nothing is undefined');
});
const result = await node.webhook(context);
expect(result.workflowData).toBeDefined();
expect(context.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Only Run If'),
expect.objectContaining({ nodeName: 'Webhook' }),
);
});
it('does not run the filter before auth/IP checks reject', async () => {
setup({
ipWhitelist: '10.0.0.1',
onlyRunIf: '={{ true }}',
});
const result = await node.webhook(context);
expect(result).toEqual({ noWebhookResponse: true });
expect(res.writeHead).toHaveBeenCalledWith(403);
expect(context.evaluateExpression).not.toHaveBeenCalled();
});
});
});