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:
+30
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -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') {
|
||||
|
||||
+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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user