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
@@ -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);
});
});
@@ -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') {
@@ -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: {
@@ -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<TBody = object> = {
/** HTTP headers (filtered by forwardedRequestHeaders in route trigger) */
headers: Record<string, string | undefined>;
/** Query string parameters (multiple values are joined with commas, e.g., "1,2,3") */
queryStringParameters: Record<string, string | undefined>;
/** Path parameters extracted from the route pattern (e.g., /users/:id → { id: '123' }). Multiple values are joined with commas. */
pathParameters: Record<string, string | undefined>;
/** 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;
};
};