Skip to content

Commit f8cf2ee

Browse files
jchui-wdclaude
andauthored
feat:533 webhook secrets (#6227)
* feat: add webhook secret & HMAC signature verification to webhook trigger Adds server-side webhook secret management (generate/clear/verify) and a UI control in the Start node for configuring the secret, signature header, and signature type (HMAC-SHA256 or plain token). Raw request body is now captured before JSON parsing so HMAC signatures can be verified against the original bytes. Migrations added for all four supported databases. * fix: accept string-coerced numbers and booleans in webhook body type validation application/x-www-form-urlencoded payloads deliver all values as strings, so the strict typeof check was incorrectly rejecting valid numeric ("42") and boolean ("true"/"false") values. Updated the filter to coerce and validate instead, with tests covering both JSON and form-encoded cases. * fix: prevent mass-assignment of webhookSecret fields in chatflow create/update Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 883e258 commit f8cf2ee

25 files changed

Lines changed: 693 additions & 14 deletions

packages/components/nodes/agentflow/Start/Start.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,45 @@ class Start_Agentflow implements INode {
174174
startInputType: 'webhookTrigger'
175175
}
176176
},
177+
{
178+
label: 'Webhook Secret',
179+
name: 'webhookSecret',
180+
type: 'string',
181+
description:
182+
'Optional secret used to verify incoming requests. When set, configure Signature Header and Signature Type below to match your sender.',
183+
optional: true,
184+
show: {
185+
startInputType: 'webhookTrigger'
186+
}
187+
},
188+
{
189+
label: 'Signature Header',
190+
name: 'webhookSignatureHeader',
191+
type: 'string',
192+
description:
193+
'The request header that carries the signature. e.g. x-hub-signature-256 for GitHub, stripe-signature for Stripe, x-gitlab-token for GitLab.',
194+
placeholder: 'x-webhook-signature',
195+
optional: true,
196+
show: {
197+
startInputType: 'webhookTrigger'
198+
}
199+
},
200+
{
201+
label: 'Signature Type',
202+
name: 'webhookSignatureType',
203+
type: 'options',
204+
description:
205+
'How to verify the signature. HMAC-SHA256 for GitHub, Stripe, Slack (supports sha256=<hex> prefix automatically). Plain Token for GitLab-style plain secret comparison.',
206+
options: [
207+
{ label: 'HMAC-SHA256', name: 'hmac-sha256' },
208+
{ label: 'Plain Token', name: 'plain-token' }
209+
],
210+
default: 'hmac-sha256',
211+
optional: true,
212+
show: {
213+
startInputType: 'webhookTrigger'
214+
}
215+
},
177216
{
178217
label: 'Expected Query Parameters',
179218
name: 'webhookQueryParams',

packages/server/src/Interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export interface IChatFlow {
7373
type?: ChatflowType
7474
mcpServerConfig?: string
7575
workspaceId: string
76+
webhookSecret?: string | null
77+
webhookSecretConfigured?: boolean
7678
}
7779

7880
export interface IChatMessage {

packages/server/src/controllers/chatflows/index.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,44 @@ const checkIfChatflowHasChanged = async (req: Request, res: Response, next: Next
275275
}
276276
}
277277

278+
const setWebhookSecret = async (req: Request, res: Response, next: NextFunction) => {
279+
try {
280+
if (!req.params.id) {
281+
throw new InternalFlowiseError(
282+
StatusCodes.PRECONDITION_FAILED,
283+
`Error: chatflowsController.setWebhookSecret - id not provided!`
284+
)
285+
}
286+
const workspaceId = req.user?.activeWorkspaceId
287+
if (!workspaceId) {
288+
throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.setWebhookSecret - workspace not found!`)
289+
}
290+
const apiResponse = await chatflowsService.setWebhookSecret(req.params.id, workspaceId)
291+
return res.json(apiResponse)
292+
} catch (error) {
293+
next(error)
294+
}
295+
}
296+
297+
const clearWebhookSecret = async (req: Request, res: Response, next: NextFunction) => {
298+
try {
299+
if (!req.params.id) {
300+
throw new InternalFlowiseError(
301+
StatusCodes.PRECONDITION_FAILED,
302+
`Error: chatflowsController.clearWebhookSecret - id not provided!`
303+
)
304+
}
305+
const workspaceId = req.user?.activeWorkspaceId
306+
if (!workspaceId) {
307+
throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.clearWebhookSecret - workspace not found!`)
308+
}
309+
await chatflowsService.clearWebhookSecret(req.params.id, workspaceId)
310+
return res.sendStatus(StatusCodes.NO_CONTENT)
311+
} catch (error) {
312+
next(error)
313+
}
314+
}
315+
278316
export default {
279317
checkIfChatflowIsValidForStreaming,
280318
checkIfChatflowIsValidForUploads,
@@ -286,5 +324,7 @@ export default {
286324
updateChatflow,
287325
getSinglePublicChatflow,
288326
getSinglePublicChatbotConfig,
289-
checkIfChatflowHasChanged
327+
checkIfChatflowHasChanged,
328+
setWebhookSecret,
329+
clearWebhookSecret
290330
}

packages/server/src/controllers/webhook/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ describe('createWebhook', () => {
165165
{ foo: 'bar' },
166166
'POST',
167167
expect.any(Object),
168-
expect.any(Object)
168+
expect.any(Object),
169+
undefined // rawBody — not set on mock request
169170
)
170171
})
171172
})

