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 /
962c2b3c14). With this change, signature verification can succeed
end-to-end.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Charles Bochet
2026-04-27 13:36:02 +02:00
committed by GitHub
parent 1e7c1691f4
commit 3db1af9a17
5 changed files with 120 additions and 41 deletions
@@ -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> = {}): 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',
@@ -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<Request>).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<string, string | undefined> => {
@@ -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<string, string | string[] | undefined>,
): Record<string, string | undefined> => {
@@ -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<string, string | string[] | undefined>;
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: {