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:
+70
@@ -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',
|
||||
|
||||
+15
-21
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user