Move http node to backend (#15424)

Fixes https://github.com/twentyhq/twenty/issues/14491

Also adding an url validator.
This commit is contained in:
Thomas Trompette
2025-10-31 17:46:58 +01:00
committed by GitHub
parent 9f97be67b1
commit 71e7063fc4
20 changed files with 695 additions and 153 deletions
@@ -5,15 +5,19 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Injectable()
export class ToolRegistryService {
private readonly toolFactories: Map<ToolType, () => Tool>;
constructor(private readonly sendEmailTool: SendEmailTool) {
constructor(
private readonly sendEmailTool: SendEmailTool,
private readonly twentyConfigService: TwentyConfigService,
) {
this.toolFactories = new Map<ToolType, () => Tool>([
[ToolType.HTTP_REQUEST, () => new HttpTool()],
[ToolType.HTTP_REQUEST, () => new HttpTool(twentyConfigService)],
[
ToolType.SEND_EMAIL,
() => ({
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
@Module({
imports: [
MessagingImportManagerModule,
TypeOrmModule.forFeature([FileEntity]),
FileModule,
],
providers: [HttpTool, SendEmailTool, SearchArticlesTool, ToolRegistryService],
exports: [ToolRegistryService],
})
export class ToolModule {}
@@ -9,12 +9,17 @@ import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-t
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';
import { type Tool } 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';
@Injectable()
export class HttpTool implements Tool {
description =
'Make an HTTP request to any URL with configurable method, headers, and body.';
inputSchema = HttpToolParametersZodSchema;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async execute(parameters: ToolInput): Promise<ToolOutput> {
const { url, method, headers, body } = parameters as HttpRequestInput;
const headersCopy = { ...headers };
@@ -36,12 +41,25 @@ export class HttpTool implements Tool {
}
}
const response = await axios(axiosConfig);
const isSafeModeEnabled = this.twentyConfigService.get(
'HTTP_TOOL_SAFE_MODE_ENABLED',
);
const axiosClient = isSafeModeEnabled
? axios.create({
adapter: getSecureAdapter(),
})
: axios.create();
const response = await axiosClient(axiosConfig);
return {
success: true,
message: `HTTP ${method} request to ${url} completed successfully`,
result: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers as Record<string, string>,
};
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -3,4 +3,7 @@ export type ToolOutput<T = object> = {
message: string;
error?: string;
result?: T;
status?: number;
statusText?: string;
headers?: Record<string, string>;
};
@@ -0,0 +1,35 @@
import dns from 'dns/promises';
import axios, {
type AxiosAdapter,
type InternalAxiosRequestConfig,
} from 'axios';
import { isPrivateIp } from 'src/engine/core-modules/tool/utils/is-private-ip.util';
const httpAdapter = axios.getAdapter('http');
export const getSecureAdapter = (): AxiosAdapter => {
return async (config: InternalAxiosRequestConfig) => {
if (!config.url) {
throw new Error('URL is required');
}
const url = new URL(config.url);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('URL should use http/https protocol');
}
const { hostname } = url;
const { address: resolvedIp } = await dns.lookup(hostname);
if (isPrivateIp(resolvedIp)) {
throw new Error(
`Request to internal IP address ${resolvedIp} is not allowed.`,
);
}
return httpAdapter(config);
};
};
@@ -0,0 +1,96 @@
// Based on code from node-ip by indutny
// Licensed under MIT License
// https://github.com/indutny/node-ip
const ipv6Regex =
/^(::)?(((\d{1,3}\.){3}(\d{1,3}){1})?([0-9a-f]){0,4}:{0,2}){1,8}(::)?$/i;
const fromLong = (ipl: number) => {
return `${ipl >>> 24}.${(ipl >> 16) & 255}.${(ipl >> 8) & 255}.${ipl & 255}`;
};
const isLoopback = (addr: string) => {
if (!/\./.test(addr) && !/:/.test(addr)) {
addr = fromLong(Number(addr));
}
return (
/^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/.test(addr) ||
/^0177\./.test(addr) ||
/^0x7f\./i.test(addr) ||
/^fe80::1$/i.test(addr) ||
/^::1$/.test(addr) ||
/^::$/.test(addr)
);
};
const normalizeToLong = (addr: string) => {
const parts = addr.split('.').map((part) => {
if (part.startsWith('0x') || part.startsWith('0X')) {
return parseInt(part, 16);
} else if (part.startsWith('0') && part !== '0' && /^[0-7]+$/.test(part)) {
return parseInt(part, 8);
} else if (/^[1-9]\d*$/.test(part) || part === '0') {
return parseInt(part, 10);
} else {
return NaN;
}
});
if (parts.some(isNaN)) return -1;
let val = 0;
const n = parts.length;
switch (n) {
case 1:
val = parts[0];
break;
case 2:
if (parts[0] > 0xff || parts[1] > 0xffffff) return -1;
val = (parts[0] << 24) | (parts[1] & 0xffffff);
break;
case 3:
if (parts[0] > 0xff || parts[1] > 0xff || parts[2] > 0xffff) return -1;
val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] & 0xffff);
break;
case 4:
if (parts.some((part) => part > 0xff)) return -1;
val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
break;
default:
return -1;
}
return val >>> 0;
};
const isIpV6 = (hostname: string) => ipv6Regex.test(hostname);
export const isPrivateIp = (addr: string) => {
if (isLoopback(addr)) {
return true;
}
if (!isIpV6(addr)) {
const ipl = normalizeToLong(addr);
if (ipl < 0) {
throw new Error('invalid ipv4 address');
}
addr = fromLong(ipl);
}
return (
/^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) ||
/^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) ||
/^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(
addr,
) ||
/^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) ||
/^f[cd][0-9a-f]{2}:/i.test(addr) ||
/^fe80:/i.test(addr) ||
/^::1$/.test(addr) ||
/^::$/.test(addr)
);
};