Skip to content

feat: Add token to sendAndWait operations links to walidate in webhook #17566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: master
Choose a base branch
from

Conversation

michael-radency
Copy link
Contributor

Summary

Links in sendAndWait operations will include a token, which will be validated upon receipt in the webhook method

Related Linear tickets, Github issues, and Community forum posts

https://linear.app/n8n/issue/NODE-3300/unprotected-human-in-the-loop-webhooks

Review / Merge checklist

  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
  • PR Labeled with release/backport (if the PR is an urgent fix that needs to be backported)

@michael-radency michael-radency added node/improvement New feature or request core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team node/issue Issue with a node labels Jul 23, 2025
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

2 issues found across 21 files • Review in cubic

React with 👍 or 👎 to teach cubic. You can also tag @cubic-dev-ai to give feedback, ask questions, or re-run the review.

@dana-gill dana-gill requested a review from elsmr July 23, 2025 04:03
Copy link

codecov bot commented Jul 23, 2025

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
9234 1 9233 0
View the top 1 failed test(s) by shortest run time
ActiveExecutions shutdown Should cancel all executions when cancelAll is true
Stack Traces | 0.002s run time
Error: expect(received).toHaveLength(expected)

Expected length: 4
Received length: 3
Received array:  [{"id": "1547", "mode": "manual", "retryOf": undefined, "startedAt": 2025-07-29T08:08:42.892Z, "status": "new", "workflowId": "123"}, {"id": "1727", "mode": "manual", "retryOf": undefined, "startedAt": 2025-07-29T08:08:42.892Z, "status": "waiting", "workflowId": "123"}, {"id": "1873", "mode": "manual", "retryOf": undefined, "startedAt": 2025-07-29T08:08:42.892Z, "status": "waiting", "workflowId": "123"}]
    at Object.<anonymous> (.../src/__tests__/active-executions.test.ts:342:51)
    at Promise.then.completed (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/utils.js:300:28)
    at new Promise (<anonymous>)
    at callAsyncCircusFn (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/utils.js:233:10)
    at _callCircusTest (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/run.js:315:40)
    at _runTest (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/run.js:251:3)
    at _runTestsForDescribeBlock (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/run.js:125:9)
    at _runTestsForDescribeBlock (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/run.js:120:9)
    at _runTestsForDescribeBlock (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/run.js:120:9)
    at run (.../n8n/node_modules/.pnpm/[email protected]..../jest-circus/build/run.js:70:3)
    at runAndTransformResultsToJestFormat (.../n8n/node_modules/.pnpm/[email protected]..../build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
    at jestAdapter (.../n8n/node_modules/.pnpm/[email protected]..../build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
    at runTestInternal (.../n8n/node_modules/.pnpm/[email protected]..../jest-runner/build/runTest.js:367:16)
    at runTest (.../n8n/node_modules/.pnpm/[email protected]..../jest-runner/build/runTest.js:444:34)
    at Object.worker (.../n8n/node_modules/.pnpm/[email protected]..../jest-runner/build/testWorker.js:106:12)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.


const expectedToken = this.getExecutionWaitingToken();

const valid = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this backwards compatible so that executions created before the introduction of the HMAC mechanism can still be resumed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check, but for now it doesn't look like it will be possible

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could be doable by storing the current largest exec id (eg using a migration) and require the signature only for executions that are newer than the oldest one before this change. Requires some effort but prolly doable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we persist the expected signature as part of execution data and compare on resume…

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe also by using timestamps of execution

question is do we want have code for that included in n8n and affect every single execution farther
breaking will be only single execution of workflow started before instance upgrade, so probably not a lot

@@ -19,3 +19,5 @@ export const CREDENTIAL_ERRORS = {
INVALID_JSON: 'Decrypted credentials data is not valid JSON.',
INVALID_DATA: 'Credentials data is not in a valid format.',
};

export const WAITING_TOKEN_QUERY_PARAM = 'n8nwaitingtoken';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should call the query param sig or signature which is commonly used name for a param like this

@@ -178,6 +180,22 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return this.instanceSettings.instanceId;
}

protected getExecutionWaitingToken() {
const token = crypto
.createHmac('sha256', this.instanceSettings.encryptionKey)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not use the instance encryption key directly for this as it’s not safe. Instead we should let it be configurable and by default derive a nee key from the encryption key. See eg the binary file signing key

* Secret for creating publicly-accesible signed URLs for binary data.

protected getExecutionWaitingToken() {
const token = crypto
.createHmac('sha256', this.instanceSettings.encryptionKey)
.update(this.getExecutionId())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signing only the execution id is not secure. It allows changing any query param and the signature will still be valid. It also makes any subsequent wait URL have the same signature. Instead we need to sugn all critical parts, at least path and all query params.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could not include query params, as we expect them be different, depending of users choice
I think we could add stringified node's parameters to update, this would be unique per waiting node and we could decode it later, WDYT?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh so users can provide arbitrary input to the hitl nodes using query params? I was thinking of signing the URL but I guess we could also sign the details that identify the 1) execution 2) node 3) input (unless arbitrary)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently it's execution id + stringified node(INode), I think this would be unique and secure


const expectedToken = this.getExecutionWaitingToken();

const valid = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment from cubic is valid. Don’t ignore it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, had to wrapped in trycatch block, thought I did this, thanks

@michael-radency michael-radency requested a review from tomi July 24, 2025 06:55
@@ -225,6 +228,14 @@ export class InstanceSettings {
.digest('hex');
}

private getOrGenerateHmacSignatureSecret() {
const hmacSignatureSecretFromEnv = process.env.N8N_HMAC_SIGNATURE_SECRET;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for my understanding, why would using the encryptionKey (or a hash of it) be considered insecure? Introducing new env vars adds configuration complexity and I think we should avoid it unless really necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having separate hmacSignatureSecret make sense to me
I would say we could skip adding new env and always derive hmacSignatureSecret from encryption key
@tomi what do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah ideally I would like us to have only single signing secret, but we have named the binary one very specifically, so we can’t reuse it (without breaking change). As long as we derive a nee key from encryption key it should be okay.

Using it directly is not a good idea because

  1. They have different purposes and different risks. It’s used for encryption while signing key only for signing messages. If one is leaked the other still remains secret
  2. Using the same key can be used to probe details (eg bits) about the key by crafting input to the hmac validation and observing the answer.

More details here

const isTokenValid = this.validateExecutionWaitingToken();

if (!isTokenValid) {
throw new Error('Invalid waiting token');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a NodeOperationError or another class?
Could validateExecutionWaitingToken do the throw?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a NodeOperationError or another class?
Yes, maybe a UserError or NodeApiErro would be more appropriate.

Could validateExecutionWaitingToken handle the throw?
I considered that, but I think it's better if this helper simply returns whether the token is valid. The calling logic can then decide how to proceed. Maybe we should rename it to isExecutionWaitingTokenValid to reflect that behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserError probably makes more sense here as it's invalid input from the user, tho it should never be invalid unless there's a bug or it's malicious input from a user, so NodeApiError might do as well. Don't really mind which one it is, as long as it results in a 403 response for the webhook.

@@ -357,6 +357,12 @@ export async function sendAndWaitWebhook(this: IWebhookFunctions) {
const res = this.getResponseObject();
const req = this.getRequestObject();

const isTokenValid = this.validateExecutionWaitingToken();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to do this in core, maybe in waiting-webhooks, for any waiting execution that is resumed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as for now doing this will break other waiting execution, because updates in nodes will be needed, this PR deals only with HiTL operations

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we should do this in core and provide necessary methods for nodes. I think it’s ok to do it for hitl nodes first

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels wrong to have this in the core.

Currently, this is a complete feature that allows obtaining a signing token and validating it at the node level.
If we move it to the core, we would need to check what kind of node it is and what is current operation. This logic would likely need to be modified later.

We should also consider community nodes - there’s already interest in adding send and wait operations. In my opinion, it’s better to provide tools for node developers rather than handling this at the core level.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking if we could always add the signature to $execution.resumeUrl and verify the signature of all (not specific nodes) incoming requests on waiting-webhook.

Then node code wouldn't have to be changed and $execution.resumeUrl would keep working for existing workflows.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could, but it would increase the scope
we’d have to ensure that the signature is always included in all possible waiting executions

changes to the nodes would still be required - for example like in this PR for including url in service's message

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly what Elias said. Every node using resume URL should have this. But like I said, let's keep this scoped to HITL nodes for now.

@@ -480,8 +486,6 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
.replace(/\\n/g, '\n')
.replace(/<br>/g, '\n');
const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string);
const resumeUrl = context.evaluateExpression('{{ $execution?.resumeUrl }}', 0) as string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that $execution.resumeUrl will no longer work because it would be missing the signature?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, unfortunately, this is breaking change for responses for executions started before update, still looking if it's possible to avoid

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But wouldn't it also be breaking for new executions, if your workflow uses $execution.resumeUrl in an expression?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resumeUrl is only used in the Wait node for webhook mode

In other waiting nodes, such as sendAndWait operations and forms, we provide the FE with a way to resume execution

it would not be breaking, this PR does not modify resumeUrl
sendAndWait operations currently use resumeUrl + nodeId
Forms use resumeFormUrl

@@ -357,6 +357,12 @@ export async function sendAndWaitWebhook(this: IWebhookFunctions) {
const res = this.getResponseObject();
const req = this.getRequestObject();

const isTokenValid = this.validateExecutionWaitingToken();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly what Elias said. Every node using resume URL should have this. But like I said, let's keep this scoped to HITL nodes for now.

const isTokenValid = this.validateExecutionWaitingToken();

if (!isTokenValid) {
throw new Error('Invalid waiting token');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserError probably makes more sense here as it's invalid input from the user, tho it should never be invalid unless there's a bug or it's malicious input from a user, so NodeApiError might do as well. Don't really mind which one it is, as long as it results in a 403 response for the webhook.

Comment on lines 216 to 218
if (this.runExecutionData) {
this.runExecutionData.validateSignature = true;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit odd that a get* method does a side effect. Could we do this somewhere else?

Also, what if there is no execution data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit odd that a get* method does a side effect. Could we do this somewhere else?
moved to constructor
Also, what if there is no execution data?
then we do not need to set anything as context not used in execution

@michael-radency
Copy link
Contributor Author

@tomi updated as suggested, could you please review?

@michael-radency michael-radency requested a review from tomi August 4, 2025 06:41
Copy link
Collaborator

@tomi tomi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly good! Few minor comments

if (this.runExecutionData) this.runExecutionData.validateSignature = true;
}

getSignedResumeUrl(parameters: Record<string, string> = {}) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add docs for this

const { webhookWaitingBaseUrl, executionId } = this.additionalData;

if (typeof executionId !== 'string') {
throw new ApplicationError('Execution id is missing');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplicationError is deprecated. Is this an expected situation? If not, we could use UnexpectedError here

Comment on lines +224 to +234
const baseURL = new URL(`${webhookWaitingBaseUrl}/${executionId}/${this.node.id}`);

for (const [key, value] of Object.entries(parameters)) {
baseURL.searchParams.set(key, value);
}

const token = this.getExecutionWaitingToken(baseURL.toString());

baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token);

return baseURL.toString();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -200,6 +202,38 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return this.instanceSettings.instanceId;
}

getExecutionWaitingToken(url: string) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used outside this class? Should make it private/protected if not

@@ -172,4 +174,28 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
itemIndex,
);
}

validateExecutionWaitingToken() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's document this method


if (typeof token !== 'string') return false;

const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we assume the URL is http? Couldn't it be https as well? Do we need to define the base?

@@ -163,7 +163,7 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
const config = getSendAndWaitConfig(context);

const buttons: string[] = config.options.map(
(option) => `*<${`${config.url}?approved=${option.value}`}|${option.label}>*`,
(option) => `*<${`${option.url}`}|${option.label}>*`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are actually cleaner now that they don't build URLs anymore 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team node/improvement New feature or request node/issue Issue with a node
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants