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 <cursoragent@cursor.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-10
@@ -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' },
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<UserEntity>,
|
||||
) {}
|
||||
@@ -185,7 +186,9 @@ export class AdminPanelService {
|
||||
const currentVersion = this.twentyConfigService.get('APP_VERSION');
|
||||
|
||||
try {
|
||||
const rawResponse = await axios.get<unknown>(
|
||||
const httpClient = this.secureHttpClientService.getHttpClient();
|
||||
|
||||
const rawResponse = await httpClient.get<unknown>(
|
||||
'https://hub.docker.com/v2/repositories/twentycrm/twenty/tags?page_size=100',
|
||||
);
|
||||
const response = z
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TokenInfoResponse>(
|
||||
const httpClient = this.secureHttpClientService.getHttpClient();
|
||||
|
||||
const response = await httpClient.get<TokenInfoResponse>(
|
||||
`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`,
|
||||
{ timeout: 600 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
+6
-5
@@ -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<CaptchaValidateResult> {
|
||||
|
||||
@@ -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<CaptchaValidateResult> {
|
||||
|
||||
+3
-3
@@ -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 {}
|
||||
|
||||
+5
-6
@@ -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);
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<AddressFields | undefined> {
|
||||
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}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+12
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+8
-8
@@ -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({
|
||||
|
||||
@@ -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<string, string>,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (isAxiosError(error)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP ${method} request to ${url} failed`,
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
+25
-25
@@ -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' },
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+2
@@ -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],
|
||||
})
|
||||
|
||||
+7
-5
@@ -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: {
|
||||
|
||||
+4
-2
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user