From 7de565f70c631eba5e68519d857670ed76b4eea9 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Thu, 19 Feb 2026 02:45:10 +0530 Subject: [PATCH] Prevent SSRF via IMAP/SMTP/CalDAV (#17973) Prevents leaking of internal services by filtering out private IPs, same way we do for webhooks --- .../imap-smtp-caldav-connection.resolver.ts | 2 +- ...smtp-caldav-connection-validator.module.ts | 3 + ...mtp-caldav-connection-validator.service.ts | 28 ++++++++- .../secure-http-client.service.ts | 25 ++++++++ ...resolve-and-validate-hostname.util.spec.ts | 60 +++++++++++++++++++ .../resolve-and-validate-hostname.util.ts | 28 +++++++++ .../twenty-config/config-variables.ts | 2 +- .../drivers/caldav/caldav-driver.module.ts | 3 +- .../caldav/providers/caldav.provider.ts | 16 +++-- .../imap/messaging-imap-driver.module.ts | 2 + .../imap/providers/imap-client.provider.ts | 12 +++- .../smtp/messaging-smtp-driver.module.ts | 3 + .../smtp/providers/smtp-client.provider.ts | 10 +++- 13 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/resolve-and-validate-hostname.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts index 954eac2cd7..f23700c7dd 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts @@ -107,7 +107,7 @@ export class ImapSmtpCaldavResolver { if (params) { validatedParams[protocol] = - this.mailConnectionValidatorService.validateProtocolConnectionParams( + await this.mailConnectionValidatorService.validateProtocolConnectionParams( params, ); const validatedProtocolParams = validatedParams[protocol]; diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module.ts index e4a6a8fabb..89534aa4a9 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; + import { ImapSmtpCaldavValidatorService } from './imap-smtp-caldav-connection-validator.service'; @Module({ + imports: [SecureHttpClientModule], providers: [ImapSmtpCaldavValidatorService], exports: [ImapSmtpCaldavValidatorService], }) diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts index f60f448239..646fd8c7c1 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service.ts @@ -4,10 +4,15 @@ import { msg } from '@lingui/core/macro'; import { z } from 'zod'; import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { type ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; @Injectable() export class ImapSmtpCaldavValidatorService { + constructor( + private readonly secureHttpClientService: SecureHttpClientService, + ) {} + private readonly protocolConnectionSchema = z.object({ host: z.string().min(1, 'Host is required'), port: z.int().positive('Port must be a positive number'), @@ -16,9 +21,9 @@ export class ImapSmtpCaldavValidatorService { secure: z.boolean().optional(), }); - validateProtocolConnectionParams( + async validateProtocolConnectionParams( params: ConnectionParameters, - ): ConnectionParameters { + ): Promise { if (!params) { throw new UserInputError('Protocol connection parameters are required', { userFriendlyMessage: msg`Please provide connection details to configure your email account.`, @@ -26,8 +31,25 @@ export class ImapSmtpCaldavValidatorService { } try { - return this.protocolConnectionSchema.parse(params); + const validated = this.protocolConnectionSchema.parse(params); + + try { + await this.secureHttpClientService.getValidatedHost(validated.host); + } catch { + throw new UserInputError( + 'Connection to private or internal network addresses is not allowed', + { + userFriendlyMessage: msg`The server address you entered is not allowed. Please use a public server address.`, + }, + ); + } + + return validated; } catch (error) { + if (error instanceof UserInputError) { + throw error; + } + if (error instanceof z.ZodError) { const errorMessages = error.issues .map((err) => `${err.path.join('.')}: ${err.message}`) diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts index 66e41a5143..38728dd30f 100644 --- a/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts @@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'; import { createSsrfSafeAgent } from 'src/engine/core-modules/secure-http-client/utils/create-ssrf-safe-agent.util'; +import { resolveAndValidateHostname } from 'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type OutboundRequestContext } from './outbound-request-context.type'; @@ -60,4 +61,28 @@ export class SecureHttpClientService { getInternalHttpClient(config?: CreateAxiosDefaults): AxiosInstance { return axios.create(config); } + + async getValidatedHost(hostnameOrUrl: string): Promise { + if (!this.isSafeModeEnabled()) { + return hostnameOrUrl; + } + + return resolveAndValidateHostname(hostnameOrUrl); + } + + async getValidatedUrl(serverUrl: string): Promise { + if (!this.isSafeModeEnabled()) { + return serverUrl; + } + const resolvedIp = await resolveAndValidateHostname(serverUrl); + const url = new URL(serverUrl); + + url.hostname = resolvedIp; + + return url.toString(); + } + + private isSafeModeEnabled(): boolean { + return this.twentyConfigService.get('OUTBOUND_HTTP_SAFE_MODE_ENABLED'); + } } diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/resolve-and-validate-hostname.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/resolve-and-validate-hostname.util.spec.ts new file mode 100644 index 0000000000..3095f82453 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/resolve-and-validate-hostname.util.spec.ts @@ -0,0 +1,60 @@ +import { resolveAndValidateHostname } from 'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util'; + +describe('resolveAndValidateHostname', () => { + let mockDnsLookup: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockDnsLookup = jest.fn(); + }); + + it('should resolve a plain hostname and pass it to DNS lookup', async () => { + mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + + await resolveAndValidateHostname('imap.fastmail.com', mockDnsLookup); + + expect(mockDnsLookup).toHaveBeenCalledWith('imap.fastmail.com'); + }); + + it('should extract hostname from a full URL before resolving', async () => { + mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + + await resolveAndValidateHostname( + 'https://caldav.example.com:8443/dav/principals', + mockDnsLookup, + ); + + expect(mockDnsLookup).toHaveBeenCalledWith('caldav.example.com'); + }); + + it('should throw when the resolved IP is private', async () => { + mockDnsLookup.mockResolvedValue({ address: '10.0.0.1', family: 4 }); + + await expect( + resolveAndValidateHostname('evil.example.com', mockDnsLookup), + ).rejects.toThrow( + 'Connection to internal IP address 10.0.0.1 is not allowed.', + ); + }); + + it('should return the resolved public IP', async () => { + mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 }); + + const result = await resolveAndValidateHostname( + 'mail.example.com', + mockDnsLookup, + ); + + expect(result).toBe('93.184.216.34'); + }); + + it('should propagate DNS resolution failures', async () => { + mockDnsLookup.mockRejectedValue( + new Error('getaddrinfo ENOTFOUND bogus.invalid'), + ); + + await expect( + resolveAndValidateHostname('bogus.invalid', mockDnsLookup), + ).rejects.toThrow('getaddrinfo ENOTFOUND bogus.invalid'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util.ts new file mode 100644 index 0000000000..e5527bebf0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util.ts @@ -0,0 +1,28 @@ +import * as dns from 'dns/promises'; + +import { isPrivateIp } from 'src/engine/core-modules/secure-http-client/utils/is-private-ip.util'; + +export const resolveAndValidateHostname = async ( + hostnameOrUrl: string, + dnsLookup: typeof dns.lookup = dns.lookup, +): Promise => { + let hostname: string; + + try { + const url = new URL(hostnameOrUrl); + + hostname = url.hostname; + } catch { + hostname = hostnameOrUrl; + } + + const { address: resolvedIp } = await dnsLookup(hostname); + + if (isPrivateIp(resolvedIp)) { + throw new Error( + `Connection to internal IP address ${resolvedIp} is not allowed.`, + ); + } + + return resolvedIp; +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index ef1c4f6115..13d4a6826c 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -72,7 +72,7 @@ export class ConfigVariables { @ConfigVariablesMetadata({ group: ConfigVariablesGroup.OTHER, description: - 'Enable safe mode for outbound HTTP requests (prevents private IPs and other security risks). Applies to HTTP workflow actions and webhooks.', + 'Enable safe mode for outbound requests (prevents private IPs and other security risks). Applies to HTTP workflow actions, webhooks, and IMAP/SMTP/CalDAV connections.', type: ConfigVariableType.BOOLEAN, }) @IsOptional() diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts index 56e17534f2..6478ccd680 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider'; import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service'; @Module({ - imports: [TwentyConfigModule], + imports: [SecureHttpClientModule, TwentyConfigModule], providers: [CalDavClientProvider, CalDavGetEventsService], exports: [CalDavGetEventsService], }) diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts index db34fed972..d9e826750d 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider.ts @@ -2,11 +2,16 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client'; import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; @Injectable() export class CalDavClientProvider { + constructor( + private readonly secureHttpClientService: SecureHttpClientService, + ) {} + public async getCalDavCalendarClient( connectedAccount: Pick< ConnectedAccountWorkspaceEntity, @@ -20,14 +25,17 @@ export class CalDavClientProvider { ) { throw new Error('Missing required CalDAV connection parameters'); } - const caldavClient = new CalDAVClient({ + + const serverUrl = await this.secureHttpClientService.getValidatedUrl( + connectedAccount.connectionParameters.CALDAV.host, + ); + + return new CalDAVClient({ username: connectedAccount.connectionParameters.CALDAV.username ?? connectedAccount.handle, password: connectedAccount.connectionParameters.CALDAV.password, - serverUrl: connectedAccount.connectionParameters.CALDAV.host, + serverUrl, }); - - return caldavClient; } } diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts index 4db7a4647c..0d873ea825 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; @@ -29,6 +30,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p TypeOrmModule.forFeature([FeatureFlagEntity]), EmailAliasManagerModule, FeatureFlagModule, + SecureHttpClientModule, WorkspaceDataSourceModule, MessageParticipantManagerModule, ], diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts index c73202c9af..562178bbab 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider.ts @@ -5,6 +5,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types'; import { CustomError, isDefined } from 'twenty-shared/utils'; import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { MessageImportDriverExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception'; import { parseImapAuthenticationError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-authentication-error.util'; @@ -21,6 +22,10 @@ export class ImapClientProvider { private static readonly CONNECTION_TIMEOUT_MS = 30000; private static readonly GREETING_TIMEOUT_MS = 16000; + constructor( + private readonly secureHttpClientService: SecureHttpClientService, + ) {} + async getClient( connectedAccount: ConnectedAccountIdentifier, ): Promise { @@ -66,8 +71,13 @@ export class ImapClientProvider { ); } + const validatedImapHost = + await this.secureHttpClientService.getValidatedHost( + connectionParameters.IMAP?.host || '', + ); + const client = new ImapFlow({ - host: connectionParameters.IMAP?.host || '', + host: validatedImapHost, port: connectionParameters.IMAP?.port || 993, secure: connectionParameters.IMAP?.secure, auth: { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts index e2d288fd08..765f750958 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; + import { SmtpClientProvider } from './providers/smtp-client.provider'; @Module({ + imports: [SecureHttpClientModule], providers: [SmtpClientProvider], exports: [SmtpClientProvider], }) diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts index 44fbd9753d..0441dd43a7 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider.ts @@ -5,10 +5,15 @@ import { isDefined } from 'twenty-shared/utils'; import type SMTPConnection from 'nodemailer/lib/smtp-connection'; +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; @Injectable() export class SmtpClientProvider { + constructor( + private readonly secureHttpClientService: SecureHttpClientService, + ) {} + public async getSmtpClient( connectedAccount: Pick< ConnectedAccountWorkspaceEntity, @@ -21,8 +26,11 @@ export class SmtpClientProvider { throw new Error('SMTP settings not configured for this account'); } + const validatedSmtpHost = + await this.secureHttpClientService.getValidatedHost(smtpParams.host); + const options: SMTPConnection.Options = { - host: smtpParams.host, + host: validatedSmtpHost, port: smtpParams.port, auth: { user: smtpParams.username ?? connectedAccount.handle ?? '',