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