From abfa6200dd0b2557b04b16bf71fb1fabc4d4d8eb Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:43:19 +0530 Subject: [PATCH] ssrf hardening (#19963) Hardened CalDav with new approach of wrapping axios ssrf http agent to fetch via `@lifeomic/axios-fetch` because `tsdav` only accept `fetch` override. Also Hardened test endpoint --- packages/twenty-server/package.json | 1 + .../imap-smtp-caldav-connection.module.ts | 2 + ...map-smtp-caldav-connection.service.spec.ts | 79 +++++++++++++++++++ .../imap-smtp-caldav-connection.service.ts | 14 +++- .../secure-http-client.service.spec.ts | 39 +++++++++ .../secure-http-client.service.ts | 10 +++ .../drivers/caldav/lib/caldav.client.ts | 28 ++++--- .../__tests__/caldav.provider.spec.ts | 52 ++++++++++++ .../caldav/providers/caldav.provider.ts | 5 +- yarn.lock | 22 +++++- 10 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts create mode 100644 packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 27f3ada539..1edbf62fae 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -44,6 +44,7 @@ "@graphql-tools/utils": "9.2.1", "@graphql-yoga/nestjs": "2.1.0", "@jrmdayn/googleapis-batcher": "^0.10.1", + "@lifeomic/axios-fetch": "^3.1.0", "@lingui/conf": "5.1.2", "@lingui/core": "^5.1.2", "@lingui/format-po": "5.1.2", diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts index d0701f1d82..297fd3bc04 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { ImapSmtpCaldavValidatorModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -28,6 +29,7 @@ import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.se FeatureFlagModule, ImapSmtpCaldavValidatorModule, PermissionsModule, + SecureHttpClientModule, ], providers: [ImapSmtpCaldavResolver, ImapSmtpCaldavService], exports: [ImapSmtpCaldavService], diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts new file mode 100644 index 0000000000..dbd7e49235 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/__tests__/imap-smtp-caldav-connection.service.spec.ts @@ -0,0 +1,79 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; +import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service'; +import { type ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; + +jest.mock( + 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client', + () => ({ + CalDAVClient: jest.fn().mockImplementation(() => ({ + listCalendars: jest.fn().mockResolvedValue([]), + validateSyncCollectionSupport: jest.fn().mockResolvedValue(undefined), + })), + }), +); + +import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client'; + +const MockCalDAVClient = jest.mocked(CalDAVClient); + +describe('ImapSmtpCaldavService', () => { + let service: ImapSmtpCaldavService; + + const mockSsrfSafeFetch = jest.fn(); + + const mockSecureHttpClientService = { + createSsrfSafeFetch: jest.fn().mockReturnValue(mockSsrfSafeFetch), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + mockSecureHttpClientService.createSsrfSafeFetch.mockReturnValue( + mockSsrfSafeFetch, + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImapSmtpCaldavService, + { + provide: GlobalWorkspaceOrmManager, + useValue: {}, + }, + { + provide: getRepositoryToken(ConnectedAccountEntity), + useValue: {}, + }, + { + provide: SecureHttpClientService, + useValue: mockSecureHttpClientService, + }, + ], + }).compile(); + + service = module.get(ImapSmtpCaldavService); + }); + + describe('testCaldavConnection', () => { + it('should pass SSRF-safe fetch to CalDAVClient', async () => { + const params: ConnectionParameters = { + host: 'https://caldav.example.com', + port: 443, + username: 'user@example.com', + password: 'password123', + }; + + await service.testCaldavConnection('user@example.com', params); + + expect( + mockSecureHttpClientService.createSsrfSafeFetch, + ).toHaveBeenCalledTimes(1); + expect(MockCalDAVClient).toHaveBeenCalledWith( + expect.objectContaining({ fetch: mockSsrfSafeFetch }), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts index b05f4838b0..e9396ef7b4 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service.ts @@ -8,6 +8,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types'; import { Repository } from 'typeorm'; 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 { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { type AccountType, @@ -25,14 +26,18 @@ export class ImapSmtpCaldavService { private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, + private readonly secureHttpClientService: SecureHttpClientService, ) {} async testImapConnection( handle: string, params: ConnectionParameters, ): Promise { + const validatedHost = await this.secureHttpClientService.getValidatedHost( + params.host, + ); const client = new ImapFlow({ - host: params.host, + host: validatedHost, port: params.port, secure: params.secure ?? true, auth: { @@ -93,8 +98,11 @@ export class ImapSmtpCaldavService { handle: string, params: ConnectionParameters, ): Promise { + const validatedHost = await this.secureHttpClientService.getValidatedHost( + params.host, + ); const transport = createTransport({ - host: params.host, + host: validatedHost, port: params.port, auth: { user: params.username ?? handle, @@ -124,10 +132,12 @@ export class ImapSmtpCaldavService { handle: string, params: ConnectionParameters, ): Promise { + const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch(); const client = new CalDAVClient({ serverUrl: params.host, username: params.username ?? handle, password: params.password, + fetch: ssrfSafeFetch, }); try { diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts index 4121b15ec6..4a691b9eaf 100644 --- a/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts @@ -11,6 +11,14 @@ jest.mock('axios-retry', () => ({ default: jest.fn(), })); +jest.mock('@lifeomic/axios-fetch', () => ({ + buildAxiosFetch: jest.fn(() => jest.fn()), +})); + +import { buildAxiosFetch } from '@lifeomic/axios-fetch'; + +const mockBuildAxiosFetch = jest.mocked(buildAxiosFetch); + jest.mock( 'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util', () => ({ @@ -315,6 +323,37 @@ describe('SecureHttpClientService', () => { }); }); + describe('createSsrfSafeFetch', () => { + beforeEach(() => { + mockBuildAxiosFetch.mockClear(); + }); + + it('should return globalThis.fetch when safe mode is off', () => { + const service = new SecureHttpClientService(createMockConfigService()); + + const result = service.createSsrfSafeFetch(); + + expect(result).toBe(globalThis.fetch); + expect(mockBuildAxiosFetch).not.toHaveBeenCalled(); + }); + + it('should wrap an SSRF-protected axios client when safe mode is on', () => { + const service = new SecureHttpClientService( + createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }), + ); + + service.createSsrfSafeFetch(); + + expect(mockBuildAxiosFetch).toHaveBeenCalledTimes(1); + const axiosClient = mockBuildAxiosFetch.mock.calls[0][0] as ReturnType< + SecureHttpClientService['getHttpClient'] + >; + + expect(axiosClient.defaults.httpAgent).toBeInstanceOf(http.Agent); + expect(axiosClient.defaults.httpsAgent).toBeInstanceOf(https.Agent); + }); + }); + describe('logging interceptor', () => { it('should add a request interceptor when context is provided', () => { const service = new SecureHttpClientService(createMockConfigService()); 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 c673ad5348..5ce63de2b0 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 @@ -4,6 +4,8 @@ import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'; import axiosRetry from 'axios-retry'; import { isDefined } from 'twenty-shared/utils'; +import { buildAxiosFetch } from '@lifeomic/axios-fetch'; + 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'; @@ -101,6 +103,14 @@ export class SecureHttpClientService { return axios.create(config); } + createSsrfSafeFetch(): typeof globalThis.fetch { + if (!this.isSafeModeEnabled()) { + return globalThis.fetch; + } + + return buildAxiosFetch(this.getHttpClient()) as typeof globalThis.fetch; + } + async getValidatedHost(hostnameOrUrl: string): Promise { if (!this.isSafeModeEnabled()) { return hostnameOrUrl; diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts index ab1b260b21..7a19208c06 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts @@ -27,6 +27,7 @@ type CalendarCredentials = { username: string; password: string; serverUrl: string; + fetch?: typeof globalThis.fetch; }; type SimpleCalendar = { @@ -61,15 +62,20 @@ type CalDAVGetEventsResponse = { export class CalDAVClient { private credentials: CalendarCredentials; private logger: Logger; - private headers: Record; constructor(credentials: CalendarCredentials) { this.credentials = credentials; this.logger = new Logger(CalDAVClient.name); - this.headers = getBasicAuthHeaders({ - username: credentials.username, - password: credentials.password, - }); + } + + private getTsdavRequestConfig() { + return { + headers: getBasicAuthHeaders({ + username: this.credentials.username, + password: this.credentials.password, + }), + fetch: this.credentials.fetch, + }; } private hasFileExtension(url: string): boolean { @@ -104,7 +110,7 @@ export class CalDAVClient { password: this.credentials.password, }, }, - headers: this.headers, + ...this.getTsdavRequestConfig(), }); } @@ -114,7 +120,7 @@ export class CalDAVClient { const calendars = (await fetchCalendars({ account, - headers: this.headers, + ...this.getTsdavRequestConfig(), })) as (Omit & { displayName?: string | Record; })[]; @@ -150,7 +156,7 @@ export class CalDAVClient { const calendars = await fetchCalendars({ account, - headers: this.headers, + ...this.getTsdavRequestConfig(), }); const eventCalendar = calendars.find((calendar) => @@ -359,7 +365,7 @@ export class CalDAVClient { }, syncLevel: 1, ...(syncToken ? { syncToken } : {}), - headers: this.headers, + ...this.getTsdavRequestConfig(), }); const allEvents: FetchedCalendarEvent[] = []; @@ -378,7 +384,7 @@ export class CalDAVClient { }, objectUrls: objectUrls, depth: '1', - headers: this.headers, + ...this.getTsdavRequestConfig(), }); for (const calendarObject of calendarObjects) { @@ -426,7 +432,7 @@ export class CalDAVClient { const account = await this.getAccount(); const updatedCalendars = await fetchCalendars({ account, - headers: this.headers, + ...this.getTsdavRequestConfig(), }); const updatedCalendar = updatedCalendars.find( (cal) => cal.url === calendar.url, diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts new file mode 100644 index 0000000000..83edb322fb --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/__tests__/caldav.provider.spec.ts @@ -0,0 +1,52 @@ +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; +import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider'; +import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; + +jest.mock( + 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client', + () => ({ + CalDAVClient: jest.fn().mockImplementation((creds) => creds), + }), +); + +import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client'; + +const MockCalDAVClient = jest.mocked(CalDAVClient); + +describe('CalDavClientProvider', () => { + describe('getCalDavCalendarClient', () => { + it('should pass SSRF-safe fetch to CalDAVClient', async () => { + const fakeFetch = jest.fn(); + const mockSecureHttpClientService = { + createSsrfSafeFetch: jest.fn().mockReturnValue(fakeFetch), + } as unknown as SecureHttpClientService; + + const provider = new CalDavClientProvider(mockSecureHttpClientService); + + const connectedAccount = { + id: 'account-1', + provider: 'IMAP_SMTP_CALDAV', + handle: 'user@example.com', + connectionParameters: { + CALDAV: { + host: 'https://caldav.example.com', + password: 'secret', + username: 'caldav-user', + }, + }, + } as unknown as Pick< + ConnectedAccountEntity, + 'id' | 'provider' | 'connectionParameters' | 'handle' + >; + + await provider.getCalDavCalendarClient(connectedAccount); + + expect( + mockSecureHttpClientService.createSsrfSafeFetch, + ).toHaveBeenCalledTimes(1); + expect(MockCalDAVClient).toHaveBeenCalledWith( + expect.objectContaining({ fetch: fakeFetch }), + ); + }); + }); +}); 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 1e6715745a..c455bc88f0 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 @@ -26,10 +26,8 @@ export class CalDavClientProvider { throw new Error('Missing required CalDAV connection parameters'); } - await this.secureHttpClientService.getValidatedHost( - connectedAccount.connectionParameters.CALDAV.host, - ); const serverUrl = connectedAccount.connectionParameters.CALDAV.host; + const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch(); return new CalDAVClient({ username: @@ -37,6 +35,7 @@ export class CalDavClientProvider { connectedAccount.handle, password: connectedAccount.connectionParameters.CALDAV.password, serverUrl, + fetch: ssrfSafeFetch, }); } } diff --git a/yarn.lock b/yarn.lock index cdcfe5d875..f5067e57b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10956,6 +10956,15 @@ __metadata: languageName: node linkType: hard +"@lifeomic/axios-fetch@npm:^3.1.0": + version: 3.1.0 + resolution: "@lifeomic/axios-fetch@npm:3.1.0" + dependencies: + "@types/node-fetch": "npm:^2.5.10" + checksum: 10c0/28e161c1473372954cb86f55f93d9c6f8116ea463678d97e3ceecc32a315f668e9ad63371646aec1ef205059d60c25a76b83424c85d95279bbf3925c630b2729 + languageName: node + linkType: hard + "@linaria/core@npm:^6.2.0": version: 6.3.0 resolution: "@linaria/core@npm:6.3.0" @@ -25697,6 +25706,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.5.10": + version: 2.6.13 + resolution: "@types/node-fetch@npm:2.6.13" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.4" + checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309 + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.6.4": version: 2.6.11 resolution: "@types/node-fetch@npm:2.6.11" @@ -39311,7 +39330,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.5, form-data@npm:~4.0.4": +"form-data@npm:^4.0.4, form-data@npm:^4.0.5, form-data@npm:~4.0.4": version: 4.0.5 resolution: "form-data@npm:4.0.5" dependencies: @@ -60640,6 +60659,7 @@ __metadata: "@graphql-tools/utils": "npm:9.2.1" "@graphql-yoga/nestjs": "npm:2.1.0" "@jrmdayn/googleapis-batcher": "npm:^0.10.1" + "@lifeomic/axios-fetch": "npm:^3.1.0" "@lingui/cli": "npm:^5.1.2" "@lingui/conf": "npm:5.1.2" "@lingui/core": "npm:^5.1.2"