From d7132b35d343b4cc53079920f2893a92d1e3fcec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 8 Feb 2026 19:52:35 +0100 Subject: [PATCH] Centralize outbound HTTP requests through SecureHttpClientService (#17779) ## Summary - Migrates all direct `axios` and `@nestjs/axios` `HttpService` usages across the server to go through `SecureHttpClientService`, which conditionally applies SSRF protection based on the `OUTBOUND_HTTP_SAFE_MODE_ENABLED` config flag - `SecureHttpClientService.getHttpClient()` now accepts optional `AxiosRequestConfig` (e.g., `baseURL`) so callers can configure their client while still getting protection - Adds `getInternalHttpClient()` for trusted same-server requests (e.g., REST-to-GraphQL proxy, code-interpreter downloading internal files) - Renames `getSecureAdapter` to `getSecureAxiosAdapter` for clarity - Captcha drivers now receive a pre-configured `AxiosInstance` from the module factory instead of creating their own ## Migrated services | Service | Previous | Risk level | |---------|----------|-----------| | `file-upload.service` | `HttpService` | High (user-provided image URLs) | | `code-interpreter-tool` | `HttpService` + direct adapter | High (user-provided file URLs) | | `search-help-center-tool` | `axios.post()` | Low (hardcoded endpoints) | | `http-tool` | Already migrated | High (user-provided URLs) | | `admin-panel.service` | `axios.get()` | Low (Docker Hub API) | | `sign-in-up.service` | `HttpService` | Medium (logo URL validation) | | `google-apis-scopes` | `HttpService` | Low (Google API) | | `geo-map.service` | `HttpService` | Low (Google Maps API) | | `telemetry.service` | `HttpService` | Low (telemetry endpoint) | | `rest-api.service` | `HttpService` | Internal (uses `getInternalHttpClient`) | | `create-company.service` | `axios.create()` | Low (Twenty companies API) | | `google-recaptcha.driver` | `axios.create()` | Low (Google reCAPTCHA) | | `turnstile.driver` | `axios.create()` | Low (Cloudflare Turnstile) | ## Test plan - [x] `npx nx typecheck twenty-server` passes - [x] `npx nx lint:diff-with-main twenty-server` passes - [x] Admin panel unit tests pass - [x] Secure adapter unit tests pass Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- .../api/rest/core/rest-api-core.module.ts | 4 +- .../src/engine/api/rest/rest-api.module.ts | 5 +- .../src/engine/api/rest/rest-api.service.ts | 11 ++-- .../__tests__/admin-panel.service.spec.ts | 25 ++++++---- .../admin-panel/admin-panel.module.ts | 2 + .../admin-panel/admin-panel.service.ts | 7 ++- .../engine/core-modules/auth/auth.module.ts | 12 ++--- .../auth/services/google-apis-scopes.ts | 10 ++-- .../auth/services/sign-in-up.service.ts | 12 ++--- .../core-modules/captcha/captcha.module.ts | 27 +++++++--- .../drivers/google-recaptcha.driver.ts | 11 ++-- .../captcha/drivers/turnstile.driver.ts | 11 ++-- .../file/file-upload/file-upload.module.ts | 6 +-- .../services/file-upload.service.ts | 11 ++-- .../engine/core-modules/file/file.module.ts | 4 +- .../core-modules/geo-map/geo-map-module.ts | 6 +-- .../geo-map/services/geo-map.service.ts | 12 +++-- .../telemetry/telemetry.module.ts | 10 ++-- .../telemetry/telemetry.service.ts | 10 ++-- .../services/secure-http-client.service.ts | 17 +++++-- .../engine/core-modules/tool/tool.module.ts | 2 - .../code-interpreter-tool.ts | 16 +++--- .../tool/tools/http-tool/http-tool.ts | 4 +- .../search-help-center-tool.ts | 14 ++++-- .../get-secure-axios-adapter.util.spec.ts | 50 +++++++++---------- .../utils/get-secure-axios-adapter.util.ts | 2 +- .../contact-creation-manager.module.ts | 2 + .../__tests__/create-company.service.spec.ts | 12 +++-- .../services/create-company.service.ts | 6 ++- 29 files changed, 187 insertions(+), 134 deletions(-) diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts index 5fba799576..8853686b5e 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts @@ -1,4 +1,3 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module'; @@ -22,6 +21,7 @@ import { restToCommonArgsHandlers } from 'src/engine/api/rest/core/rest-to-commo import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service'; import { RestApiService } from 'src/engine/api/rest/rest-api.service'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; @@ -58,7 +58,6 @@ const restApiCoreResolvers = [ AuthModule, ApiKeyModule, UserRoleModule, - HttpModule, TwentyORMModule, RecordTransformerModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, @@ -72,6 +71,7 @@ const restApiCoreResolvers = [ providers: [ RestApiService, RestApiCoreService, + SecureHttpClientService, ...restApiCoreResolvers, ...restToCommonArgsHandlers, ], diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts index da85d9f834..08123785a5 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts @@ -1,9 +1,9 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module'; import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { RestApiCoreModule } from 'src/engine/api/rest/core/rest-api-core.module'; import { RestApiService } from 'src/engine/api/rest/rest-api.service'; @@ -15,12 +15,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi MetadataQueryBuilderModule, WorkspaceCacheStorageModule, AuthModule, - HttpModule, RestApiCoreModule, PermissionsModule, ], controllers: [RestApiMetadataController], - providers: [RestApiService, RestApiMetadataService], + providers: [RestApiService, RestApiMetadataService, SecureHttpClientService], exports: [RestApiMetadataService, RestApiService], }) export class RestApiModule {} diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.service.ts b/packages/twenty-server/src/engine/api/rest/rest-api.service.ts index 4d2895beed..7cac7a61b9 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.service.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.service.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { type AxiosResponse } from 'axios'; @@ -6,6 +5,7 @@ import { type AxiosResponse } from 'axios'; import { type Query } from 'src/engine/api/rest/core/types/query.type'; import { RestApiException } from 'src/engine/api/rest/errors/RestApiException'; import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; export enum GraphqlApiType { CORE = 'core', @@ -14,7 +14,9 @@ export enum GraphqlApiType { @Injectable() export class RestApiService { - constructor(private readonly httpService: HttpService) {} + constructor( + private readonly secureHttpClientService: SecureHttpClientService, + ) {} async call( graphqlApiType: GraphqlApiType, @@ -28,8 +30,11 @@ export class RestApiService { : GraphqlApiType.METADATA }`; + // Internal request to the server's own GraphQL endpoint + const httpClient = this.secureHttpClientService.getInternalHttpClient(); + try { - response = await this.httpService.axiosRef.post(url, data, { + response = await httpClient.post(url, data, { headers: { 'Content-Type': 'application/json', Authorization: requestContext.headers.authorization, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts index e03f16ee73..11a029ff86 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.service.spec.ts @@ -1,13 +1,12 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import axios from 'axios'; - import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -15,6 +14,8 @@ const UserFindOneMock = jest.fn(); const LoginTokenServiceGenerateLoginTokenMock = jest.fn(); const TwentyConfigServiceGetAllMock = jest.fn(); const TwentyConfigServiceGetVariableWithMetadataMock = jest.fn(); +const mockHttpClientGet = jest.fn(); +const mockGetHttpClient = jest.fn().mockReturnValue({ get: mockHttpClientGet }); jest.mock( 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata', @@ -87,6 +88,12 @@ describe('AdminPanelService', () => { provide: FileService, useValue: {}, }, + { + provide: SecureHttpClientService, + useValue: { + getHttpClient: mockGetHttpClient, + }, + }, ], }).compile(); @@ -256,18 +263,16 @@ describe('AdminPanelService', () => { describe('getVersionInfo', () => { const mockEnvironmentGet = jest.fn(); - const mockAxiosGet = jest.fn(); beforeEach(() => { mockEnvironmentGet.mockReset(); - mockAxiosGet.mockReset(); - jest.spyOn(axios, 'get').mockImplementation(mockAxiosGet); + mockHttpClientGet.mockReset(); service['twentyConfigService'].get = mockEnvironmentGet; }); it('should return current and latest version when everything works', async () => { mockEnvironmentGet.mockReturnValue('1.0.0'); - mockAxiosGet.mockResolvedValue({ + mockHttpClientGet.mockResolvedValue({ data: { results: [ { name: '2.0.0' }, @@ -288,7 +293,7 @@ describe('AdminPanelService', () => { it('should handle undefined APP_VERSION', async () => { mockEnvironmentGet.mockReturnValue(undefined); - mockAxiosGet.mockResolvedValue({ + mockHttpClientGet.mockResolvedValue({ data: { results: [{ name: '2.0.0' }, { name: 'latest' }], }, @@ -304,7 +309,7 @@ describe('AdminPanelService', () => { it('should handle Docker Hub API error', async () => { mockEnvironmentGet.mockReturnValue('1.0.0'); - mockAxiosGet.mockRejectedValue(new Error('API Error')); + mockHttpClientGet.mockRejectedValue(new Error('API Error')); const result = await service.getVersionInfo(); @@ -316,7 +321,7 @@ describe('AdminPanelService', () => { it('should handle empty Docker Hub tags', async () => { mockEnvironmentGet.mockReturnValue('1.0.0'); - mockAxiosGet.mockResolvedValue({ + mockHttpClientGet.mockResolvedValue({ data: { results: [], }, @@ -332,7 +337,7 @@ describe('AdminPanelService', () => { it('should handle invalid semver tags', async () => { mockEnvironmentGet.mockReturnValue('1.0.0'); - mockAxiosGet.mockResolvedValue({ + mockHttpClientGet.mockResolvedValue({ data: { results: [ { name: '2.0.0' }, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts index 5505fa2114..fdf9299082 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -7,6 +7,7 @@ import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admi import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver'; import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -38,6 +39,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi AdminPanelService, AdminPanelHealthService, AdminPanelQueueService, + SecureHttpClientService, ], exports: [AdminPanelService], }) diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts index fdbbb11b33..0c5bec1e60 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; -import axios from 'axios'; import semver from 'semver'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; @@ -21,6 +20,7 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { type FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { CONFIG_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata'; import { type ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; @@ -34,6 +34,7 @@ export class AdminPanelService { private readonly twentyConfigService: TwentyConfigService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly fileService: FileService, + private readonly secureHttpClientService: SecureHttpClientService, @InjectRepository(UserEntity) private readonly userRepository: Repository, ) {} @@ -185,7 +186,9 @@ export class AdminPanelService { const currentVersion = this.twentyConfigService.get('APP_VERSION'); try { - const rawResponse = await axios.get( + const httpClient = this.secureHttpClientService.getHttpClient(); + + const rawResponse = await httpClient.get( 'https://hub.docker.com/v2/repositories/twentycrm/twenty/tags?page_size=100', ); const response = z diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 2bcc9498f8..e69f375ef8 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-restricted-imports */ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -8,6 +6,7 @@ import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; @@ -45,6 +44,9 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; +import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; +import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -63,10 +65,6 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; - -import { TwoFactorAuthenticationMethodEntity } from '../two-factor-authentication/entities/two-factor-authentication-method.entity'; -import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module'; import { AuthResolver } from './auth.resolver'; @@ -96,7 +94,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; TwoFactorAuthenticationMethodEntity, ObjectMetadataEntity, ]), - HttpModule, UserWorkspaceModule, WorkspaceModule, OnboardingModule, @@ -151,6 +148,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; UpdateConnectedAccountOnReconnectService, TransientTokenService, AuthSsoService, + SecureHttpClientService, ], exports: [ AccessTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts index 7051054628..7e779e1ff5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis-scopes.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { @@ -7,6 +6,7 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { includesExpectedScopes } from 'src/engine/core-modules/auth/services/google-apis-scopes.service.util'; import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; interface TokenInfoResponse { scope: string; @@ -24,13 +24,17 @@ interface TokenInfoResponse { @Injectable() export class GoogleAPIScopesService { - constructor(private httpService: HttpService) {} + constructor( + private readonly secureHttpClientService: SecureHttpClientService, + ) {} public async getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent( accessToken: string, ): Promise<{ scopes: string[]; isValid: boolean }> { try { - const response = await this.httpService.axiosRef.get( + const httpClient = this.secureHttpClientService.getHttpClient(); + + const response = await httpClient.get( `https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`, { timeout: 600 }, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 794e6a8174..34139ee48c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -31,6 +30,7 @@ import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomai import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; @@ -55,7 +55,7 @@ export class SignInUpService { private readonly userWorkspaceService: UserWorkspaceService, private readonly onboardingService: OnboardingService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, - private readonly httpService: HttpService, + private readonly secureHttpClientService: SecureHttpClientService, private readonly twentyConfigService: TwentyConfigService, private readonly subdomainManagerService: SubdomainManagerService, private readonly userService: UserService, @@ -471,10 +471,10 @@ export class SignInUpService { const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; const isLogoUrlValid = async () => { try { - return ( - (await this.httpService.axiosRef.get(logoUrl, { timeout: 600 })) - .status === 200 - ); + const httpClient = this.secureHttpClientService.getHttpClient(); + const response = await httpClient.get(logoUrl, { timeout: 600 }); + + return response.status === 200; } catch { return false; } diff --git a/packages/twenty-server/src/engine/core-modules/captcha/captcha.module.ts b/packages/twenty-server/src/engine/core-modules/captcha/captcha.module.ts index a3c4c26bbb..ba480d3e35 100644 --- a/packages/twenty-server/src/engine/core-modules/captcha/captcha.module.ts +++ b/packages/twenty-server/src/engine/core-modules/captcha/captcha.module.ts @@ -8,14 +8,18 @@ import { CaptchaDriverType, type CaptchaModuleAsyncOptions, } from 'src/engine/core-modules/captcha/interfaces'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; @Global() export class CaptchaModule { static forRoot(options: CaptchaModuleAsyncOptions): DynamicModule { const provider = { provide: CAPTCHA_DRIVER, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - useFactory: async (...args: any[]) => { + useFactory: async ( + secureHttpClientService: SecureHttpClientService, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] + ) => { const config = await options.useFactory(...args); if (!config) { @@ -24,19 +28,30 @@ export class CaptchaModule { switch (config.type) { case CaptchaDriverType.GOOGLE_RECAPTCHA: - return new GoogleRecaptchaDriver(config.options); + return new GoogleRecaptchaDriver( + config.options, + secureHttpClientService.getHttpClient({ + baseURL: 'https://www.google.com/recaptcha/api/siteverify', + }), + ); case CaptchaDriverType.TURNSTILE: - return new TurnstileDriver(config.options); + return new TurnstileDriver( + config.options, + secureHttpClientService.getHttpClient({ + baseURL: + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + }), + ); default: return; } }, - inject: options.inject || [], + inject: [SecureHttpClientService, ...(options.inject || [])], }; return { module: CaptchaModule, - providers: [CaptchaService, provider], + providers: [CaptchaService, SecureHttpClientService, provider], exports: [CaptchaService], }; } diff --git a/packages/twenty-server/src/engine/core-modules/captcha/drivers/google-recaptcha.driver.ts b/packages/twenty-server/src/engine/core-modules/captcha/drivers/google-recaptcha.driver.ts index 396b8e7da2..25fede4874 100644 --- a/packages/twenty-server/src/engine/core-modules/captcha/drivers/google-recaptcha.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/captcha/drivers/google-recaptcha.driver.ts @@ -1,4 +1,4 @@ -import axios, { type AxiosInstance } from 'axios'; +import { type AxiosInstance } from 'axios'; import { type CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface'; import { type CaptchaServerResponse } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-server-response'; @@ -12,12 +12,13 @@ export class GoogleRecaptchaDriver implements CaptchaDriver { private readonly _siteKey: string; private readonly secretKey: string; private readonly httpService: AxiosInstance; - constructor(private _options: CaptchaDriverOptions) { + constructor( + private _options: CaptchaDriverOptions, + httpClient: AxiosInstance, + ) { this._siteKey = _options.siteKey; this.secretKey = _options.secretKey; - this.httpService = axios.create({ - baseURL: 'https://www.google.com/recaptcha/api/siteverify', - }); + this.httpService = httpClient; } async validate(token: string): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/captcha/drivers/turnstile.driver.ts b/packages/twenty-server/src/engine/core-modules/captcha/drivers/turnstile.driver.ts index fb65844c6d..27d9376c07 100644 --- a/packages/twenty-server/src/engine/core-modules/captcha/drivers/turnstile.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/captcha/drivers/turnstile.driver.ts @@ -1,4 +1,4 @@ -import axios, { type AxiosInstance } from 'axios'; +import { type AxiosInstance } from 'axios'; import { type CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface'; import { type CaptchaServerResponse } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-server-response'; @@ -12,12 +12,13 @@ export class TurnstileDriver implements CaptchaDriver { private readonly _siteKey: string; private readonly secretKey: string; private readonly httpService: AxiosInstance; - constructor(private _options: CaptchaDriverOptions) { + constructor( + private _options: CaptchaDriverOptions, + httpClient: AxiosInstance, + ) { this._siteKey = _options.siteKey; this.secretKey = _options.secretKey; - this.httpService = axios.create({ - baseURL: 'https://challenges.cloudflare.com/turnstile/v0/siteverify', - }); + this.httpService = httpClient; } async validate(token: string): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts index 1da6e3c49d..9661fc1c14 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts @@ -1,14 +1,14 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ - imports: [FileModule, HttpModule, PermissionsModule], - providers: [FileUploadService, FileUploadResolver], + imports: [FileModule, PermissionsModule], + providers: [FileUploadService, FileUploadResolver, SecureHttpClientService], exports: [FileUploadService, FileUploadResolver], }) export class FileUploadModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index ae4f283f92..94cdb48e3f 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import FileType from 'file-type'; @@ -11,6 +10,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils'; import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { getCropSize, getImageBufferFromUrl } from 'src/utils/image'; export type SignedFile = { path: string; token: string }; @@ -26,7 +26,7 @@ export class FileUploadService { constructor( private readonly fileStorage: FileStorageService, private readonly fileService: FileService, - private readonly httpService: HttpService, + private readonly secureHttpClientService: SecureHttpClientService, ) {} private async _uploadFile({ @@ -95,10 +95,9 @@ export class FileUploadService { fileFolder: FileFolder; workspaceId: string; }) { - const buffer = await getImageBufferFromUrl( - imageUrl, - this.httpService.axiosRef, - ); + const httpClient = this.secureHttpClientService.getHttpClient(); + + const buffer = await getImageBufferFromUrl(imageUrl, httpClient); const type = await FileType.fromBuffer(buffer); diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts index 5584e3ce45..e50ba8311b 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts @@ -1,4 +1,3 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -10,6 +9,7 @@ import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/job import { FileAttachmentListener } from 'src/engine/core-modules/file/listeners/file-attachment.listener'; import { FileWorkspaceMemberListener } from 'src/engine/core-modules/file/listeners/file-workspace-member.listener'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -25,7 +25,6 @@ import { FileService } from './services/file.service'; imports: [ JwtModule, TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]), - HttpModule, PermissionsModule, FileStorageModule, FilesFieldModule, @@ -40,6 +39,7 @@ import { FileService } from './services/file.service'; FileWorkspaceFolderDeletionJob, FileDeletionJob, FileUploadService, + SecureHttpClientService, ], exports: [FileService, FileMetadataService], controllers: [FileController], diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts b/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts index 578170a947..a4def72d6d 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/geo-map-module.ts @@ -1,14 +1,14 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { GeoMapResolver } from 'src/engine/core-modules/geo-map/resolver/geo-map.resolver'; import { GeoMapService } from 'src/engine/core-modules/geo-map/services/geo-map.service'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @Module({ - imports: [HttpModule, WorkspaceCacheStorageModule, TokenModule], - providers: [GeoMapService, GeoMapResolver], + imports: [WorkspaceCacheStorageModule, TokenModule], + providers: [GeoMapService, GeoMapResolver, SecureHttpClientService], exports: [], }) export class GeoMapModule {} diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts b/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts index a6db714fd3..6d6968ec91 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/services/geo-map.service.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { isNonEmptyString } from '@sniptt/guards'; @@ -12,6 +11,7 @@ import { type AddressFields, sanitizePlaceDetailsResults, } from 'src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() @@ -19,7 +19,7 @@ export class GeoMapService { private apiMapKey: string | undefined; constructor( private readonly twentyConfigService: TwentyConfigService, - private readonly httpService: HttpService, + private readonly secureHttpClientService: SecureHttpClientService, ) { if ( !this.twentyConfigService.get( @@ -50,7 +50,9 @@ export class GeoMapService { if (isDefined(isFieldCity) && isFieldCity === true) { url += `&types=(cities)`; } - const result = await this.httpService.axiosRef.get(url); + const httpClient = this.secureHttpClientService.getHttpClient(); + + const result = await httpClient.get(url); if (result.data.status === 'OK') { return sanitizeAutocompleteResults(result.data.predictions); @@ -63,7 +65,9 @@ export class GeoMapService { placeId: string, token: string, ): Promise { - const result = await this.httpService.axiosRef.get( + const httpClient = this.secureHttpClientService.getHttpClient(); + + const result = await httpClient.get( `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&sessiontoken=${token}&fields=address_components%2Cgeometry&key=${this.apiMapKey}`, ); diff --git a/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.module.ts b/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.module.ts index 951c2404a4..be20641633 100644 --- a/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.module.ts +++ b/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.module.ts @@ -1,15 +1,11 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; + import { TelemetryService } from './telemetry.service'; @Module({ - providers: [TelemetryService], - imports: [ - HttpModule.register({ - baseURL: 'https://twenty-telemetry.com/api/v2', - }), - ], + providers: [TelemetryService, SecureHttpClientService], exports: [TelemetryService], }) export class TelemetryModule {} diff --git a/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.service.ts b/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.service.ts index b780fb3c3d..550eb556d1 100644 --- a/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/telemetry/telemetry.service.ts @@ -1,6 +1,6 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; type CreateEventInput = { @@ -12,7 +12,7 @@ type CreateEventInput = { export class TelemetryService { constructor( private readonly twentyConfigService: TwentyConfigService, - private readonly httpService: HttpService, + private readonly secureHttpClientService: SecureHttpClientService, ) {} async create( @@ -36,7 +36,11 @@ export class TelemetryService { }; try { - await this.httpService.axiosRef.post(`/selfHostingEvent`, data); + const httpClient = this.secureHttpClientService.getHttpClient({ + baseURL: 'https://twenty-telemetry.com/api/v2', + }); + + await httpClient.post(`/selfHostingEvent`, data); } catch { return { success: false }; } diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts b/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts index 8a154af850..1acbfb5093 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts @@ -1,21 +1,28 @@ import { Injectable } from '@nestjs/common'; -import axios, { type AxiosInstance } from 'axios'; +import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'; -import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util'; +import { getSecureAxiosAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class SecureHttpClientService { constructor(private readonly twentyConfigService: TwentyConfigService) {} - getHttpClient(): AxiosInstance { + // Returns an SSRF-protected HTTP client for external requests + getHttpClient(config?: CreateAxiosDefaults): AxiosInstance { const isSafeModeEnabled = this.twentyConfigService.get( 'OUTBOUND_HTTP_SAFE_MODE_ENABLED', ); return isSafeModeEnabled - ? axios.create({ adapter: getSecureAdapter() }) - : axios.create(); + ? axios.create({ ...config, adapter: getSecureAxiosAdapter() }) + : axios.create(config); + } + + // Returns a plain HTTP client for requests to trusted internal URLs + // (e.g., the server's own API endpoints). Not SSRF-protected. + getInternalHttpClient(config?: CreateAxiosDefaults): AxiosInstance { + return axios.create(config); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 09aa5c38b5..a90922dfbe 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -1,4 +1,3 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -17,7 +16,6 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo MessagingImportManagerModule, TypeOrmModule.forFeature([FileEntity]), FileModule, - HttpModule, JwtModule, ], providers: [ diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index eb0587bf57..76978a5f18 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { Injectable, Logger } from '@nestjs/common'; import path from 'path'; @@ -24,6 +23,7 @@ import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { CodeInterpreterInputZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema'; import { TWENTY_MCP_HELPER } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const'; import { type CodeInterpreterInput } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type'; @@ -33,7 +33,6 @@ import { type Tool, type ToolExecutionContext, } from 'src/engine/core-modules/tool/types/tool.type'; -import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @@ -50,7 +49,7 @@ export class CodeInterpreterTool implements Tool { private readonly codeInterpreterService: CodeInterpreterService, private readonly fileStorageService: FileStorageService, private readonly fileService: FileService, - private readonly httpService: HttpService, + private readonly secureHttpClientService: SecureHttpClientService, private readonly twentyConfigService: TwentyConfigService, private readonly jwtWrapperService: JwtWrapperService, ) {} @@ -274,15 +273,16 @@ export class CodeInterpreterTool implements Tool { continue; } - // Allow requests to the server's own URL (for internal file downloads) - // but block all other private/internal IPs to prevent SSRF attacks + // Internal file downloads (from the server itself) use a plain client; + // external URLs go through the SSRF-protected client const isInternalFileUrl = file.url.startsWith(serverUrl); - const adapter = isInternalFileUrl ? undefined : getSecureAdapter(); + const httpClient = isInternalFileUrl + ? this.secureHttpClientService.getInternalHttpClient() + : this.secureHttpClientService.getHttpClient(); - const response = await this.httpService.axiosRef.get(file.url, { + const response = await httpClient.get(file.url, { responseType: 'arraybuffer', timeout: 30_000, - adapter, }); inputFiles.push({ diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts index 040049d209..2601973425 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import axios, { type AxiosRequestConfig } from 'axios'; +import { type AxiosRequestConfig, isAxiosError } from 'axios'; import { isDefined } from 'twenty-shared/utils'; import { parseDataFromContentType } from 'twenty-shared/workflow'; @@ -60,7 +60,7 @@ export class HttpTool implements Tool { headers: response.headers as Record, }; } catch (error) { - if (axios.isAxiosError(error)) { + if (isAxiosError(error)) { return { success: false, message: `HTTP ${method} request to ${url} failed`, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.ts index 8f9ae90e43..86094eb879 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; -import axios from 'axios'; +import { isAxiosError } from 'axios'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { SearchHelpCenterInputZodSchema } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.schema'; import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; @@ -17,7 +18,10 @@ export class SearchHelpCenterTool implements Tool { 'Search Twenty documentation and help center to find information about features, setup, usage, and troubleshooting.'; inputSchema = SearchHelpCenterInputZodSchema; - constructor(private readonly twentyConfigService: TwentyConfigService) {} + constructor( + private readonly twentyConfigService: TwentyConfigService, + private readonly secureHttpClientService: SecureHttpClientService, + ) {} async execute( parameters: ToolInput, @@ -41,7 +45,9 @@ export class SearchHelpCenterTool implements Tool { ...(useDirectApi && { Authorization: `Bearer ${MINTLIFY_API_KEY}` }), }; - const response = await axios.post( + const httpClient = this.secureHttpClientService.getHttpClient(); + + const response = await httpClient.post( endpoint, { query, pageSize: 10 }, { headers }, @@ -63,7 +69,7 @@ export class SearchHelpCenterTool implements Tool { result: results, }; } catch (error) { - const errorDetail = axios.isAxiosError(error) + const errorDetail = isAxiosError(error) ? error.response?.data?.message || error.message : error instanceof Error ? error.message diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts index ca4e82080b..54af1a6770 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts @@ -4,9 +4,9 @@ import * as https from 'https'; import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'; import { type SecureAdapterDependencies } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.types'; -import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util'; +import { getSecureAxiosAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util'; -describe('getSecureAdapter', () => { +describe('getSecureAxiosAdapter', () => { let mockDnsLookup: jest.Mock; let mockHttpAdapter: jest.Mock; let dependencies: SecureAdapterDependencies; @@ -22,14 +22,14 @@ describe('getSecureAdapter', () => { describe('URL validation', () => { it('should throw if URL is not provided', async () => { - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: undefined } as InternalAxiosRequestConfig; await expect(adapter(config)).rejects.toThrow('URL is required'); }); it('should throw for non-http/https protocols', async () => { - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'file:///etc/passwd', } as InternalAxiosRequestConfig; @@ -40,7 +40,7 @@ describe('getSecureAdapter', () => { }); it('should throw for ftp protocol', async () => { - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'ftp://example.com/file', } as InternalAxiosRequestConfig; @@ -56,7 +56,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://example.com', headers: new AxiosHeaders(), @@ -73,7 +73,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com', headers: new AxiosHeaders(), @@ -89,7 +89,7 @@ describe('getSecureAdapter', () => { it('should block requests to 127.0.0.1', async () => { mockDnsLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://localhost', headers: new AxiosHeaders(), @@ -103,7 +103,7 @@ describe('getSecureAdapter', () => { it('should block requests to 10.x.x.x range', async () => { mockDnsLookup.mockResolvedValue({ address: '10.0.0.1', family: 4 }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://internal.example.com', headers: new AxiosHeaders(), @@ -117,7 +117,7 @@ describe('getSecureAdapter', () => { it('should block requests to 192.168.x.x range', async () => { mockDnsLookup.mockResolvedValue({ address: '192.168.1.1', family: 4 }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://router.local', headers: new AxiosHeaders(), @@ -131,7 +131,7 @@ describe('getSecureAdapter', () => { it('should block requests to 172.16-31.x.x range', async () => { mockDnsLookup.mockResolvedValue({ address: '172.16.0.1', family: 4 }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://internal.corp', headers: new AxiosHeaders(), @@ -148,7 +148,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://metadata.google.internal', headers: new AxiosHeaders(), @@ -167,7 +167,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com/api/data', headers: new AxiosHeaders(), @@ -188,7 +188,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com/api/data', headers: new AxiosHeaders(), @@ -206,7 +206,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'http://example.com/api/data', headers: new AxiosHeaders(), @@ -224,7 +224,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com/api/data', headers: new AxiosHeaders(), @@ -261,7 +261,7 @@ describe('getSecureAdapter', () => { family: 6, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com/api/data', headers: new AxiosHeaders(), @@ -295,7 +295,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com/api?foo=bar&baz=qux', headers: new AxiosHeaders(), @@ -316,7 +316,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com:8443/api', headers: new AxiosHeaders(), @@ -336,7 +336,7 @@ describe('getSecureAdapter', () => { it('should allow requests to public IP addresses', async () => { mockDnsLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://dns.google', headers: new AxiosHeaders(), @@ -353,7 +353,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com', headers: new AxiosHeaders(), @@ -372,7 +372,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://user:pass@example.com/api', headers: new AxiosHeaders(), @@ -393,7 +393,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com/page#section', headers: new AxiosHeaders(), @@ -410,7 +410,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com', headers: undefined, @@ -428,7 +428,7 @@ describe('getSecureAdapter', () => { family: 4, }); - const adapter = getSecureAdapter(dependencies); + const adapter = getSecureAxiosAdapter(dependencies); const config = { url: 'https://example.com', headers: { 'Content-Type': 'application/json' }, diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts index 640ac7dcb4..fff182d85a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts @@ -17,7 +17,7 @@ const defaultDependencies: SecureAdapterDependencies = { httpAdapter: axios.getAdapter('http'), }; -export const getSecureAdapter = ( +export const getSecureAxiosAdapter = ( dependencies: SecureAdapterDependencies = defaultDependencies, ): AxiosAdapter => { const { dnsLookup, httpAdapter } = dependencies; diff --git a/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts b/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts index 3dd45bf0a5..06ed28cb13 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; @@ -19,6 +20,7 @@ import { CreatePersonService } from 'src/modules/contact-creation-manager/servic CreateCompanyService, CreatePersonService, CreateCompanyAndPersonService, + SecureHttpClientService, ], exports: [CreateCompanyAndPersonService], }) diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts index 68363283c6..ffd97db08e 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts @@ -1,7 +1,6 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import axios from 'axios'; import { ConnectedAccountProvider, FieldActorSource, @@ -9,14 +8,13 @@ import { import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type CompanyToCreate, CreateCompanyService, } from 'src/modules/contact-creation-manager/services/create-company.service'; -jest.mock('axios'); - describe('CreateCompanyService', () => { let service: CreateCompanyService; let mockCompanyRepository: any; @@ -109,11 +107,15 @@ describe('CreateCompanyService', () => { get: jest.fn(), }; - (axios.create as jest.Mock).mockReturnValue(mockHttpService); - const module: TestingModule = await Test.createTestingModule({ providers: [ CreateCompanyService, + { + provide: SecureHttpClientService, + useValue: { + getHttpClient: jest.fn().mockReturnValue(mockHttpService), + }, + }, { provide: GlobalWorkspaceOrmManager, useValue: { diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts index 1d6eadae82..ef20ce4ec4 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import axios, { type AxiosInstance } from 'axios'; +import { type AxiosInstance } from 'axios'; import uniqBy from 'lodash.uniqby'; import { TWENTY_COMPANIES_BASE_URL } from 'twenty-shared/constants'; import { @@ -13,6 +13,7 @@ import { } from 'twenty-shared/utils'; import { type DeepPartial, ILike } from 'typeorm'; +import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -37,8 +38,9 @@ export class CreateCompanyService { constructor( private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly secureHttpClientService: SecureHttpClientService, ) { - this.httpService = axios.create({ + this.httpService = this.secureHttpClientService.getHttpClient({ baseURL: TWENTY_COMPANIES_BASE_URL, }); }