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
@@ -14,9 +14,8 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
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 { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -30,8 +29,8 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, FileEntity]),
TokenModule,
FileModule,
TokenModule,
FeatureFlagModule,
RecordCrudModule,
ObjectMetadataModule,
@@ -41,6 +40,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
TwentyORMModule,
MessagingModule,
PermissionsModule,
ToolModule,
],
controllers: [AiController, McpController],
providers: [
@@ -48,10 +48,8 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
AiModelRegistryService,
ToolService,
ToolAdapterService,
ToolRegistryService,
AIBillingService,
McpService,
SendEmailTool,
SearchArticlesTool,
],
exports: [
@@ -60,9 +58,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
AIBillingService,
ToolService,
ToolAdapterService,
ToolRegistryService,
McpService,
SendEmailTool,
SearchArticlesTool,
],
})
@@ -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)
);
};
@@ -66,6 +66,15 @@ export class ConfigVariables {
@IsOptional()
IS_EMAIL_VERIFICATION_REQUIRED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.OTHER,
description:
'Enable safe mode for HTTP requests (prevents private IPs and other security risks)',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
HTTP_TOOL_SAFE_MODE_ENABLED = true;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TOKENS_DURATION,
description: 'Duration for which the email verification token is valid',
@@ -0,0 +1,32 @@
import { Field, InputType } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
import { WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type';
@InputType()
export class TestHttpRequestInput {
@Field(() => String, {
description: 'URL to make the request to',
nullable: false,
})
url: WorkflowHttpRequestActionInput['url'];
@Field(() => String, {
description: 'HTTP method',
nullable: false,
})
method: WorkflowHttpRequestActionInput['method'];
@Field(() => graphqlTypeJson, {
description: 'HTTP headers',
nullable: true,
})
headers?: WorkflowHttpRequestActionInput['headers'];
@Field(() => graphqlTypeJson, {
description: 'Request body',
nullable: true,
})
body?: WorkflowHttpRequestActionInput['body'];
}
@@ -0,0 +1,46 @@
import { Field, ObjectType } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
@ObjectType()
export class TestHttpRequestOutput {
@Field(() => Boolean, {
description: 'Whether the request was successful',
})
success: boolean;
@Field(() => String, {
description: 'Message describing the result',
})
message: string;
@Field(() => graphqlTypeJson, {
description: 'Response data',
nullable: true,
})
result?: object;
@Field(() => graphqlTypeJson, {
description: 'Error information',
nullable: true,
})
error?: string;
@Field(() => Number, {
description: 'HTTP status code',
nullable: true,
})
status?: number;
@Field(() => String, {
description: 'HTTP status text',
nullable: true,
})
statusText?: string;
@Field(() => graphqlTypeJson, {
description: 'Response headers',
nullable: true,
})
headers?: Record<string, string>;
}
@@ -5,10 +5,14 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto';
import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto';
import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto';
import { TestHttpRequestInput } from 'src/engine/core-modules/workflow/dtos/test-http-request-input.dto';
import { TestHttpRequestOutput } from 'src/engine/core-modules/workflow/dtos/test-http-request-output.dto';
import { UpdateWorkflowRunStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-run-step-input.dto';
import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
@@ -43,6 +47,7 @@ export class WorkflowVersionStepResolver {
private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService,
private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly toolRegistryService: ToolRegistryService,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -142,4 +147,17 @@ export class WorkflowVersionStepResolver {
},
);
}
@Mutation(() => TestHttpRequestOutput)
async testHttpRequest(
@Args('input')
{ url, method, headers, body }: TestHttpRequestInput,
): Promise<TestHttpRequestOutput> {
return this.toolRegistryService.getTool(ToolType.HTTP_REQUEST).execute({
url,
method,
headers,
body,
});
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { WorkflowTriggerController } from 'src/engine/core-modules/workflow/controllers/workflow-trigger.controller';
import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-builder.resolver';
import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver';
@@ -25,6 +26,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor
WorkflowRunModule,
WorkflowRunnerModule,
PermissionsModule,
ToolModule,
],
controllers: [WorkflowTriggerController],
providers: [