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:
+1
-1
@@ -107,7 +107,7 @@ export class ImapSmtpCaldavResolver {
|
||||
|
||||
if (params) {
|
||||
validatedParams[protocol] =
|
||||
this.mailConnectionValidatorService.validateProtocolConnectionParams(
|
||||
await this.mailConnectionValidatorService.validateProtocolConnectionParams(
|
||||
params,
|
||||
);
|
||||
const validatedProtocolParams = validatedParams[protocol];
|
||||
|
||||
+3
@@ -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],
|
||||
})
|
||||
|
||||
+25
-3
@@ -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}`)
|
||||
|
||||
+25
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -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');
|
||||
});
|
||||
});
|
||||
+28
@@ -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()
|
||||
|
||||
+2
-1
@@ -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],
|
||||
})
|
||||
|
||||
+12
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
],
|
||||
|
||||
+11
-1
@@ -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: {
|
||||
|
||||
+3
@@ -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],
|
||||
})
|
||||
|
||||
+9
-1
@@ -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 ?? '',
|
||||
|
||||
Reference in New Issue
Block a user