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
This commit is contained in:
neo773
2026-04-29 17:43:19 +05:30
committed by GitHub
parent 480e5796ec
commit abfa6200dd
10 changed files with 235 additions and 17 deletions
+1
View File
@@ -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",
@@ -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],
@@ -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>(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 }),
);
});
});
});
@@ -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<ConnectedAccountEntity>,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
async testImapConnection(
handle: string,
params: ConnectionParameters,
): Promise<boolean> {
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<boolean> {
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<boolean> {
const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch();
const client = new CalDAVClient({
serverUrl: params.host,
username: params.username ?? handle,
password: params.password,
fetch: ssrfSafeFetch,
});
try {
@@ -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());
@@ -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<string> {
if (!this.isSafeModeEnabled()) {
return hostnameOrUrl;
@@ -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<string, string>;
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<DAVCalendar, 'displayName'> & {
displayName?: string | Record<string, unknown>;
})[];
@@ -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,
@@ -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 }),
);
});
});
});
@@ -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,
});
}
}
+21 -1
View File
@@ -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"