Skip to content

Commit 3db1af9

Browse files
fix(logic-function): forward raw request body for HMAC signature verification (#20061)
## 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 / 962c2b3). With this change, signature verification can succeed end-to-end. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1e7c169 commit 3db1af9

5 files changed

Lines changed: 120 additions & 41 deletions

File tree

packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,17 @@ describe('verifyGitHubSignature', () => {
8282
});
8383

8484
describe('getRawBodyForSignature', () => {
85-
it('returns the string as-is for string body', () => {
85+
it('prefers event.rawBody when the runtime forwarded it', () => {
86+
const original = '{ "action": "opened", "number": 42 }';
87+
expect(
88+
getRawBodyForSignature({
89+
body: { action: 'opened', number: 42 },
90+
rawBody: original,
91+
}),
92+
).toBe(original);
93+
});
94+
95+
it('falls back to string body when rawBody is not provided', () => {
8696
expect(
8797
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
8898
).toBe('{"a":1}');
@@ -119,3 +129,22 @@ describe('verifyGitHubSignature with parsed body', () => {
119129
});
120130
});
121131
});
132+
133+
describe('end-to-end: server forwards rawBody, signature verifies', () => {
134+
it('verifies a signature when rawBody is present alongside parsed body', () => {
135+
const original = '{ "action": "opened", "number": 42 }';
136+
const event = {
137+
body: { action: 'opened', number: 42 },
138+
rawBody: original,
139+
isBase64Encoded: false,
140+
};
141+
142+
const rawBody = getRawBodyForSignature(event);
143+
const result = verifyGitHubSignature({
144+
rawBody,
145+
signatureHeader: sign(original),
146+
secret: SECRET,
147+
});
148+
expect(result.ok).toBe(true);
149+
});
150+
});

packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ export type SignatureVerificationResult =
77
export function getRawBodyForSignature(event: {
88
body: unknown;
99
isBase64Encoded?: boolean;
10+
rawBody?: string;
1011
}): string | null {
12+
if (typeof event.rawBody === 'string') {
13+
return event.rawBody;
14+
}
1115
const raw = event.body;
1216
if (raw == null) return '';
1317
if (typeof raw === 'string') {

packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type Request } from 'express';
33
import {
44
buildLogicFunctionEvent,
55
extractBody,
6+
extractRawBody,
67
filterRequestHeaders,
78
normalizePathParameters,
89
normalizeQueryStringParameters,
@@ -272,6 +273,37 @@ describe('normalizePathParameters', () => {
272273
});
273274
});
274275

276+
describe('extractRawBody', () => {
277+
it('returns the raw body as utf-8 string when present', () => {
278+
const request = {
279+
rawBody: Buffer.from('{"a":1}', 'utf-8'),
280+
} as unknown as Request;
281+
282+
expect(extractRawBody(request)).toBe('{"a":1}');
283+
});
284+
285+
it('preserves byte-exact representation, including whitespace', () => {
286+
const original = '{ "a" : 1,\n "b": "héllo"\n}';
287+
const request = {
288+
rawBody: Buffer.from(original, 'utf-8'),
289+
} as unknown as Request;
290+
291+
expect(extractRawBody(request)).toBe(original);
292+
});
293+
294+
it('returns undefined when rawBody is missing', () => {
295+
expect(extractRawBody({} as Request)).toBeUndefined();
296+
});
297+
298+
it('returns empty string when rawBody is an empty buffer', () => {
299+
const request = {
300+
rawBody: Buffer.alloc(0),
301+
} as unknown as Request;
302+
303+
expect(extractRawBody(request)).toBe('');
304+
});
305+
});
306+
275307
describe('buildLogicFunctionEvent', () => {
276308
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
277309
({
@@ -416,6 +448,44 @@ describe('buildLogicFunctionEvent', () => {
416448
expect(result.isBase64Encoded).toBe(false);
417449
});
418450

451+
it('should forward rawBody when NestJS preserves it on the request', () => {
452+
const original = '{"action":"opened","number":42}';
453+
const request = createMockRequest({
454+
method: 'POST',
455+
body: { action: 'opened', number: 42 },
456+
});
457+
458+
(request as unknown as { rawBody: Buffer }).rawBody = Buffer.from(
459+
original,
460+
'utf-8',
461+
);
462+
463+
const result = buildLogicFunctionEvent({
464+
request,
465+
pathParameters: {},
466+
forwardedRequestHeaders: [],
467+
});
468+
469+
expect(result.rawBody).toBe(original);
470+
expect(result.body).toEqual({ action: 'opened', number: 42 });
471+
});
472+
473+
it('should omit rawBody when the request has none', () => {
474+
const request = createMockRequest({
475+
method: 'POST',
476+
body: { data: 'test' },
477+
});
478+
479+
const result = buildLogicFunctionEvent({
480+
request,
481+
pathParameters: {},
482+
forwardedRequestHeaders: [],
483+
});
484+
485+
expect(result.rawBody).toBeUndefined();
486+
expect('rawBody' in result).toBe(false);
487+
});
488+
419489
it('should handle complex path parameters', () => {
420490
const request = createMockRequest({
421491
path: '/s/organizations/org1/users/user1/posts',

packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1+
import { type RawBodyRequest } from '@nestjs/common';
12
import { type Request } from 'express';
23
import { type LogicFunctionEvent } from 'twenty-shared/types';
4+
import { isDefined } from 'twenty-shared/utils';
35

4-
/**
5-
* Filters HTTP headers from Express request based on allowed header names
6-
* Header names are case-insensitive as per HTTP specification
7-
*/
86
export const filterRequestHeaders = ({
97
requestHeaders,
108
forwardedRequestHeaders,
@@ -31,11 +29,16 @@ export const filterRequestHeaders = ({
3129
return filteredHeaders;
3230
};
3331

34-
/**
35-
* Extracts the body from Express request as an object
36-
* Express body-parser middleware parses JSON bodies automatically
37-
* Returns null if body is empty/undefined
38-
*/
32+
export const extractRawBody = (request: Request): string | undefined => {
33+
const rawBody = (request as RawBodyRequest<Request>).rawBody;
34+
35+
if (!isDefined(rawBody)) {
36+
return undefined;
37+
}
38+
39+
return rawBody.toString('utf-8');
40+
};
41+
3942
export const extractBody = (request: Request): object | null => {
4043
if (request.body === undefined || request.body === null) {
4144
return null;
@@ -64,10 +67,6 @@ export const extractBody = (request: Request): object | null => {
6467
return { raw: String(request.body) };
6568
};
6669

67-
/**
68-
* Converts Express query parameters to a normalized string format
69-
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
70-
*/
7170
export const normalizeQueryStringParameters = (
7271
query: Request['query'],
7372
): Record<string, string | undefined> => {
@@ -94,10 +93,6 @@ export const normalizeQueryStringParameters = (
9493
return normalized;
9594
};
9695

97-
/**
98-
* Normalizes path parameters to string format
99-
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
100-
*/
10196
export const normalizePathParameters = (
10297
pathParams: Record<string, string | string[] | undefined>,
10398
): Record<string, string | undefined> => {
@@ -118,10 +113,6 @@ export const normalizePathParameters = (
118113
return normalized;
119114
};
120115

121-
/**
122-
* Builds an AWS HTTP API v2 compatible event from an Express request
123-
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
124-
*/
125116
export const buildLogicFunctionEvent = ({
126117
request,
127118
pathParameters,
@@ -131,6 +122,8 @@ export const buildLogicFunctionEvent = ({
131122
pathParameters: Record<string, string | string[] | undefined>;
132123
forwardedRequestHeaders: string[];
133124
}): LogicFunctionEvent => {
125+
const rawBody = extractRawBody(request);
126+
134127
return {
135128
headers: filterRequestHeaders({
136129
requestHeaders: request.headers,
@@ -139,6 +132,7 @@ export const buildLogicFunctionEvent = ({
139132
queryStringParameters: normalizeQueryStringParameters(request.query),
140133
pathParameters: normalizePathParameters(pathParameters),
141134
body: extractBody(request),
135+
...(isDefined(rawBody) ? { rawBody } : {}),
142136
isBase64Encoded: false,
143137
requestContext: {
144138
http: {

packages/twenty-shared/src/types/LogicFunctionEvent.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,13 @@
1-
/**
2-
* AWS HTTP API v2 compatible request format for logic functions
3-
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
4-
*
5-
* @typeParam TBody - The type of the request body. Defaults to `object` for parsed JSON bodies.
6-
*/
71
export type LogicFunctionEvent<TBody = object> = {
8-
/** HTTP headers (filtered by forwardedRequestHeaders in route trigger) */
92
headers: Record<string, string | undefined>;
10-
11-
/** Query string parameters (multiple values are joined with commas, e.g., "1,2,3") */
123
queryStringParameters: Record<string, string | undefined>;
13-
14-
/** Path parameters extracted from the route pattern (e.g., /users/:id → { id: '123' }). Multiple values are joined with commas. */
154
pathParameters: Record<string, string | undefined>;
16-
17-
/** Request body */
185
body: TBody | null;
19-
20-
/** Whether the body is base64 encoded */
6+
rawBody?: string;
217
isBase64Encoded: boolean;
22-
23-
/** Request context containing HTTP method, path, and other metadata */
248
requestContext: {
259
http: {
26-
/** HTTP method (GET, POST, PUT, PATCH, DELETE) */
2710
method: string;
28-
/** Raw request path (e.g., /users/123) */
2911
path: string;
3012
};
3113
};

0 commit comments

Comments
 (0)