From cdd78462b90ef370e8c8863100687e3bd107da6a Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 27 Jul 2026 12:25:34 +0200 Subject: [PATCH] fix: block route triggers for suspended workspaces (#23347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Logic function route triggers (app HTTP endpoints served under `/s/*` and public domains) kept serving traffic for suspended workspaces. A workspace suspended for non-payment (`activationStatus = SUSPENDED`) still served its route triggers for the entire suspension window until soft-deletion removed it from domain lookup. Every other trigger path gates on activation status — cron triggers only process `ACTIVE` workspaces — but the route trigger path had no check at all. ## Fix `RouteTriggerService.getLogicFunctionWithPathParamsOrFail` now rejects requests when the resolved workspace has `activationStatus = SUSPENDED`, throwing a `RouteTriggerException` with a new `WORKSPACE_SUSPENDED` code mapped to `403 Forbidden` in the REST exception filter. Scope is intentionally limited to `SUSPENDED`; other non-active statuses and the DB event trigger path are untouched. Review in cubic --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../__tests__/route-trigger.service.spec.ts | 82 ++++++++- ...-trigger-rest-api-exception-filter.spec.ts | 11 ++ ...route-trigger-rest-api-exception-filter.ts | 1 + .../exceptions/route-trigger.exception.ts | 3 + .../triggers/route/route-trigger.service.ts | 8 + ...spended-workspace.integration-spec.ts.snap | 15 ++ ...er-suspended-workspace.integration-spec.ts | 159 ++++++++++++++++++ 7 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/test/integration/route-trigger/suites/__snapshots__/route-trigger-suspended-workspace.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts index d1908d114a..7a89c39c5e 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts @@ -1,6 +1,19 @@ -import { LOGIC_FUNCTION_HTTP_RESPONSE_MARKER } from 'twenty-shared/types'; +import { type Request } from 'express'; +import { + HTTPMethod, + LOGIC_FUNCTION_HTTP_RESPONSE_MARKER, +} from 'twenty-shared/types'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { type Repository } from 'typeorm'; +import { type AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { type WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { type LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service'; +import { RouteTriggerExceptionCode } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception'; +import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service'; import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; describe('buildRouteTriggerResponse', () => { it('wraps a plain body with status 200 and no headers', () => { @@ -47,3 +60,70 @@ describe('buildRouteTriggerResponse', () => { }); }); }); + +describe('RouteTriggerService', () => { + const resolveWorkspaceAndPublicDomain = jest.fn(); + const find = jest.fn(); + + const service = new RouteTriggerService( + {} as unknown as AccessTokenService, + {} as unknown as LogicFunctionTriggerService, + { resolveWorkspaceAndPublicDomain } as unknown as WorkspaceDomainsService, + { get: jest.fn() } as unknown as TwentyConfigService, + { find } as unknown as Repository, + ); + + const request = { + protocol: 'https', + get: () => 'acme.twenty.com', + path: '/s/webhook', + } as unknown as Request; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should reject requests when the workspace is suspended', async () => { + resolveWorkspaceAndPublicDomain.mockResolvedValue({ + workspace: { + id: 'workspace-id', + activationStatus: WorkspaceActivationStatus.SUSPENDED, + }, + publicDomain: null, + isIsolatedOrigin: false, + }); + + await expect( + service.handle({ request, httpMethod: HTTPMethod.GET }), + ).rejects.toMatchObject({ + code: RouteTriggerExceptionCode.WORKSPACE_SUSPENDED, + }); + + expect(find).not.toHaveBeenCalled(); + }); + + it('should resolve route triggers when the workspace is active', async () => { + resolveWorkspaceAndPublicDomain.mockResolvedValue({ + workspace: { + id: 'workspace-id', + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + publicDomain: null, + isIsolatedOrigin: false, + }); + find.mockResolvedValue([]); + + await expect( + service.handle({ request, httpMethod: HTTPMethod.GET }), + ).rejects.toMatchObject({ + code: RouteTriggerExceptionCode.TRIGGER_NOT_FOUND, + }); + + expect(find).toHaveBeenCalledWith({ + where: { + workspaceId: 'workspace-id', + httpRouteTriggerSettings: expect.anything(), + }, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts index 9b1ea409a7..5735ff2293 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts @@ -69,6 +69,17 @@ describe('RouteTriggerRestApiExceptionFilter', () => { expect(handleError).toHaveBeenCalledWith(exception, response, 403); }); + it('maps a suspended workspace to 403', () => { + const exception = new RouteTriggerException( + 'suspended', + RouteTriggerExceptionCode.WORKSPACE_SUSPENDED, + ); + + filter.catch(exception, host); + + expect(handleError).toHaveBeenCalledWith(exception, response, 403); + }); + it('maps not-found codes to 404', () => { const exception = new RouteTriggerException( 'missing', diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts index a5d20d264b..e1446d42d3 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts @@ -34,6 +34,7 @@ export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter { 404, ); case RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION: + case RouteTriggerExceptionCode.WORKSPACE_SUSPENDED: return this.httpExceptionHandlerService.handleError( exception as CustomException, response, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts index 091517154d..e469aea4f1 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts @@ -6,6 +6,7 @@ import { CustomException } from 'src/utils/custom-exception'; export enum RouteTriggerExceptionCode { WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', + WORKSPACE_SUSPENDED = 'WORKSPACE_SUSPENDED', ROUTE_NOT_FOUND = 'ROUTE_NOT_FOUND', TRIGGER_NOT_FOUND = 'TRIGGER_NOT_FOUND', LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND', @@ -24,6 +25,8 @@ const getRouteTriggerExceptionUserFriendlyMessage = ( switch (code) { case RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND: return msg`Workspace not found.`; + case RouteTriggerExceptionCode.WORKSPACE_SUSPENDED: + return msg`Workspace is suspended.`; case RouteTriggerExceptionCode.ROUTE_NOT_FOUND: return msg`Route not found.`; case RouteTriggerExceptionCode.TRIGGER_NOT_FOUND: diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index ab54cabd5f..639f25b375 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -8,6 +8,7 @@ import { match } from 'path-to-regexp'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { IsNull, Not, Repository } from 'typeorm'; import { HTTPMethod } from 'twenty-shared/types'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; @@ -67,6 +68,13 @@ export class RouteTriggerService { ), ); + if (workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED) { + throw new RouteTriggerException( + 'Workspace is suspended', + RouteTriggerExceptionCode.WORKSPACE_SUSPENDED, + ); + } + // App-scoped public domain → restrict matches to that app's logic functions. const applicationId = publicDomain?.applicationId ?? null; diff --git a/packages/twenty-server/test/integration/route-trigger/suites/__snapshots__/route-trigger-suspended-workspace.integration-spec.ts.snap b/packages/twenty-server/test/integration/route-trigger/suites/__snapshots__/route-trigger-suspended-workspace.integration-spec.ts.snap new file mode 100644 index 0000000000..fdde5f1d0f --- /dev/null +++ b/packages/twenty-server/test/integration/route-trigger/suites/__snapshots__/route-trigger-suspended-workspace.integration-spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RouteTrigger suspended workspace (integration) GET /s/suspended-workspace-route rejects the route trigger with 403 once the workspace is suspended 1`] = ` +{ + "body": { + "code": "WORKSPACE_SUSPENDED", + "error": "Error", + "messages": [ + "Workspace is suspended", + ], + "statusCode": 403, + }, + "status": 403, +} +`; diff --git a/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts b/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts new file mode 100644 index 0000000000..062062bc75 --- /dev/null +++ b/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts @@ -0,0 +1,159 @@ +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 { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; +import { type LogicFunctionManifest } from 'twenty-shared/application'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; + +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const APP_UNIVERSAL_IDENTIFIER = '6a6f983f-5c1a-4c60-a3c8-7d0e2a4a66a6'; +const ROLE_UNIVERSAL_IDENTIFIER = '7b7f983f-5c1a-4c60-a3c8-7d0e2a4a77b7'; +const ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER = + '8c8f983f-5c1a-4c60-a3c8-7d0e2a4a88c8'; + +const ROUTE_FUNCTION_RESPONSE = { greeting: 'hello from route function' }; + +const UNSAFE_JS_CHAR_MAP: Record = { + '<': '\\u003C', + '>': '\\u003E', + '/': '\\u002F', + '\\': '\\\\', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\0': '\\0', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +const escapeUnsafeChars = (value: string): string => + value.replace(/[<>/\\\b\f\n\r\t\0\u2028\u2029]/g, (char) => { + return UNSAFE_JS_CHAR_MAP[char] ?? char; + }); + +const ROUTE_BUILT_HANDLER_CODE = `export const main = async () => (${escapeUnsafeChars( + JSON.stringify(ROUTE_FUNCTION_RESPONSE), +)}); +`; + +const routeFunctionManifest: LogicFunctionManifest = { + universalIdentifier: ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'suspended-workspace-route', + handlerName: 'main', + sourceHandlerPath: 'src/suspended-workspace-route.ts', + builtHandlerPath: 'dist/suspended-workspace-route.mjs', + builtHandlerChecksum: 'checksum-suspended-workspace-route', + httpRouteTriggerSettings: { + path: '/suspended-workspace-route', + httpMethod: 'GET', + isAuthRequired: false, + }, +}; + +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('RouteTrigger suspended workspace (integration)', () => { + const baseUrl = `http://localhost:${APP_PORT}`; + const workspaceHost = `apple.localhost:${APP_PORT}`; + + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER, + name: 'Route Trigger Suspended Workspace Test App', + description: 'App for testing route triggers on a suspended workspace', + sourcePath: 'route-trigger-suspended-workspace-test-app', + }); + + await uploadBuiltHandlerFile({ + builtHandlerPath: 'dist/suspended-workspace-route.mjs', + builtHandlerCode: ROUTE_BUILT_HANDLER_CODE, + }); + + await syncApplication({ + manifest: buildBaseManifest({ + appId: APP_UNIVERSAL_IDENTIFIER, + roleId: ROLE_UNIVERSAL_IDENTIFIER, + overrides: { + logicFunctions: [routeFunctionManifest], + }, + }), + expectToFail: false, + }); + + jest.useRealTimers(); + }, 60000); + + afterAll(async () => { + await getCoreRepository(WorkspaceEntity).update( + SEED_APPLE_WORKSPACE_ID, + { + activationStatus: WorkspaceActivationStatus.ACTIVE, + suspendedAt: null, + }, + ); + + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER, + }); + }, 60000); + + describe('GET /s/suspended-workspace-route', () => { + it('serves the route trigger while the workspace is active', async () => { + const response = await request(baseUrl) + .get('/s/suspended-workspace-route') + .set('Host', workspaceHost); + + expect(response.status).toBe(200); + expect(response.body).toEqual(ROUTE_FUNCTION_RESPONSE); + }, 60000); + + it('rejects the route trigger with 403 once the workspace is suspended', async () => { + await getCoreRepository(WorkspaceEntity).update( + SEED_APPLE_WORKSPACE_ID, + { + activationStatus: WorkspaceActivationStatus.SUSPENDED, + suspendedAt: new Date(), + }, + ); + + const response = await request(baseUrl) + .get('/s/suspended-workspace-route') + .set('Host', workspaceHost); + + expect(response.status).toBe(403); + expect(response.body.code).toBe('WORKSPACE_SUSPENDED'); + expectOneNotInternalServerErrorHttpResponseSnapshot({ + status: response.status, + body: response.body, + }); + }, 60000); + }); +});