From 3db1af9a1771a87c9b1ff4b8df79036bdad189a3 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 27 Apr 2026 13:36:02 +0200 Subject: [PATCH] fix(logic-function): forward raw request body for HMAC signature verification (#20061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add optional `rawBody?: string` to `LogicFunctionEvent` and forward it from the route trigger so HMAC-based webhook signatures (GitHub's `X-Hub-Signature-256`, Stripe, …) can be verified by user logic functions. - Update `github-connector`'s `getRawBodyForSignature` to prefer `event.rawBody` (with the existing string/base64/null fallbacks kept for older runtimes). ## Why GitHub computes `X-Hub-Signature-256` over the **raw bytes** of the request body. The receiver must verify against those exact bytes — key order, whitespace and unicode escaping all matter, so the parsed JSON body cannot be re-serialized to them. Today the route trigger calls `extractBody(request)` which returns the parsed object only. NestJS already preserves the raw body on `request.rawBody` (the app is bootstrapped with `rawBody: true` in `main.ts`), but it was never propagated into `LogicFunctionEvent`. As a result the github-connector's webhook handler always took the "raw body unavailable" branch and rejected every delivery (after #19961 / 962c2b3c14). With this change, signature verification can succeed end-to-end. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../webhook-signature.integration-test.ts | 31 +++++++- .../github/connector/webhook-signature.ts | 4 ++ .../build-logic-function-event.util.spec.ts | 70 +++++++++++++++++++ .../utils/build-logic-function-event.util.ts | 36 ++++------ .../src/types/LogicFunctionEvent.ts | 20 +----- 5 files changed, 120 insertions(+), 41 deletions(-) diff --git a/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts b/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts index a12d17c2ea..714786048b 100644 --- a/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts +++ b/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts @@ -82,7 +82,17 @@ describe('verifyGitHubSignature', () => { }); describe('getRawBodyForSignature', () => { - it('returns the string as-is for string body', () => { + it('prefers event.rawBody when the runtime forwarded it', () => { + const original = '{ "action": "opened", "number": 42 }'; + expect( + getRawBodyForSignature({ + body: { action: 'opened', number: 42 }, + rawBody: original, + }), + ).toBe(original); + }); + + it('falls back to string body when rawBody is not provided', () => { expect( getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }), ).toBe('{"a":1}'); @@ -119,3 +129,22 @@ describe('verifyGitHubSignature with parsed body', () => { }); }); }); + +describe('end-to-end: server forwards rawBody, signature verifies', () => { + it('verifies a signature when rawBody is present alongside parsed body', () => { + const original = '{ "action": "opened", "number": 42 }'; + const event = { + body: { action: 'opened', number: 42 }, + rawBody: original, + isBase64Encoded: false, + }; + + const rawBody = getRawBodyForSignature(event); + const result = verifyGitHubSignature({ + rawBody, + signatureHeader: sign(original), + secret: SECRET, + }); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts index ef9c76e2f9..51b4fd5241 100644 --- a/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts @@ -7,7 +7,11 @@ export type SignatureVerificationResult = export function getRawBodyForSignature(event: { body: unknown; isBase64Encoded?: boolean; + rawBody?: string; }): string | null { + if (typeof event.rawBody === 'string') { + return event.rawBody; + } const raw = event.body; if (raw == null) return ''; if (typeof raw === 'string') { diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts index 92b904517b..46d31a9451 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts @@ -3,6 +3,7 @@ import { type Request } from 'express'; import { buildLogicFunctionEvent, extractBody, + extractRawBody, filterRequestHeaders, normalizePathParameters, normalizeQueryStringParameters, @@ -272,6 +273,37 @@ describe('normalizePathParameters', () => { }); }); +describe('extractRawBody', () => { + it('returns the raw body as utf-8 string when present', () => { + const request = { + rawBody: Buffer.from('{"a":1}', 'utf-8'), + } as unknown as Request; + + expect(extractRawBody(request)).toBe('{"a":1}'); + }); + + it('preserves byte-exact representation, including whitespace', () => { + const original = '{ "a" : 1,\n "b": "héllo"\n}'; + const request = { + rawBody: Buffer.from(original, 'utf-8'), + } as unknown as Request; + + expect(extractRawBody(request)).toBe(original); + }); + + it('returns undefined when rawBody is missing', () => { + expect(extractRawBody({} as Request)).toBeUndefined(); + }); + + it('returns empty string when rawBody is an empty buffer', () => { + const request = { + rawBody: Buffer.alloc(0), + } as unknown as Request; + + expect(extractRawBody(request)).toBe(''); + }); +}); + describe('buildLogicFunctionEvent', () => { const createMockRequest = (overrides: Partial = {}): Request => ({ @@ -416,6 +448,44 @@ describe('buildLogicFunctionEvent', () => { expect(result.isBase64Encoded).toBe(false); }); + it('should forward rawBody when NestJS preserves it on the request', () => { + const original = '{"action":"opened","number":42}'; + const request = createMockRequest({ + method: 'POST', + body: { action: 'opened', number: 42 }, + }); + + (request as unknown as { rawBody: Buffer }).rawBody = Buffer.from( + original, + 'utf-8', + ); + + const result = buildLogicFunctionEvent({ + request, + pathParameters: {}, + forwardedRequestHeaders: [], + }); + + expect(result.rawBody).toBe(original); + expect(result.body).toEqual({ action: 'opened', number: 42 }); + }); + + it('should omit rawBody when the request has none', () => { + const request = createMockRequest({ + method: 'POST', + body: { data: 'test' }, + }); + + const result = buildLogicFunctionEvent({ + request, + pathParameters: {}, + forwardedRequestHeaders: [], + }); + + expect(result.rawBody).toBeUndefined(); + expect('rawBody' in result).toBe(false); + }); + it('should handle complex path parameters', () => { const request = createMockRequest({ path: '/s/organizations/org1/users/user1/posts', diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts index 2442e04a2f..80cbf71399 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts @@ -1,10 +1,8 @@ +import { type RawBodyRequest } from '@nestjs/common'; import { type Request } from 'express'; import { type LogicFunctionEvent } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; -/** - * Filters HTTP headers from Express request based on allowed header names - * Header names are case-insensitive as per HTTP specification - */ export const filterRequestHeaders = ({ requestHeaders, forwardedRequestHeaders, @@ -31,11 +29,16 @@ export const filterRequestHeaders = ({ return filteredHeaders; }; -/** - * Extracts the body from Express request as an object - * Express body-parser middleware parses JSON bodies automatically - * Returns null if body is empty/undefined - */ +export const extractRawBody = (request: Request): string | undefined => { + const rawBody = (request as RawBodyRequest).rawBody; + + if (!isDefined(rawBody)) { + return undefined; + } + + return rawBody.toString('utf-8'); +}; + export const extractBody = (request: Request): object | null => { if (request.body === undefined || request.body === null) { return null; @@ -64,10 +67,6 @@ export const extractBody = (request: Request): object | null => { return { raw: String(request.body) }; }; -/** - * Converts Express query parameters to a normalized string format - * Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3') - */ export const normalizeQueryStringParameters = ( query: Request['query'], ): Record => { @@ -94,10 +93,6 @@ export const normalizeQueryStringParameters = ( return normalized; }; -/** - * Normalizes path parameters to string format - * Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3') - */ export const normalizePathParameters = ( pathParams: Record, ): Record => { @@ -118,10 +113,6 @@ export const normalizePathParameters = ( return normalized; }; -/** - * Builds an AWS HTTP API v2 compatible event from an Express request - * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html - */ export const buildLogicFunctionEvent = ({ request, pathParameters, @@ -131,6 +122,8 @@ export const buildLogicFunctionEvent = ({ pathParameters: Record; forwardedRequestHeaders: string[]; }): LogicFunctionEvent => { + const rawBody = extractRawBody(request); + return { headers: filterRequestHeaders({ requestHeaders: request.headers, @@ -139,6 +132,7 @@ export const buildLogicFunctionEvent = ({ queryStringParameters: normalizeQueryStringParameters(request.query), pathParameters: normalizePathParameters(pathParameters), body: extractBody(request), + ...(isDefined(rawBody) ? { rawBody } : {}), isBase64Encoded: false, requestContext: { http: { diff --git a/packages/twenty-shared/src/types/LogicFunctionEvent.ts b/packages/twenty-shared/src/types/LogicFunctionEvent.ts index 5ecb5bbdaf..8e76532bf6 100644 --- a/packages/twenty-shared/src/types/LogicFunctionEvent.ts +++ b/packages/twenty-shared/src/types/LogicFunctionEvent.ts @@ -1,31 +1,13 @@ -/** - * AWS HTTP API v2 compatible request format for logic functions - * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html - * - * @typeParam TBody - The type of the request body. Defaults to `object` for parsed JSON bodies. - */ export type LogicFunctionEvent = { - /** HTTP headers (filtered by forwardedRequestHeaders in route trigger) */ headers: Record; - - /** Query string parameters (multiple values are joined with commas, e.g., "1,2,3") */ queryStringParameters: Record; - - /** Path parameters extracted from the route pattern (e.g., /users/:id → { id: '123' }). Multiple values are joined with commas. */ pathParameters: Record; - - /** Request body */ body: TBody | null; - - /** Whether the body is base64 encoded */ + rawBody?: string; isBase64Encoded: boolean; - - /** Request context containing HTTP method, path, and other metadata */ requestContext: { http: { - /** HTTP method (GET, POST, PUT, PATCH, DELETE) */ method: string; - /** Raw request path (e.g., /users/123) */ path: string; }; };