fix(server): restrict /webhooks/server dispatch to server-route-exposed functions (#22469)

## What

`ServerRouteTriggerService.findResolver` resolved a logic function
purely by `universalIdentifier` and app-registration ownership, then
executed it before its resolver result shape was validated. As a result
the public `/webhooks/server/:universalIdentifier` route could dispatch
any owner-workspace app function — including ones exposed only as
authenticated HTTP routes, tools, or workflow actions — instead of only
functions declared as server-route resolvers.

## Change

`findResolver` now requires `serverRouteTriggerSettings`:
- DB predicate `serverRouteTriggerSettings: Not(IsNull())`, so
non-exposed functions are never fetched
- in-memory `isDefined(...)` guard alongside the existing
owner-workspace check

A function that did not opt into server-route exposure is now rejected
at `findResolver`, before any execution. A legitimately exposed resolver
is unaffected.

## Tests

- Unit (`server-route-trigger.service.spec.ts`): asserts the resolver
query carries the exposure predicate, and that an owner-workspace
function without `serverRouteTriggerSettings` is rejected and never
handed to the executor.
- Integration
(`server-route-trigger-authorization.integration-spec.ts`): exercises
the public endpoint end to end — a non-exposed owner-workspace function
is rejected before execution, while a server-route-exposed resolver
still passes the boundary.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22469?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-07-03 16:52:14 +02:00
committed by GitHub
parent 1270054d35
commit cdd667b106
6 changed files with 342 additions and 122 deletions
@@ -46,35 +46,59 @@ const buildRequest = (body: object | null = {}): Request =>
body,
}) as unknown as Request;
type QueryBuilderMock = {
innerJoinAndSelect: jest.Mock;
where: jest.Mock;
andWhere: jest.Mock;
getOne: jest.Mock;
};
const buildResolverRow = () => ({
id: 'resolver-id',
universalIdentifier: RESOLVER_UID,
workspaceId: 'owner-ws',
serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-test'] },
application: {
applicationRegistration: { id: 'reg-1', ownerWorkspaceId: 'owner-ws' },
},
});
describe('ServerRouteTriggerService', () => {
let service: ServerRouteTriggerService;
let logicFunctionRepository: jest.Mocked<
Pick<Repository<LogicFunctionEntity>, 'find' | 'findOne'>
>;
let logicFunctionRepository: {
createQueryBuilder: jest.Mock;
findOne: jest.Mock;
};
let logicFunctionExecutorService: jest.Mocked<
Pick<LogicFunctionExecutorService, 'execute'>
>;
let resolverRow: unknown;
let queryBuilder: QueryBuilderMock;
const buildQueryBuilderMock = (): QueryBuilderMock => {
const qb: QueryBuilderMock = {
innerJoinAndSelect: jest.fn(() => qb),
where: jest.fn(() => qb),
andWhere: jest.fn(() => qb),
getOne: jest.fn(() => Promise.resolve(resolverRow)),
};
return qb;
};
const handle = () =>
service.handle({
request: buildRequest(),
resolverLogicFunctionUniversalIdentifier: RESOLVER_UID,
});
const buildResolverRow = (overrides: Record<string, unknown> = {}) => ({
id: 'resolver-id',
universalIdentifier: RESOLVER_UID,
workspaceId: 'owner-ws',
serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-test'] },
application: {
applicationRegistration: { id: 'reg-1', ownerWorkspaceId: 'owner-ws' },
},
...overrides,
});
beforeEach(() => {
resolverRow = buildResolverRow();
queryBuilder = buildQueryBuilderMock();
logicFunctionRepository = {
find: jest.fn().mockResolvedValue([buildResolverRow()]),
createQueryBuilder: jest.fn(() => queryBuilder),
findOne: jest
.fn()
// resolver lookup inside runFunction
@@ -129,68 +153,56 @@ describe('ServerRouteTriggerService', () => {
);
});
it('throws LOGIC_FUNCTION_NOT_FOUND when no row matches the universalIdentifier', async () => {
logicFunctionRepository.find.mockResolvedValue([]);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('throws LOGIC_FUNCTION_NOT_FOUND when only non-owner-workspace copies exist', async () => {
logicFunctionRepository.find.mockResolvedValue([
buildResolverRow({
workspaceId: 'other-ws',
application: {
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
},
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('picks the owner-workspace copy when multiple workspaces installed the app', async () => {
logicFunctionRepository.find.mockResolvedValue([
buildResolverRow({
id: 'tenant-copy',
workspaceId: 'tenant-ws',
application: {
applicationRegistration: {
id: 'reg-1',
ownerWorkspaceId: 'owner-ws',
},
},
}),
buildResolverRow({
id: 'owner-copy',
workspaceId: 'owner-ws',
application: {
applicationRegistration: {
id: 'reg-1',
ownerWorkspaceId: 'owner-ws',
},
},
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
it('queries the resolver by universalIdentifier and joins the application registration chain', async () => {
await handle();
// runFunction's internal findOne is called with the
// (universalIdentifier, workspaceId) of the owner-workspace copy.
expect(logicFunctionRepository.findOne).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: expect.objectContaining({
universalIdentifier: RESOLVER_UID,
workspaceId: 'owner-ws',
}),
}),
expect(logicFunctionRepository.createQueryBuilder).toHaveBeenCalledWith(
'logicFunction',
);
expect(queryBuilder.innerJoinAndSelect).toHaveBeenCalledWith(
'logicFunction.application',
'application',
);
expect(queryBuilder.innerJoinAndSelect).toHaveBeenCalledWith(
'application.applicationRegistration',
'applicationRegistration',
);
expect(queryBuilder.where).toHaveBeenCalledWith(
'logicFunction.universalIdentifier = :universalIdentifier',
{ universalIdentifier: RESOLVER_UID },
);
});
it('restricts the resolver query to server-route-exposed, owner-workspace functions', async () => {
await handle();
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'logicFunction.serverRouteTriggerSettings IS NOT NULL',
);
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'logicFunction.workspaceId = applicationRegistration.ownerWorkspaceId',
);
});
it('throws LOGIC_FUNCTION_NOT_FOUND and executes nothing when the query returns no server-route resolver', async () => {
resolverRow = null;
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
expect(logicFunctionExecutorService.execute).not.toHaveBeenCalled();
});
it('rejects and executes nothing when the resolver requires authentication', async () => {
resolverRow = {
...buildResolverRow(),
httpRouteTriggerSettings: { isAuthRequired: true },
};
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.RESOLVER_REQUIRES_AUTHENTICATION,
});
expect(logicFunctionExecutorService.execute).not.toHaveBeenCalled();
});
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a workspaceId', async () => {
@@ -232,11 +244,7 @@ describe('ServerRouteTriggerService', () => {
logicFunctionRepository.findOne.mockReset();
logicFunctionRepository.findOne
// resolver lookup succeeds
.mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
id: 'resolver-id',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
.mockResolvedValueOnce({ id: 'resolver-id' })
// target lookup returns null
.mockResolvedValueOnce(null);
@@ -286,23 +294,6 @@ describe('ServerRouteTriggerService', () => {
});
});
it('looks up the resolver by universalIdentifier and loads the application registration chain', async () => {
await handle();
const findArgs = logicFunctionRepository.find.mock.calls[0][0];
expect(findArgs?.where).toEqual(
expect.objectContaining({ universalIdentifier: RESOLVER_UID }),
);
expect(findArgs?.relations).toEqual(
expect.objectContaining({
application: expect.objectContaining({
applicationRegistration: true,
}),
}),
);
});
it('maps a LogicFunctionExecutionException(RATE_LIMIT_EXCEEDED) to the server-route rate-limit code', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockRejectedValue(
@@ -332,21 +323,6 @@ describe('ServerRouteTriggerService', () => {
);
});
it('throws LOGIC_FUNCTION_NOT_FOUND when the resolver is not linked to an application registration', async () => {
logicFunctionRepository.find.mockResolvedValue([
buildResolverRow({
application: {
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
},
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('does not leak the raw executor error message to the caller', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute
@@ -63,6 +63,15 @@ export class ServerRouteTriggerRestApiExceptionFilter implements ExceptionFilter
undefined,
{ shouldBeCapturedBySentry: false },
);
case ServerRouteTriggerExceptionCode.RESOLVER_REQUIRES_AUTHENTICATION:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
403,
undefined,
undefined,
{ shouldBeCapturedBySentry: false },
);
default: {
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
@@ -10,6 +10,7 @@ export enum ServerRouteTriggerExceptionCode {
SERVER_ROUTE_USER_UNCAUGHT_ERROR = 'SERVER_ROUTE_USER_UNCAUGHT_ERROR',
SERVER_ROUTE_PLATFORM_ERROR = 'SERVER_ROUTE_PLATFORM_ERROR',
RESOLVER_INVALID_RESULT = 'RESOLVER_INVALID_RESULT',
RESOLVER_REQUIRES_AUTHENTICATION = 'RESOLVER_REQUIRES_AUTHENTICATION',
}
const getServerRouteTriggerExceptionUserFriendlyMessage = (
@@ -26,6 +27,8 @@ const getServerRouteTriggerExceptionUserFriendlyMessage = (
return msg`An unexpected error occurred while handling the server route.`;
case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT:
return msg`Resolver logic function returned an invalid result.`;
case ServerRouteTriggerExceptionCode.RESOLVER_REQUIRES_AUTHENTICATION:
return msg`Server logic function requires authentication.`;
default:
assertUnreachable(code);
}
@@ -57,6 +57,13 @@ export class ServerRouteTriggerService {
);
}
if (resolver.httpRouteTriggerSettings?.isAuthRequired === true) {
throw new ServerRouteTriggerException(
`Server resolver function ${resolverLogicFunctionUniversalIdentifier} requires authentication and cannot be dispatched through the public server route`,
ServerRouteTriggerExceptionCode.RESOLVER_REQUIRES_AUTHENTICATION,
);
}
const applicationRegistrationId =
resolver.application?.applicationRegistration?.id;
@@ -105,18 +112,22 @@ export class ServerRouteTriggerService {
}: {
logicFunctionUniversalIdentifier: string;
}): Promise<LogicFunctionEntity | null> {
const candidates = await this.logicFunctionRepository.find({
where: { universalIdentifier: logicFunctionUniversalIdentifier },
relations: { application: { applicationRegistration: true } },
});
return (
candidates.find(
(candidate) =>
isDefined(candidate.application?.applicationRegistration) &&
candidate.workspaceId ===
candidate.application.applicationRegistration.ownerWorkspaceId,
) ?? null
(await this.logicFunctionRepository
.createQueryBuilder('logicFunction')
.innerJoinAndSelect('logicFunction.application', 'application')
.innerJoinAndSelect(
'application.applicationRegistration',
'applicationRegistration',
)
.where('logicFunction.universalIdentifier = :universalIdentifier', {
universalIdentifier: logicFunctionUniversalIdentifier,
})
.andWhere('logicFunction.serverRouteTriggerSettings IS NOT NULL')
.andWhere(
'logicFunction.workspaceId = applicationRegistration.ownerWorkspaceId',
)
.getOne()) ?? null
);
}
@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ServerRouteTrigger authorization (integration) POST /webhooks/server/:universalIdentifier (public, unauthenticated) rejects a server-route-exposed resolver that requires authentication before executing it 1`] = `
{
"body": {
"code": "RESOLVER_REQUIRES_AUTHENTICATION",
"error": "Error",
"messages": [
"Server resolver function 3c3f983f-5c1a-4c60-a3c8-7d0e2a4a33c3 requires authentication and cannot be dispatched through the public server route",
],
"statusCode": 403,
},
"status": 403,
}
`;
exports[`ServerRouteTrigger authorization (integration) POST /webhooks/server/:universalIdentifier (public, unauthenticated) rejects an owner-workspace function without serverRouteTriggerSettings before executing it 1`] = `
{
"body": {
"code": "LOGIC_FUNCTION_NOT_FOUND",
"error": "Error",
"messages": [
"Server resolver function 1a1f983f-5c1a-4c60-a3c8-7d0e2a4a11a1 not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
@@ -0,0 +1,192 @@
import request from 'supertest';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import { expectOneNotInternalServerErrorHttpResponseSnapshot } from 'test/integration/utils/expect-one-not-internal-server-error-http-response-snapshot.util';
import { type LogicFunctionManifest } from 'twenty-shared/application';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const OWNER_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const APP_UNIVERSAL_IDENTIFIER = 'd41340eb-6cc9-4383-8b04-9be7dc794bb1';
const ROLE_UNIVERSAL_IDENTIFIER = 'e5b19f77-3e1c-4a10-9c2e-56d6b0f8a3d2';
const NON_EXPOSED_FUNCTION_UNIVERSAL_IDENTIFIER =
'1a1f983f-5c1a-4c60-a3c8-7d0e2a4a11a1';
const EXPOSED_RESOLVER_UNIVERSAL_IDENTIFIER =
'2b2f983f-5c1a-4c60-a3c8-7d0e2a4a22b2';
const AUTH_REQUIRED_RESOLVER_UNIVERSAL_IDENTIFIER =
'3c3f983f-5c1a-4c60-a3c8-7d0e2a4a33c3';
const TARGET_FUNCTION_UNIVERSAL_IDENTIFIER =
'4d4f983f-5c1a-4c60-a3c8-7d0e2a4a44d4';
const TARGET_FUNCTION_RESPONSE = { greeting: 'hello from target function' };
// Built (ESM) handler code executed by the logic function driver. The resolver
// routes the public request to the target function in the owner workspace.
const RESOLVER_BUILT_HANDLER_CODE = `export const main = async () => ({
workspaceId: '${OWNER_WORKSPACE_ID}',
targetLogicFunctionUniversalIdentifier: '${TARGET_FUNCTION_UNIVERSAL_IDENTIFIER}',
});
`;
const TARGET_BUILT_HANDLER_CODE = `export const main = async () => (${JSON.stringify(
TARGET_FUNCTION_RESPONSE,
)});
`;
const buildLogicFunctionManifest = ({
universalIdentifier,
name,
serverRouteExposed,
authRequired,
}: {
universalIdentifier: string;
name: string;
serverRouteExposed: boolean;
authRequired: boolean;
}): LogicFunctionManifest => ({
universalIdentifier,
name,
handlerName: 'main',
sourceHandlerPath: `src/${name}.ts`,
builtHandlerPath: `dist/${name}.mjs`,
builtHandlerChecksum: `checksum-${name}`,
...(authRequired
? {
httpRouteTriggerSettings: {
path: `/${name}`,
httpMethod: 'POST',
isAuthRequired: true,
},
}
: {}),
...(serverRouteExposed
? { serverRouteTriggerSettings: { forwardedRequestHeaders: [] } }
: {}),
});
const uploadBuiltHandlerFile = async ({
builtHandlerPath,
builtHandlerCode,
}: {
builtHandlerPath: string;
builtHandlerCode: string;
}) => {
jest.useRealTimers();
await uploadApplicationFile({
applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER,
fileFolder: 'BuiltLogicFunction',
filePath: builtHandlerPath,
fileBuffer: Buffer.from(builtHandlerCode),
filename: builtHandlerPath.split('/').pop() as string,
contentType: 'application/javascript',
expectToFail: false,
});
jest.useFakeTimers();
};
describe('ServerRouteTrigger authorization (integration)', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER,
name: 'Server Route Auth Test App',
description: 'App for testing server route trigger authorization',
sourcePath: 'server-route-auth-test-app',
});
await uploadBuiltHandlerFile({
builtHandlerPath: 'dist/exposed-resolver.mjs',
builtHandlerCode: RESOLVER_BUILT_HANDLER_CODE,
});
await uploadBuiltHandlerFile({
builtHandlerPath: 'dist/target-function.mjs',
builtHandlerCode: TARGET_BUILT_HANDLER_CODE,
});
await syncApplication({
manifest: buildBaseManifest({
appId: APP_UNIVERSAL_IDENTIFIER,
roleId: ROLE_UNIVERSAL_IDENTIFIER,
overrides: {
logicFunctions: [
buildLogicFunctionManifest({
universalIdentifier: NON_EXPOSED_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'non-exposed-function',
serverRouteExposed: false,
authRequired: true,
}),
buildLogicFunctionManifest({
universalIdentifier: EXPOSED_RESOLVER_UNIVERSAL_IDENTIFIER,
name: 'exposed-resolver',
serverRouteExposed: true,
authRequired: false,
}),
buildLogicFunctionManifest({
universalIdentifier: AUTH_REQUIRED_RESOLVER_UNIVERSAL_IDENTIFIER,
name: 'auth-required-resolver',
serverRouteExposed: true,
authRequired: true,
}),
buildLogicFunctionManifest({
universalIdentifier: TARGET_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'target-function',
serverRouteExposed: false,
authRequired: false,
}),
],
},
}),
expectToFail: false,
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER,
});
}, 60000);
describe('POST /webhooks/server/:universalIdentifier (public, unauthenticated)', () => {
it('rejects an owner-workspace function without serverRouteTriggerSettings before executing it', async () => {
const response = await request(baseUrl)
.post(`/webhooks/server/${NON_EXPOSED_FUNCTION_UNIVERSAL_IDENTIFIER}`)
.send({ any: 'payload' });
expect(response.status).toBe(404);
expectOneNotInternalServerErrorHttpResponseSnapshot({
status: response.status,
body: response.body,
});
});
it('dispatches a server-route-exposed resolver and returns the target function response', async () => {
const response = await request(baseUrl)
.post(`/webhooks/server/${EXPOSED_RESOLVER_UNIVERSAL_IDENTIFIER}`)
.send({ any: 'payload' });
expect(response.status).toBe(200);
expect(response.body).toEqual(TARGET_FUNCTION_RESPONSE);
}, 60000);
it('rejects a server-route-exposed resolver that requires authentication before executing it', async () => {
const response = await request(baseUrl)
.post(`/webhooks/server/${AUTH_REQUIRED_RESOLVER_UNIVERSAL_IDENTIFIER}`)
.send({ any: 'payload' });
expect(response.status).toBe(403);
expectOneNotInternalServerErrorHttpResponseSnapshot({
status: response.status,
body: response.body,
});
});
});
});