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:
Félix Malfait
2026-02-08 19:52:35 +01:00
committed by GitHub
parent bc6268bb29
commit d7132b35d3
29 changed files with 187 additions and 134 deletions
@@ -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: [
@@ -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`,
@@ -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
@@ -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' },
@@ -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;