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
@@ -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;
};