fix: CalDAV sync broken by SSRF hostname replacement (#19291)

/closes #19272
This commit is contained in:
neo773
2026-04-03 13:45:59 +05:30
committed by GitHub
parent baa4fda3d2
commit c521ba29be
3 changed files with 57 additions and 13 deletions
@@ -11,6 +11,17 @@ jest.mock('axios-retry', () => ({
default: jest.fn(),
}));
jest.mock(
'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util',
() => ({
resolveAndValidateHostname: jest.fn(),
}),
);
import { resolveAndValidateHostname } from 'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util';
const mockResolveAndValidate = jest.mocked(resolveAndValidateHostname);
const createMockConfigService = (
overrides: Record<string, unknown> = {},
): TwentyConfigService => {
@@ -260,6 +271,50 @@ describe('SecureHttpClientService', () => {
});
});
describe('getValidatedHost', () => {
it('should return the original hostname when safe mode is off', async () => {
const service = new SecureHttpClientService(createMockConfigService());
const result = await service.getValidatedHost(
'https://caldav.icloud.com/principals/',
);
expect(result).toBe('https://caldav.icloud.com/principals/');
expect(mockResolveAndValidate).not.toHaveBeenCalled();
});
it('should validate and return resolved IP when safe mode is on', async () => {
mockResolveAndValidate.mockResolvedValue('17.248.239.66');
const service = new SecureHttpClientService(
createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }),
);
const result = await service.getValidatedHost(
'https://caldav.icloud.com/principals/',
);
expect(mockResolveAndValidate).toHaveBeenCalledWith(
'https://caldav.icloud.com/principals/',
);
expect(result).toBe('17.248.239.66');
});
it('should throw when hostname resolves to a private IP', async () => {
mockResolveAndValidate.mockRejectedValue(
new Error(
'Connection to internal IP address 192.168.1.1 is not allowed.',
),
);
const service = new SecureHttpClientService(
createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }),
);
await expect(
service.getValidatedHost('https://my-local-server.local/'),
).rejects.toThrow('internal IP address');
});
});
describe('logging interceptor', () => {
it('should add a request interceptor when context is provided', () => {
const service = new SecureHttpClientService(createMockConfigService());
@@ -109,18 +109,6 @@ export class SecureHttpClientService {
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');
}
@@ -26,9 +26,10 @@ export class CalDavClientProvider {
throw new Error('Missing required CalDAV connection parameters');
}
const serverUrl = await this.secureHttpClientService.getValidatedUrl(
await this.secureHttpClientService.getValidatedHost(
connectedAccount.connectionParameters.CALDAV.host,
);
const serverUrl = connectedAccount.connectionParameters.CALDAV.host;
return new CalDAVClient({
username: