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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user