From d2b50005dae8d70500e5724c43ff876c39a63f58 Mon Sep 17 00:00:00 2001 From: Jackie Chui Date: Wed, 15 Apr 2026 18:11:15 -0700 Subject: [PATCH 1/2] 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. --- .../components/nodes/agentflow/Start/Start.ts | 39 ++++++ packages/server/src/Interface.ts | 2 + .../server/src/controllers/chatflows/index.ts | 42 ++++++- .../src/controllers/webhook/index.test.ts | 3 +- .../server/src/controllers/webhook/index.ts | 12 +- .../server/src/database/entities/ChatFlow.ts | 6 + ...776240000003-AddWebhookSecretToChatFlow.ts | 14 +++ .../src/database/migrations/mariadb/index.ts | 2 + ...776240000002-AddWebhookSecretToChatFlow.ts | 14 +++ .../src/database/migrations/mysql/index.ts | 2 + ...776240000001-AddWebhookSecretToChatFlow.ts | 14 +++ .../src/database/migrations/postgres/index.ts | 2 + ...776240000000-AddWebhookSecretToChatFlow.ts | 14 +++ .../src/database/migrations/sqlite/index.ts | 2 + packages/server/src/index.ts | 9 +- packages/server/src/routes/chatflows/index.ts | 4 + .../server/src/services/chatflows/index.ts | 62 +++++++++- .../server/src/services/webhook/index.test.ts | 75 ++++++++++- packages/server/src/services/webhook/index.ts | 25 +++- .../src/utils/signatureVerification.test.ts | 103 +++++++++++++++ .../server/src/utils/signatureVerification.ts | 50 ++++++++ packages/ui/src/api/chatflows.js | 7 +- .../ui/src/views/canvas/NodeInputHandler.jsx | 117 +++++++++++++++++- 23 files changed, 608 insertions(+), 12 deletions(-) create mode 100644 packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts create mode 100644 packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts create mode 100644 packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts create mode 100644 packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts create mode 100644 packages/server/src/utils/signatureVerification.test.ts create mode 100644 packages/server/src/utils/signatureVerification.ts diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index b7ae86730e4..3a33e430789 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -174,6 +174,45 @@ class Start_Agentflow implements INode { startInputType: 'webhookTrigger' } }, + { + label: 'Webhook Secret', + name: 'webhookSecret', + type: 'string', + description: + 'Optional secret used to verify incoming requests. When set, configure Signature Header and Signature Type below to match your sender.', + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, + { + label: 'Signature Header', + name: 'webhookSignatureHeader', + type: 'string', + description: + 'The request header that carries the signature. e.g. x-hub-signature-256 for GitHub, stripe-signature for Stripe, x-gitlab-token for GitLab.', + placeholder: 'x-webhook-signature', + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, + { + label: 'Signature Type', + name: 'webhookSignatureType', + type: 'options', + description: + 'How to verify the signature. HMAC-SHA256 for GitHub, Stripe, Slack (supports sha256= prefix automatically). Plain Token for GitLab-style plain secret comparison.', + options: [ + { label: 'HMAC-SHA256', name: 'hmac-sha256' }, + { label: 'Plain Token', name: 'plain-token' } + ], + default: 'hmac-sha256', + optional: true, + show: { + startInputType: 'webhookTrigger' + } + }, { label: 'Expected Query Parameters', name: 'webhookQueryParams', diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 3d808cfacb9..aea84ab42be 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -73,6 +73,8 @@ export interface IChatFlow { type?: ChatflowType mcpServerConfig?: string workspaceId: string + webhookSecret?: string | null + webhookSecretConfigured?: boolean } export interface IChatMessage { diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index c77f293e4f4..ebcc19e3e5c 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -275,6 +275,44 @@ const checkIfChatflowHasChanged = async (req: Request, res: Response, next: Next } } +const setWebhookSecret = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + `Error: chatflowsController.setWebhookSecret - id not provided!` + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.setWebhookSecret - workspace not found!`) + } + const apiResponse = await chatflowsService.setWebhookSecret(req.params.id, workspaceId) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + +const clearWebhookSecret = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + `Error: chatflowsController.clearWebhookSecret - id not provided!` + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.clearWebhookSecret - workspace not found!`) + } + await chatflowsService.clearWebhookSecret(req.params.id, workspaceId) + return res.sendStatus(StatusCodes.NO_CONTENT) + } catch (error) { + next(error) + } +} + export default { checkIfChatflowIsValidForStreaming, checkIfChatflowIsValidForUploads, @@ -286,5 +324,7 @@ export default { updateChatflow, getSinglePublicChatflow, getSinglePublicChatbotConfig, - checkIfChatflowHasChanged + checkIfChatflowHasChanged, + setWebhookSecret, + clearWebhookSecret } diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts index 39af4e462bd..f08235c1f1d 100644 --- a/packages/server/src/controllers/webhook/index.test.ts +++ b/packages/server/src/controllers/webhook/index.test.ts @@ -165,7 +165,8 @@ describe('createWebhook', () => { { foo: 'bar' }, 'POST', expect.any(Object), - expect.any(Object) + expect.any(Object), + undefined // rawBody — not set on mock request ) }) }) diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts index 2d67086fc9b..caf56d74a5f 100644 --- a/packages/server/src/controllers/webhook/index.ts +++ b/packages/server/src/controllers/webhook/index.ts @@ -7,7 +7,7 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' const createWebhook = async (req: Request, res: Response, next: NextFunction) => { try { - if (req.params.id == null) { + if (typeof req.params === 'undefined' || !req.params.id) { throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: webhookController.createWebhook - id not provided!`) } @@ -25,7 +25,15 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) => } } - await webhookService.validateWebhookChatflow(req.params.id, workspaceId, body, req.method, req.headers, req.query) + await webhookService.validateWebhookChatflow( + req.params.id, + workspaceId, + body, + req.method, + req.headers, + req.query, + (req as any).rawBody + ) // Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist req.body = { diff --git a/packages/server/src/database/entities/ChatFlow.ts b/packages/server/src/database/entities/ChatFlow.ts index bc1a2347ec4..bd895df1dc1 100644 --- a/packages/server/src/database/entities/ChatFlow.ts +++ b/packages/server/src/database/entities/ChatFlow.ts @@ -64,6 +64,12 @@ export class ChatFlow implements IChatFlow { @Column({ nullable: true, type: 'text' }) mcpServerConfig?: string + @Column({ nullable: true, type: 'text', select: false }) + webhookSecret?: string | null + + @Column({ nullable: true, default: false }) + webhookSecretConfigured?: boolean + @Column({ nullable: false, type: 'text' }) workspaceId: string } diff --git a/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..df14de0d812 --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000003 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;') + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;') + await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;') + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;') + } +} diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index 3a94d54fa26..69fa367af26 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -43,6 +43,7 @@ import { AddChatFlowNameIndex1759424809984 } from './1759424809984-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000003 } from './1776240000003-AddWebhookSecretToChatFlow' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables' @@ -114,5 +115,6 @@ export const mariadbMigrations = [ FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000003, AddMcpServerConfigToChatFlow1767000000000 ] diff --git a/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..23753a3f53c --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;') + await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;') + await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;') + await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;') + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index 7dee50f9040..26e023d68a1 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -44,6 +44,7 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000002 } from './1776240000002-AddWebhookSecretToChatFlow' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables' @@ -116,5 +117,6 @@ export const mysqlMigrations = [ FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000002, AddMcpServerConfigToChatFlow1767000000000 ] diff --git a/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..787f51a9ec2 --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "webhookSecret" TEXT;`) + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "webhookSecretConfigured" BOOLEAN DEFAULT FALSE;`) + await queryRunner.query(`UPDATE "chat_flow" SET "webhookSecretConfigured" = TRUE WHERE "webhookSecret" IS NOT NULL;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecretConfigured";`) + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecret";`) + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 4fac9eb1172..e54b0eacb2c 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -42,6 +42,7 @@ import { AddTextToSpeechToChatFlow1759419194331 } from './1759419194331-AddTextT import { AddChatFlowNameIndex1759424903973 } from './1759424903973-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000001 } from './1776240000001-AddWebhookSecretToChatFlow' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/postgres/1720230151482-AddAuthTables' @@ -112,5 +113,6 @@ export const postgresMigrations = [ AddChatFlowNameIndex1759424903973, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000001, AddMcpServerConfigToChatFlow1767000000000 ] diff --git a/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts new file mode 100644 index 00000000000..e8217f14c4f --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddWebhookSecretToChatFlow1776240000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "webhookSecret" TEXT;`) + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "webhookSecretConfigured" BOOLEAN DEFAULT FALSE;`) + await queryRunner.query(`UPDATE "chat_flow" SET "webhookSecretConfigured" = TRUE WHERE "webhookSecret" IS NOT NULL;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecretConfigured";`) + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecret";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index a7612cd8807..8c506eec2d9 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -40,6 +40,7 @@ import { AddTextToSpeechToChatFlow1759419136055 } from './1759419136055-AddTextT import { AddChatFlowNameIndex1759424923093 } from './1759424923093-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddWebhookSecretToChatFlow1776240000000 } from './1776240000000-AddWebhookSecretToChatFlow' import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/sqlite/1720230151482-AddAuthTables' @@ -108,5 +109,6 @@ export const sqliteMigrations = [ AddChatFlowNameIndex1759424923093, AddApiKeyPermission1765360298674, AddReasonContentToChatMessage1764759496768, + AddWebhookSecretToChatFlow1776240000000, AddMcpServerConfigToChatFlow1767000000000 ] diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 91c88fafcdb..1e98e36fb20 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -162,8 +162,13 @@ export class App { async config() { // Limit is needed to allow sending/receiving base64 encoded string const flowise_file_size_limit = process.env.FLOWISE_FILE_SIZE_LIMIT || '50mb' - this.app.use(express.json({ limit: flowise_file_size_limit })) - this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true })) + + // Preserve raw bytes before JSON parsing for webhook HMAC signature verification + const captureRawBody = (req: Request, _res: Response, buf: Buffer) => { + ;(req as any).rawBody = buf as unknown as string + } + this.app.use(express.json({ limit: flowise_file_size_limit, verify: captureRawBody })) + this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true, verify: captureRawBody })) // Enhanced trust proxy settings for load balancer let trustProxy: string | boolean | number | undefined = process.env.TRUST_PROXY diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 5d2ec2609ec..bb9c61c72e5 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -33,6 +33,10 @@ router.put( // DELETE router.delete(['/', '/:id'], checkAnyPermission('chatflows:delete,agentflows:delete'), chatflowsController.deleteChatflow) +// WEBHOOK SECRET +router.post('/:id/webhook-secret', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.setWebhookSecret) +router.delete('/:id/webhook-secret', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.clearWebhookSecret) + // CHECK FOR CHANGE router.get( '/has-changed/:id/:lastUpdatedDateTime', diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 697b757d6c7..cfdeec3bbad 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto' import { ICommonObject, removeFolderFromStorage } from 'flowise-components' import { StatusCodes } from 'http-status-codes' import { Brackets, In } from 'typeorm' @@ -463,6 +464,62 @@ const checkIfChatflowHasChanged = async (chatflowId: string, lastUpdatedDateTime } } +const setWebhookSecret = async (chatflowId: string, workspaceId: string): Promise<{ webhookSecret: string }> => { + try { + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ChatFlow) + const chatflow = await repo.findOne({ where: { id: chatflowId, workspaceId } }) + if (!chatflow) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + chatflow.webhookSecret = randomBytes(32).toString('hex') + chatflow.webhookSecretConfigured = true + await repo.save(chatflow) + return { webhookSecret: chatflow.webhookSecret } + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.setWebhookSecret - ${getErrorMessage(error)}` + ) + } +} + +const clearWebhookSecret = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const repo = appServer.AppDataSource.getRepository(ChatFlow) + const chatflow = await repo.findOne({ where: { id: chatflowId, workspaceId } }) + if (!chatflow) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + chatflow.webhookSecret = null + chatflow.webhookSecretConfigured = false + await repo.save(chatflow) + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.clearWebhookSecret - ${getErrorMessage(error)}` + ) + } +} + +const getWebhookSecret = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow) + .createQueryBuilder('chatflow') + .select('chatflow.webhookSecret') + .where('chatflow.id = :id', { id: chatflowId }) + .andWhere('chatflow.workspaceId = :workspaceId', { workspaceId }) + .getOne() + return dbResponse?.webhookSecret ?? null + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.getWebhookSecret - ${getErrorMessage(error)}` + ) + } +} + export default { checkIfChatflowIsValidForStreaming, checkIfChatflowIsValidForUploads, @@ -475,5 +532,8 @@ export default { updateChatflow, getSinglePublicChatbotConfig, checkIfChatflowHasChanged, - getAllChatflowsCountByOrganization + getAllChatflowsCountByOrganization, + setWebhookSecret, + clearWebhookSecret, + getWebhookSecret } diff --git a/packages/server/src/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts index 3f2c5dc55f3..03dbb80bf6b 100644 --- a/packages/server/src/services/webhook/index.test.ts +++ b/packages/server/src/services/webhook/index.test.ts @@ -2,10 +2,11 @@ import { StatusCodes } from 'http-status-codes' import { InternalFlowiseError } from '../../errors/internalFlowiseError' const mockGetChatflowById = jest.fn() +const mockGetWebhookSecret = jest.fn() jest.mock('../chatflows', () => ({ __esModule: true, - default: { getChatflowById: mockGetChatflowById } + default: { getChatflowById: mockGetChatflowById, getWebhookSecret: mockGetWebhookSecret } })) import webhookService from './index' @@ -18,9 +19,11 @@ const makeChatflow = ( webhookContentType?: string webhookHeaderParams?: unknown webhookQueryParams?: unknown - } + }, + entityFields?: { webhookSecretConfigured?: boolean } ) => ({ id: 'test-id', + ...entityFields, flowData: JSON.stringify({ nodes: [ { @@ -219,4 +222,72 @@ describe('validateWebhookChatflow', () => { await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toBeUndefined() }) + + // --- HMAC signature verification --- + + const SECRET = 'test-secret-abc123' + const RAW_BODY = Buffer.from('{"event":"push"}') + + function sign(secret: string, body: Buffer): string { + const { createHmac } = require('crypto') + return createHmac('sha256', secret).update(body).digest('hex') + } + + it('resolves without signature check when no webhookSecret is configured', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger')) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).resolves.toBeUndefined() + }) + + it('resolves when secret is set and signature is valid', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY) + ).resolves.toBeUndefined() + }) + + it('throws 401 when secret is set but X-Webhook-Signature header is missing', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetWebhookSecret.mockResolvedValue(SECRET) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).rejects.toMatchObject({ + statusCode: 401 + }) + }) + + it('throws 401 when secret is set but signature is wrong', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const headers = { 'x-webhook-signature': 'deadbeef' } + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY)).rejects.toMatchObject( + { + statusCode: 401 + } + ) + }) + + it('throws 401 when payload is tampered (signature computed against original body)', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const tamperedBody = Buffer.from('{"event":"delete"}') + const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, tamperedBody) + ).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('throws 401 when secret is set but rawBody is undefined', async () => { + mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', {}, { webhookSecretConfigured: true })) + mockGetWebhookSecret.mockResolvedValue(SECRET) + const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) } + + await expect( + webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, undefined) + ).rejects.toMatchObject({ statusCode: 401 }) + }) }) diff --git a/packages/server/src/services/webhook/index.ts b/packages/server/src/services/webhook/index.ts index bc945975fbe..e3e8f3a6a38 100644 --- a/packages/server/src/services/webhook/index.ts +++ b/packages/server/src/services/webhook/index.ts @@ -2,6 +2,7 @@ import { StatusCodes } from 'http-status-codes' import { IReactFlowObject } from '../../Interface' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' +import { verifyWebhookSignature, verifyPlainToken } from '../../utils/signatureVerification' import chatflowsService from '../chatflows' const validateWebhookChatflow = async ( @@ -10,7 +11,8 @@ const validateWebhookChatflow = async ( body?: Record, method?: string, headers?: Record, - query?: Record + query?: Record, + rawBody?: Buffer ): Promise => { try { const chatflow = await chatflowsService.getChatflowById(chatflowId, workspaceId) @@ -26,6 +28,27 @@ const validateWebhookChatflow = async ( throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} is not configured as a webhook trigger`) } + // Signature verification (runs before any other validation to fail-fast on bad auth) + if (chatflow.webhookSecretConfigured) { + const sigHeader = ((startNode?.data?.inputs?.webhookSignatureHeader as string) || 'x-webhook-signature').toLowerCase() + const sigType = (startNode?.data?.inputs?.webhookSignatureType as string) || 'hmac-sha256' + const sigValue = (headers?.[sigHeader] ?? '') as string + + if (!sigValue) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid or missing webhook signature') + } + + const secret = await chatflowsService.getWebhookSecret(chatflowId, chatflow.workspaceId) + const valid = + sigType === 'plain-token' + ? !!secret && verifyPlainToken(secret, sigValue) + : !!secret && !!rawBody && verifyWebhookSignature(secret, rawBody, sigValue) + + if (!valid) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid or missing webhook signature') + } + } + // Method validation const webhookMethod = startNode?.data?.inputs?.webhookMethod if (webhookMethod && method?.toUpperCase() !== webhookMethod.toUpperCase()) { diff --git a/packages/server/src/utils/signatureVerification.test.ts b/packages/server/src/utils/signatureVerification.test.ts new file mode 100644 index 00000000000..093c9b5493d --- /dev/null +++ b/packages/server/src/utils/signatureVerification.test.ts @@ -0,0 +1,103 @@ +import { createHmac } from 'crypto' +import { verifyWebhookSignature, verifyPlainToken } from './signatureVerification' + +const SECRET = 'test-secret-abc123' +const BODY = Buffer.from('{"event":"push"}') + +function sign(secret: string, body: Buffer): string { + return createHmac('sha256', secret).update(new Uint8Array(body)).digest('hex') +} + +describe('verifyWebhookSignature', () => { + it('returns true for a valid signature', () => { + expect(verifyWebhookSignature(SECRET, BODY, sign(SECRET, BODY))).toBe(true) + }) + + it('returns false for a wrong secret', () => { + expect(verifyWebhookSignature('wrong-secret', BODY, sign(SECRET, BODY))).toBe(false) + }) + + it('returns false for a tampered body', () => { + const tamperedBody = Buffer.from('{"event":"delete"}') + expect(verifyWebhookSignature(SECRET, tamperedBody, sign(SECRET, BODY))).toBe(false) + }) + + it('returns false for an empty signature string', () => { + expect(verifyWebhookSignature(SECRET, BODY, '')).toBe(false) + }) + + it('returns false for a non-hex signature string', () => { + expect(verifyWebhookSignature(SECRET, BODY, 'not-hex!!')).toBe(false) + }) + + it('returns false for a signature that is too short', () => { + const truncated = sign(SECRET, BODY).slice(0, 10) + expect(verifyWebhookSignature(SECRET, BODY, truncated)).toBe(false) + }) + + it('returns false for a signature that is too long', () => { + const padded = sign(SECRET, BODY) + 'aabb' + expect(verifyWebhookSignature(SECRET, BODY, padded)).toBe(false) + }) + + it('returns true for an empty body when signed correctly', () => { + const emptyBody = Buffer.from('') + expect(verifyWebhookSignature(SECRET, emptyBody, sign(SECRET, emptyBody))).toBe(true) + }) + + describe('sha256= prefix', () => { + it('returns true for a valid sha256= signature', () => { + const sig = 'sha256=' + sign(SECRET, BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(true) + }) + + it('returns false for sha256= with wrong secret', () => { + const sig = 'sha256=' + sign('wrong-secret', BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + + it('returns false for sha256= with tampered body', () => { + const sig = 'sha256=' + sign(SECRET, Buffer.from('{"event":"delete"}')) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + }) + + describe('sha1= prefix', () => { + function signSha1(secret: string, body: Buffer): string { + return createHmac('sha1', secret).update(new Uint8Array(body)).digest('hex') + } + + it('returns true for a valid sha1= signature', () => { + const sig = 'sha1=' + signSha1(SECRET, BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(true) + }) + + it('returns false for sha1= with wrong secret', () => { + const sig = 'sha1=' + signSha1('wrong-secret', BODY) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + + it('returns false for sha1= with tampered body', () => { + const sig = 'sha1=' + signSha1(SECRET, Buffer.from('{"event":"delete"}')) + expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false) + }) + }) +}) + +describe('verifyPlainToken', () => { + it('returns true when provided token matches secret', () => { + expect(verifyPlainToken(SECRET, SECRET)).toBe(true) + }) + + it('returns false when provided token does not match secret', () => { + expect(verifyPlainToken(SECRET, 'wrong-token')).toBe(false) + }) + + it('returns false when lengths differ (shorter provided)', () => { + expect(verifyPlainToken(SECRET, SECRET.slice(0, -1))).toBe(false) + }) + + it('returns false when lengths differ (longer provided)', () => { + expect(verifyPlainToken(SECRET, SECRET + 'x')).toBe(false) + }) +}) diff --git a/packages/server/src/utils/signatureVerification.ts b/packages/server/src/utils/signatureVerification.ts new file mode 100644 index 00000000000..00f7118c8c5 --- /dev/null +++ b/packages/server/src/utils/signatureVerification.ts @@ -0,0 +1,50 @@ +import { createHmac, timingSafeEqual } from 'crypto' + +/** + * Verifies that `providedHex` is the HMAC of `rawBody` keyed with `secret`. + * Uses constant-time comparison to prevent timing attacks. + * + * Automatically detects the algorithm from a leading "=" prefix: + * - "sha256=" → HMAC-SHA256 (GitHub X-Hub-Signature-256, Slack, Bitbucket) + * - "sha1=" → HMAC-SHA1 (GitHub X-Hub-Signature legacy) + * - no prefix → HMAC-SHA256 (default) + * + * @param secret The webhook secret stored on the chatflow + * @param rawBody The raw request body bytes + * @param providedHex The hex digest (optionally prefixed) from the signature request header + * @returns true if the signature is valid, false otherwise + */ +export function verifyWebhookSignature(secret: string, rawBody: Buffer, providedHex: string): boolean { + let algo = 'sha256' + let hex = providedHex + if (providedHex.includes('=')) { + const [prefix, ...rest] = providedHex.split('=') + hex = rest.join('=') + if (prefix === 'sha1') algo = 'sha1' + } + const expected = createHmac(algo, secret).update(new Uint8Array(rawBody)).digest() + let provided: Buffer + try { + provided = Buffer.from(hex, 'hex') + } catch { + return false + } + if (provided.length !== expected.length) return false + return timingSafeEqual(new Uint8Array(provided), new Uint8Array(expected)) +} + +/** + * Verifies a plain-token signature by doing a constant-time string comparison + * between the stored secret and the value provided in the request header. + * Used for GitLab-style webhooks that send the raw secret directly in a header. + * + * @param secret The webhook secret stored on the chatflow + * @param provided The raw value from the signature request header + * @returns true if the values match, false otherwise + */ +export function verifyPlainToken(secret: string, provided: string): boolean { + const secretBuf = Buffer.from(secret) + const providedBuf = Buffer.from(provided) + if (secretBuf.length !== providedBuf.length) return false + return timingSafeEqual(new Uint8Array(secretBuf), new Uint8Array(providedBuf)) +} diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index a5d4f323ac5..b2a5beac4b1 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -22,6 +22,9 @@ const getHasChatflowChanged = (id, lastUpdatedDateTime) => client.get(`/chatflow const generateAgentflow = (body) => client.post(`/agentflowv2-generator/generate`, body) +const setWebhookSecret = (id) => client.post(`/chatflows/${id}/webhook-secret`) +const clearWebhookSecret = (id) => client.delete(`/chatflows/${id}/webhook-secret`) + export default { getAllChatflows, getAllAgentflows, @@ -33,5 +36,7 @@ export default { getIsChatflowStreaming, getAllowChatflowUploads, getHasChatflowChanged, - generateAgentflow + generateAgentflow, + setWebhookSecret, + clearWebhookSecret } diff --git a/packages/ui/src/views/canvas/NodeInputHandler.jsx b/packages/ui/src/views/canvas/NodeInputHandler.jsx index 021e7e9571f..6fd2cdb5776 100644 --- a/packages/ui/src/views/canvas/NodeInputHandler.jsx +++ b/packages/ui/src/views/canvas/NodeInputHandler.jsx @@ -76,6 +76,7 @@ import PromptGeneratorDialog from '@/ui-component/dialog/PromptGeneratorDialog' // API import assistantsApi from '@/api/assistants' +import chatflowsApi from '@/api/chatflows' import documentstoreApi from '@/api/documentstore' // utils @@ -90,7 +91,7 @@ import useNotifier from '@/utils/useNotifier' // const import { baseURL, FLOWISE_CREDENTIAL_ID } from '@/store/constant' -import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions' +import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, SET_CHATFLOW } from '@/store/actions' const EDITABLE_OPTIONS = ['selectedTool', 'selectedAssistant'] @@ -168,6 +169,46 @@ const NodeInputHandler = ({ const [isNvidiaNIMDialogOpen, setIsNvidiaNIMDialogOpen] = useState(false) const [tabValue, setTabValue] = useState(0) + // Webhook secret — holds plaintext only for the current session (generate/regenerate response). + // Cleared on page reload; the configured state comes from canvasChatflow.webhookSecretConfigured. + const [webhookSecretPlaintext, setWebhookSecretPlaintext] = useState(null) + + const handleSetWebhookSecret = async () => { + if (!chatflowId) return + try { + const resp = await chatflowsApi.setWebhookSecret(chatflowId) + setWebhookSecretPlaintext(resp.data.webhookSecret) + dispatch({ type: SET_CHATFLOW, chatflow: { ...canvasChatflow, webhookSecretConfigured: true } }) + enqueueSnackbar({ + message: 'Webhook secret generated.', + options: { key: new Date().getTime() + Math.random(), variant: 'success' } + }) + } catch (error) { + enqueueSnackbar({ + message: error?.response?.data?.message || 'Failed to generate webhook secret.', + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + } + } + + const handleClearWebhookSecret = async () => { + if (!chatflowId) return + try { + await chatflowsApi.clearWebhookSecret(chatflowId) + setWebhookSecretPlaintext(null) + dispatch({ type: SET_CHATFLOW, chatflow: { ...canvasChatflow, webhookSecretConfigured: false } }) + enqueueSnackbar({ + message: 'Webhook secret removed.', + options: { key: new Date().getTime() + Math.random(), variant: 'success' } + }) + } catch (error) { + enqueueSnackbar({ + message: error?.response?.data?.message || 'Failed to remove webhook secret.', + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + } + } + const [modelSelectionDialogOpen, setModelSelectionDialogOpen] = useState(false) const [availableChatModels, setAvailableChatModels] = useState([]) const [availableChatModelsOptions, setAvailableChatModelsOptions] = useState([]) @@ -1139,7 +1180,81 @@ const NodeInputHandler = ({ /> )} + {inputParam.name === 'webhookSecret' && ( + + {!canvasChatflow?.webhookSecretConfigured && !webhookSecretPlaintext ? ( + // Not configured + + + No secret configured + + {chatflowId && ( + + )} + + ) : ( + // Configured — show masked or plaintext field with actions + + {webhookSecretPlaintext && ( + + { + navigator.clipboard.writeText(webhookSecretPlaintext).then( + () => + enqueueSnackbar({ + message: 'Secret copied!', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success' + } + }), + () => + enqueueSnackbar({ + message: 'Failed to copy secret.', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error' + } + }) + ) + }} + > + + + + )} + + + + + + + + + + + + ) + }} + /> + )} + + )} + {inputParam.name !== 'webhookURL' && + inputParam.name !== 'webhookSecret' && (inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') && (inputParam?.acceptVariable && (window.location.href.includes('v2/agentcanvas') || window.location.href.includes('v2/marketplace')) ? ( From e1affad07f42c470a27a26ee5d83581f897c0141 Mon Sep 17 00:00:00 2001 From: Jackie Chui Date: Thu, 16 Apr 2026 11:25:30 -0700 Subject: [PATCH 2/2] 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. --- packages/server/src/index.ts | 2 +- .../server/src/services/webhook/index.test.ts | 54 +++++++++++++++++++ packages/server/src/services/webhook/index.ts | 8 ++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1e98e36fb20..55f9d3e96b5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -165,7 +165,7 @@ export class App { // Preserve raw bytes before JSON parsing for webhook HMAC signature verification const captureRawBody = (req: Request, _res: Response, buf: Buffer) => { - ;(req as any).rawBody = buf as unknown as string + ;(req as any).rawBody = buf } this.app.use(express.json({ limit: flowise_file_size_limit, verify: captureRawBody })) this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true, verify: captureRawBody })) diff --git a/packages/server/src/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts index 03dbb80bf6b..198c26d7048 100644 --- a/packages/server/src/services/webhook/index.test.ts +++ b/packages/server/src/services/webhook/index.test.ts @@ -206,6 +206,60 @@ describe('validateWebhookChatflow', () => { await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toBeUndefined() }) + it('resolves when number param is sent as a numeric string (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '42' })).resolves.toBeUndefined() + }) + + it('throws 400 when number param is an empty string (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('count') + }) + }) + + it('resolves when boolean param is a native boolean (JSON)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: true })).resolves.toBeUndefined() + }) + + it('resolves when boolean param is the string "true" (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'true' })).resolves.toBeUndefined() + }) + + it('resolves when boolean param is the string "false" (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'false' })).resolves.toBeUndefined() + }) + + it('throws 400 when boolean param is an invalid string like "yes" (form-encoded)', async () => { + mockGetChatflowById.mockResolvedValue( + makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }] }) + ) + + await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'yes' })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST, + message: expect.stringContaining('active') + }) + }) + // --- Query param validation --- it('throws 400 when a required query param is missing', async () => { diff --git a/packages/server/src/services/webhook/index.ts b/packages/server/src/services/webhook/index.ts index e3e8f3a6a38..e5c6917e3e6 100644 --- a/packages/server/src/services/webhook/index.ts +++ b/packages/server/src/services/webhook/index.ts @@ -85,7 +85,13 @@ const validateWebhookChatflow = async ( // Body type validation (only for params that have an explicit type declared) const typeMismatch = webhookBodyParams - .filter((p) => p.type != null && body?.[p.name] != null && typeof body[p.name] !== p.type) + .filter((p) => { + if (p.type == null || body?.[p.name] == null) return false + const val = body[p.name] + if (p.type === 'number') return val === '' || isNaN(Number(val)) + if (p.type === 'boolean') return typeof val !== 'boolean' && val !== 'true' && val !== 'false' + return typeof val !== p.type + }) .map((p) => p.name) if (typeMismatch.length > 0) {