Add forwardedRequestHeaders in routeTriggers (#17151)

- add `forwardedRequestHeaders` `string[]` column in `core.routeTrigger`
- filter request headers and forward filtered headers to function
payload (avoid spreading unexpectedly token or cookie)
- add `forwardedRequestHeaders` option in twenty-sdk `defineFunction`
util

BREAKING for actual routeTrigger payload but only 16 to migrate in
production
This commit is contained in:
martmull
2026-01-16 10:19:28 +01:00
committed by GitHub
parent 8908e0785f
commit 1917c1c9b9
24 changed files with 820 additions and 23 deletions
@@ -4,4 +4,5 @@ export const FLAT_ROUTE_TRIGGER_EDITABLE_PROPERTIES = [
'path',
'isAuthRequired',
'httpMethod',
'forwardedRequestHeaders',
] as const satisfies (keyof FlatRouteTrigger)[];
@@ -1,9 +1,11 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
@@ -31,6 +33,16 @@ export class CreateRouteTriggerInput {
@Field()
serverlessFunctionId: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
@Field(() => [String], {
defaultValue: [],
description:
'List of HTTP header names to forward to the serverless function event',
})
forwardedRequestHeaders: string[];
@HideField()
universalIdentifier?: string;
@@ -18,6 +18,12 @@ export class RouteTriggerDTO {
@Field(() => HTTPMethod)
httpMethod: HTTPMethod;
@Field(() => [String], {
description:
'List of HTTP header names to forward to the serverless function event',
})
forwardedRequestHeaders: string[];
@Field()
createdAt: Date;
@@ -2,9 +2,11 @@ import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
@@ -27,6 +29,16 @@ class UpdateRouteTriggerInputUpdates {
@IsNotEmpty()
@Field(() => HTTPMethod)
httpMethod: HTTPMethod;
@IsArray()
@IsString({ each: true })
@IsOptional()
@Field(() => [String], {
defaultValue: [],
description:
'List of HTTP header names to forward to the serverless function event',
})
forwardedRequestHeaders: string[];
}
@InputType()
@@ -41,6 +41,9 @@ export class RouteTriggerEntity
})
httpMethod: HTTPMethod;
@Column({ nullable: false, type: 'jsonb', default: [] })
forwardedRequestHeaders: string[];
@ManyToOne(
() => ServerlessFunctionEntity,
(serverlessFunction) => serverlessFunction.routeTriggers,
@@ -14,6 +14,7 @@ import {
RouteTriggerExceptionCode,
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { buildServerlessFunctionEvent } from 'src/engine/metadata-modules/route-trigger/utils/build-serverless-function-event.util';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@Injectable()
@@ -128,21 +129,18 @@ export class RouteTriggerService {
});
}
const queryParams = request.query;
const bodyParams = request.body;
const executionParams = {
...queryParams,
...bodyParams,
...routeTriggerWithPathParams.pathParams,
};
const event = buildServerlessFunctionEvent({
request,
pathParameters: routeTriggerWithPathParams.pathParams,
forwardedRequestHeaders:
routeTriggerWithPathParams.routeTrigger.forwardedRequestHeaders ?? [],
});
const result =
await this.serverlessFunctionService.executeOneServerlessFunction({
id: routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId,
payload: executionParams,
payload: event,
version: 'draft',
});
@@ -0,0 +1,438 @@
import { type Request } from 'express';
import {
buildServerlessFunctionEvent,
extractBody,
filterRequestHeaders,
normalizePathParameters,
normalizeQueryStringParameters,
} from 'src/engine/metadata-modules/route-trigger/utils/build-serverless-function-event.util';
describe('filterRequestHeaders', () => {
it('should filter headers based on allowed names', () => {
const requestHeaders = {
'content-type': 'application/json',
authorization: 'Bearer token123',
'x-custom-header': 'custom-value',
'user-agent': 'test-agent',
};
const forwardedRequestHeaders = ['content-type', 'authorization'];
const result = filterRequestHeaders({
requestHeaders,
forwardedRequestHeaders,
});
expect(result).toEqual({
'content-type': 'application/json',
authorization: 'Bearer token123',
});
});
it('should handle case-insensitive header names', () => {
const requestHeaders = {
'content-type': 'application/json',
authorization: 'Bearer token123',
};
const forwardedRequestHeaders = ['Content-Type', 'AUTHORIZATION'];
const result = filterRequestHeaders({
requestHeaders,
forwardedRequestHeaders,
});
expect(result).toEqual({
'content-type': 'application/json',
authorization: 'Bearer token123',
});
});
it('should return empty object when no headers match', () => {
const requestHeaders = {
'content-type': 'application/json',
};
const forwardedRequestHeaders = ['x-custom-header'];
const result = filterRequestHeaders({
requestHeaders,
forwardedRequestHeaders,
});
expect(result).toEqual({});
});
it('should return empty object when forwardedRequestHeaders is empty', () => {
const requestHeaders = {
'content-type': 'application/json',
};
const result = filterRequestHeaders({
requestHeaders,
forwardedRequestHeaders: [],
});
expect(result).toEqual({});
});
it('should convert array header values to comma-separated string', () => {
const requestHeaders = {
'x-custom-array-header': ['value1', 'value2', 'value3'],
};
const forwardedRequestHeaders = ['x-custom-array-header'];
const result = filterRequestHeaders({
requestHeaders,
forwardedRequestHeaders,
});
expect(result).toEqual({
'x-custom-array-header': 'value1, value2, value3',
});
});
it('should skip undefined header values', () => {
const requestHeaders = {
'content-type': 'application/json',
'x-missing': undefined,
};
const forwardedRequestHeaders = ['content-type', 'x-missing'];
const result = filterRequestHeaders({
requestHeaders,
forwardedRequestHeaders,
});
expect(result).toEqual({
'content-type': 'application/json',
});
});
});
describe('extractBody', () => {
it('should return null for undefined body', () => {
const request = { body: undefined } as Request;
const result = extractBody(request);
expect(result).toBeNull();
});
it('should return null for null body', () => {
const request = { body: null } as unknown as Request;
const result = extractBody(request);
expect(result).toBeNull();
});
it('should parse string body as JSON', () => {
const request = { body: '{"key":"value"}' } as unknown as Request;
const result = extractBody(request);
expect(result).toEqual({ key: 'value' });
});
it('should wrap non-JSON string body in raw property', () => {
const request = { body: 'plain text body' } as unknown as Request;
const result = extractBody(request);
expect(result).toEqual({ raw: 'plain text body' });
});
it('should return object body as-is (parsed JSON)', () => {
const request = {
body: { key: 'value', nested: { foo: 'bar' } },
} as Request;
const result = extractBody(request);
expect(result).toEqual({ key: 'value', nested: { foo: 'bar' } });
});
it('should parse Buffer body as JSON', () => {
const request = {
body: Buffer.from('{"buffered":"json"}'),
} as unknown as Request;
const result = extractBody(request);
expect(result).toEqual({ buffered: 'json' });
});
it('should wrap non-JSON Buffer body in raw property', () => {
const request = {
body: Buffer.from('buffer content'),
} as unknown as Request;
const result = extractBody(request);
expect(result).toEqual({ raw: 'buffer content' });
});
it('should handle empty object body', () => {
const request = { body: {} } as Request;
const result = extractBody(request);
expect(result).toEqual({});
});
it('should handle array body', () => {
const request = { body: [1, 2, 3] } as unknown as Request;
const result = extractBody(request);
expect(result).toEqual([1, 2, 3]);
});
});
describe('normalizeQueryStringParameters', () => {
it('should handle simple string parameters', () => {
const query = { page: '1', limit: '10' };
const result = normalizeQueryStringParameters(query);
expect(result).toEqual({ page: '1', limit: '10' });
});
it('should join array parameters with commas', () => {
const query = { ids: ['1', '2', '3'] };
const result = normalizeQueryStringParameters(query);
expect(result).toEqual({ ids: '1,2,3' });
});
it('should skip undefined parameters', () => {
const query = { page: '1', missing: undefined };
const result = normalizeQueryStringParameters(query);
expect(result).toEqual({ page: '1' });
});
it('should handle empty query object', () => {
const query = {};
const result = normalizeQueryStringParameters(query);
expect(result).toEqual({});
});
it('should stringify nested objects', () => {
const query = { filter: { name: 'test' } as unknown as string };
const result = normalizeQueryStringParameters(query);
expect(result).toEqual({ filter: '{"name":"test"}' });
});
it('should filter non-string values from arrays and join with commas', () => {
const query = { ids: ['1', undefined as unknown as string, '2'] };
const result = normalizeQueryStringParameters(query);
expect(result).toEqual({ ids: '1,2' });
});
});
describe('normalizePathParameters', () => {
it('should handle simple string parameters', () => {
const pathParams = { id: '123', slug: 'test' };
const result = normalizePathParameters(pathParams);
expect(result).toEqual({ id: '123', slug: 'test' });
});
it('should join array parameters with commas', () => {
const pathParams = { ids: ['1', '2', '3'] };
const result = normalizePathParameters(pathParams);
expect(result).toEqual({ ids: '1,2,3' });
});
it('should skip undefined parameters', () => {
const pathParams = { id: '123', missing: undefined };
const result = normalizePathParameters(pathParams);
expect(result).toEqual({ id: '123' });
});
it('should handle empty object', () => {
const pathParams = {};
const result = normalizePathParameters(pathParams);
expect(result).toEqual({});
});
});
describe('buildServerlessFunctionEvent', () => {
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
({
headers: {},
query: {},
body: undefined,
method: 'GET',
path: '/test',
...overrides,
}) as Request;
it('should build a complete event from Express request', () => {
const request = createMockRequest({
headers: {
'content-type': 'application/json',
authorization: 'Bearer token',
'user-agent': 'test',
},
query: { page: '1' },
body: { data: 'test' },
method: 'POST',
path: '/s/users/123',
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: { id: '123' },
forwardedRequestHeaders: ['content-type', 'authorization'],
});
expect(result).toEqual({
headers: {
'content-type': 'application/json',
authorization: 'Bearer token',
},
queryStringParameters: { page: '1' },
pathParameters: { id: '123' },
body: { data: 'test' },
isBase64Encoded: false,
requestContext: {
http: {
method: 'POST',
path: '/s/users/123',
},
},
});
});
it('should preserve the request path as-is', () => {
const request = createMockRequest({
path: '/s/api/users',
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: {},
forwardedRequestHeaders: [],
});
expect(result.requestContext.http.path).toBe('/s/api/users');
});
it('should preserve path without prefix', () => {
const request = createMockRequest({
path: '/api/users',
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: {},
forwardedRequestHeaders: [],
});
expect(result.requestContext.http.path).toBe('/api/users');
});
it('should handle GET request with no body', () => {
const request = createMockRequest({
method: 'GET',
query: { search: 'test' },
body: undefined,
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: {},
forwardedRequestHeaders: [],
});
expect(result.body).toBeNull();
expect(result.queryStringParameters).toEqual({ search: 'test' });
});
it('should handle DELETE request with path parameters', () => {
const request = createMockRequest({
method: 'DELETE',
path: '/s/users/456',
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: { userId: '456' },
forwardedRequestHeaders: [],
});
expect(result.requestContext.http.method).toBe('DELETE');
expect(result.pathParameters).toEqual({ userId: '456' });
});
it('should filter only allowed headers', () => {
const request = createMockRequest({
headers: {
'content-type': 'application/json',
authorization: 'Bearer secret',
'x-api-key': 'key123',
cookie: 'session=abc',
},
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: {},
forwardedRequestHeaders: ['x-api-key'],
});
expect(result.headers).toEqual({
'x-api-key': 'key123',
});
expect(result.headers['authorization']).toBeUndefined();
expect(result.headers['cookie']).toBeUndefined();
});
it('should set isBase64Encoded to false', () => {
const request = createMockRequest();
const result = buildServerlessFunctionEvent({
request,
pathParameters: {},
forwardedRequestHeaders: [],
});
expect(result.isBase64Encoded).toBe(false);
});
it('should handle complex path parameters', () => {
const request = createMockRequest({
path: '/s/organizations/org1/users/user1/posts',
});
const result = buildServerlessFunctionEvent({
request,
pathParameters: {
orgId: 'org1',
userId: 'user1',
},
forwardedRequestHeaders: [],
});
expect(result.pathParameters).toEqual({
orgId: 'org1',
userId: 'user1',
});
});
});
@@ -0,0 +1,158 @@
import { type Request } from 'express';
import { type ServerlessFunctionEvent } from 'twenty-shared/types';
/**
* 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,
}: {
requestHeaders: Request['headers'];
forwardedRequestHeaders: string[];
}): Record<string, string | undefined> => {
const lowercaseForwardedHeaders = forwardedRequestHeaders.map((h) =>
h.toLowerCase(),
);
const filteredHeaders: Record<string, string | undefined> = {};
for (const headerName of lowercaseForwardedHeaders) {
const headerValue = requestHeaders[headerName];
if (headerValue !== undefined) {
// Convert string[] to comma-separated string (as per HTTP spec)
filteredHeaders[headerName] = Array.isArray(headerValue)
? headerValue.join(', ')
: headerValue;
}
}
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 extractBody = (request: Request): object | null => {
if (request.body === undefined || request.body === null) {
return null;
}
// If body is already an object (parsed JSON by body-parser), return as-is
if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
return request.body;
}
// If body is a string, try to parse as JSON
if (typeof request.body === 'string') {
try {
return JSON.parse(request.body);
} catch {
// If not valid JSON, wrap in an object
return { raw: request.body };
}
}
// If body is a Buffer, try to parse as JSON
if (Buffer.isBuffer(request.body)) {
try {
return JSON.parse(request.body.toString('utf-8'));
} catch {
return { raw: request.body.toString('utf-8') };
}
}
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> => {
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(query)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
// Join array values with commas
const stringValues = value.filter(
(v): v is string => typeof v === 'string',
);
normalized[key] = stringValues.join(',');
} else if (typeof value === 'string') {
normalized[key] = value;
} else if (typeof value === 'object') {
// Handle nested query objects (e.g., ?foo[bar]=baz)
// This is uncommon in REST APIs, convert to JSON string as fallback
normalized[key] = JSON.stringify(value);
}
}
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> => {
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(pathParams)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
normalized[key] = value.join(',');
} else {
normalized[key] = value;
}
}
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 buildServerlessFunctionEvent = ({
request,
pathParameters,
forwardedRequestHeaders,
}: {
request: Request;
pathParameters: Record<string, string | string[] | undefined>;
forwardedRequestHeaders: string[];
}): ServerlessFunctionEvent => {
return {
headers: filterRequestHeaders({
requestHeaders: request.headers,
forwardedRequestHeaders,
}),
queryStringParameters: normalizeQueryStringParameters(request.query),
pathParameters: normalizePathParameters(pathParameters),
body: extractBody(request),
isBase64Encoded: false,
requestContext: {
http: {
method: request.method,
path: request.path,
},
},
};
};
@@ -21,6 +21,8 @@ export const fromCreateRouteTriggerInputToFlatRouteTrigger = ({
path: createRouteTriggerInput.path,
isAuthRequired: createRouteTriggerInput.isAuthRequired,
httpMethod: createRouteTriggerInput.httpMethod,
forwardedRequestHeaders:
createRouteTriggerInput.forwardedRequestHeaders ?? [],
serverlessFunctionId: createRouteTriggerInput.serverlessFunctionId,
workspaceId,
createdAt: now.toISOString(),
@@ -29,5 +29,8 @@ export const fromUpdateRouteTriggerInputToFlatRouteTriggerToUpdateOrThrow = ({
path: updateRouteTriggerInput.update.path,
isAuthRequired: updateRouteTriggerInput.update.isAuthRequired,
httpMethod: updateRouteTriggerInput.update.httpMethod,
forwardedRequestHeaders:
updateRouteTriggerInput.update.forwardedRequestHeaders ??
existingFlatRouteTrigger.forwardedRequestHeaders,
};
};