packages/server/src/controllers/webhook/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError'
77

88
const createWebhook = async (req: Request, res: Response, next: NextFunction) => {
99
try {
10-
if (req.params.id == null) {
10+
if (typeof req.params === 'undefined' || !req.params.id) {
1111
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: webhookController.createWebhook - id not provided!`)
1212
}
1313

@@ -25,7 +25,15 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) =>
2525
}
2626
}
2727

28-
await webhookService.validateWebhookChatflow(req.params.id, workspaceId, body, req.method, req.headers, req.query)
28+
await webhookService.validateWebhookChatflow(
29+
req.params.id,
30+
workspaceId,
31+
body,
32+
req.method,
33+
req.headers,
34+
req.query,
35+
(req as any).rawBody
36+
)
2937

3038
// Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist
3139
req.body = {

packages/server/src/database/entities/ChatFlow.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export class ChatFlow implements IChatFlow {
6464
@Column({ nullable: true, type: 'text' })
6565
mcpServerConfig?: string
6666

67+
@Column({ nullable: true, type: 'text', select: false })
68+
webhookSecret?: string | null
69+
70+
@Column({ nullable: true, default: false })
71+
webhookSecretConfigured?: boolean
72+
6773
@Column({ nullable: false, type: 'text' })
6874
workspaceId: string
6975
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm'
2+
3+
export class AddWebhookSecretToChatFlow1776240000003 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;')
6+
await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;')
7+
await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;')
8+
}
9+
10+
public async down(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;')
12+
await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;')
13+
}
14+
}

packages/server/src/database/migrations/mariadb/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { AddChatFlowNameIndex1759424809984 } from './1759424809984-AddChatFlowNa
4343
import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText'
4444
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
4545
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
46+
import { AddWebhookSecretToChatFlow1776240000003 } from './1776240000003-AddWebhookSecretToChatFlow'
4647
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
4748

4849
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables'
@@ -114,5 +115,6 @@ export const mariadbMigrations = [
114115
FixDocumentStoreFileChunkLongText1765000000000,
115116
AddApiKeyPermission1765360298674,
116117
AddReasonContentToChatMessage1764759496768,
118+
AddWebhookSecretToChatFlow1776240000003,
117119
AddMcpServerConfigToChatFlow1767000000000
118120
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm'
2+
3+
export class AddWebhookSecretToChatFlow1776240000002 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;')
6+
await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;')
7+
await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;')
8+
}
9+
10+
public async down(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;')
12+
await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;')
13+
}
14+
}

packages/server/src/database/migrations/mysql/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa
4444
import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText'
4545
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
4646
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
47+
import { AddWebhookSecretToChatFlow1776240000002 } from './1776240000002-AddWebhookSecretToChatFlow'
4748
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
4849

4950
import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables'
@@ -116,5 +117,6 @@ export const mysqlMigrations = [
116117
FixDocumentStoreFileChunkLongText1765000000000,
117118
AddApiKeyPermission1765360298674,
118119
AddReasonContentToChatMessage1764759496768,
120+
AddWebhookSecretToChatFlow1776240000002,
119121
AddMcpServerConfigToChatFlow1767000000000
120122
]

0 commit comments

Comments
 (0)