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
@@ -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],
};
}
@@ -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> {