Move http node to backend (#15424)
Fixes https://github.com/twentyhq/twenty/issues/14491 Also adding an url validator.
This commit is contained in:
+6
-2
@@ -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>;
|
||||
};
|
||||
|
||||
+35
@@ -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)
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user