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:
@@ -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",
|
||||
|
||||
+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],
|
||||
|
||||
+79
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
-2
@@ -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 {
|
||||
|
||||
+39
@@ -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());
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
+17
-11
@@ -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,
|
||||
|
||||
+52
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user