Prevent SSRF via IMAP/SMTP/CalDAV (#17973)

Prevents leaking of internal services by filtering out private IPs, same
way we do for webhooks
This commit is contained in:
neo773
2026-02-19 02:45:10 +05:30
committed by GitHub
parent 67074a7581
commit 7de565f70c
13 changed files with 182 additions and 12 deletions
@@ -107,7 +107,7 @@ export class ImapSmtpCaldavResolver {
if (params) {
validatedParams[protocol] =
this.mailConnectionValidatorService.validateProtocolConnectionParams(
await this.mailConnectionValidatorService.validateProtocolConnectionParams(
params,
);
const validatedProtocolParams = validatedParams[protocol];
@@ -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],
})
@@ -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<ConnectionParameters> {
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}`)
@@ -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<string> {
if (!this.isSafeModeEnabled()) {
return hostnameOrUrl;
}
return resolveAndValidateHostname(hostnameOrUrl);
}
async getValidatedUrl(serverUrl: string): Promise<string> {
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');
}
}
@@ -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');
});
});
@@ -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<string> => {
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;
};
@@ -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()
@@ -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],
})
@@ -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;
}
}
@@ -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,
],
@@ -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<ImapFlow> {
@@ -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: {
@@ -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],
})
@@ -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 ?? '',