fix: CalDAV credential verification between multiple instances (#22331)
Co-authored-by: zomars@cal.com <zomars@me.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
import type { DestinationCalendar } from "@prisma/client";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
import EventManager from "./EventManager";
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedSymmetricDecrypt = vi.mocked(symmetricDecrypt);
|
||||
|
||||
function buildCalDAVCredential(data: {
|
||||
id: number;
|
||||
key: string;
|
||||
userId?: number;
|
||||
}): CredentialForCalendarService {
|
||||
return {
|
||||
id: data.id,
|
||||
type: "caldav_calendar",
|
||||
key: data.key,
|
||||
userId: data.userId || 1,
|
||||
user: { email: "test@example.com" },
|
||||
teamId: null,
|
||||
appId: "caldav-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDestinationCalendar(data: {
|
||||
id: number;
|
||||
integration: string;
|
||||
externalId: string | null;
|
||||
}): DestinationCalendar {
|
||||
return {
|
||||
id: data.id,
|
||||
integration: data.integration,
|
||||
externalId: data.externalId || "",
|
||||
userId: 1,
|
||||
eventTypeId: null,
|
||||
credentialId: null,
|
||||
primaryEmail: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("EventManager CalDAV credential validation", () => {
|
||||
let eventManager: EventManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
eventManager = new EventManager({
|
||||
credentials: [],
|
||||
destinationCalendar: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractServerUrlFromCredential", () => {
|
||||
it("should extract server URL from valid CalDAV credential", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBe("https://caldav.example.com");
|
||||
expect(mockedSymmetricDecrypt).toHaveBeenCalledWith(
|
||||
"encrypted_key",
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for non-CalDAV credential", () => {
|
||||
const credential = {
|
||||
...buildCalDAVCredential({ id: 1, key: "encrypted_key" }),
|
||||
type: "google_calendar",
|
||||
} as CredentialForCalendarService;
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
expect(mockedSymmetricDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return null when decryption fails", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "invalid_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when decrypted data has no URL", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when URL is invalid", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "invalid-url",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle URLs with different ports", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com:8443/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBe("https://caldav.example.com:8443");
|
||||
});
|
||||
|
||||
it("should handle HTTP URLs", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "http://caldav.internal.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBe("http://caldav.internal.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractServerUrlFromDestination", () => {
|
||||
it("should extract server URL from CalDAV destination calendar", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBe("https://caldav.example.com");
|
||||
});
|
||||
|
||||
it("should return null for non-CalDAV destination", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "google_calendar",
|
||||
externalId: "calendar@gmail.com",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when externalId is null", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: null,
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when externalId is invalid URL", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "invalid-url",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle URLs with different ports", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com:8443/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBe("https://caldav.example.com:8443");
|
||||
});
|
||||
|
||||
it("should handle HTTP URLs", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "http://caldav.internal.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBe("http://caldav.internal.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("credentialMatchesDestination", () => {
|
||||
it("should return true for matching CalDAV server URLs", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-matching CalDAV server URLs", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://different.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for non-CalDAV credentials", () => {
|
||||
const credential = {
|
||||
...buildCalDAVCredential({ id: 1, key: "encrypted_key" }),
|
||||
type: "google_calendar",
|
||||
} as CredentialForCalendarService;
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "google_calendar",
|
||||
externalId: "calendar@gmail.com",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
expect(symmetricDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true when credential is CalDAV but destination is not", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "google_calendar",
|
||||
externalId: "calendar@gmail.com",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
expect(symmetricDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false when credential URL extraction fails", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "invalid_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when destination URL extraction fails", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "invalid-url",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle URLs with different paths but same server", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/different/path/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle URLs with different ports as non-matching", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com:8443/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com:9443/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle HTTP vs HTTPS as non-matching", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "http://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import getApps from "@calcom/app-store/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getUid } from "@calcom/lib/CalEventParser";
|
||||
import CRMScheduler from "@calcom/lib/crmManager/tasker/crmScheduler";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import {
|
||||
getPiiFreeDestinationCalendar,
|
||||
@@ -40,6 +41,8 @@ import { isDelegationCredential } from "./delegationCredential/clientAndServer";
|
||||
import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["EventManager"] });
|
||||
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
const CALDAV_CALENDAR_TYPE = "caldav_calendar";
|
||||
export const isDedicatedIntegration = (location: string): boolean => {
|
||||
return location !== MeetLocationType && location.includes("integrations:");
|
||||
};
|
||||
@@ -172,6 +175,82 @@ export default class EventManager {
|
||||
this.appOptions = eventTypeAppMetadata;
|
||||
}
|
||||
|
||||
private extractServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
|
||||
try {
|
||||
if (credential.type !== CALDAV_CALENDAR_TYPE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
if (!decryptedData.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(decryptedData.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
log.warn("Failed to extract server URL from CalDAV credential", {
|
||||
credentialId: credential.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractServerUrlFromDestination(destination: DestinationCalendar): string | null {
|
||||
try {
|
||||
if (destination.integration !== CALDAV_CALENDAR_TYPE || !destination.externalId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(destination.externalId);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
log.warn("Failed to extract server URL from destination calendar", {
|
||||
destinationId: destination.id,
|
||||
externalId: destination.externalId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private credentialMatchesDestination(
|
||||
credential: CredentialForCalendarService,
|
||||
destination: DestinationCalendar
|
||||
): boolean {
|
||||
if (credential.type !== CALDAV_CALENDAR_TYPE || destination.integration !== CALDAV_CALENDAR_TYPE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentialServerUrl = this.extractServerUrlFromCredential(credential);
|
||||
const destinationServerUrl = this.extractServerUrlFromDestination(destination);
|
||||
|
||||
if (!credentialServerUrl || !destinationServerUrl) {
|
||||
log.warn("Could not extract server URLs for CalDAV credential validation", {
|
||||
credentialId: credential.id,
|
||||
destinationId: destination.id,
|
||||
credentialServerUrl,
|
||||
destinationServerUrl,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = credentialServerUrl === destinationServerUrl;
|
||||
|
||||
if (!matches) {
|
||||
log.warn("CalDAV credential server URL does not match destination calendar server URL", {
|
||||
credentialId: credential.id,
|
||||
destinationId: destination.id,
|
||||
credentialServerUrl,
|
||||
destinationServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a CalendarEvent and creates all necessary integration entries for it.
|
||||
* When a video integration is chosen as the event's location, a video integration
|
||||
@@ -774,18 +853,33 @@ export default class EventManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const destinationCalendarCredentials = this.calendarCredentials.filter(
|
||||
(c) => c.type === destination.integration
|
||||
);
|
||||
const destinationCalendarCredentials = this.calendarCredentials.filter((c) => {
|
||||
if (c.type !== destination.integration) return false;
|
||||
|
||||
if (c.type === CALDAV_CALENDAR_TYPE) {
|
||||
return this.credentialMatchesDestination(c, destination);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
// It might not be the first connected calendar as it seems that the order is not guaranteed to be ascending of credentialId.
|
||||
const firstCalendarCredential = destinationCalendarCredentials[0] as
|
||||
| (typeof destinationCalendarCredentials)[number]
|
||||
| undefined;
|
||||
|
||||
if (!firstCalendarCredential) {
|
||||
log.warn(
|
||||
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
|
||||
);
|
||||
if (destination.integration === CALDAV_CALENDAR_TYPE) {
|
||||
log.warn(
|
||||
"No CalDAV credentials found with matching server URL for destination calendar. This prevents credential leakage.",
|
||||
safeStringify({
|
||||
destination: getPiiFreeDestinationCalendar(destination),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
log.warn(
|
||||
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
|
||||
);
|
||||
}
|
||||
await fallbackToFirstCalendarInTheList();
|
||||
} else {
|
||||
log.warn(
|
||||
|
||||
@@ -7,7 +7,17 @@ import logger from "@calcom/lib/logger";
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import getCalendarsEvents, { getCalendarsEventsWithTimezones } from "./getCalendarsEvents";
|
||||
import { symmetricDecrypt } from "./crypto";
|
||||
import getCalendarsEvents, {
|
||||
getCalendarsEventsWithTimezones,
|
||||
filterSelectedCalendarsForCredential,
|
||||
} from "./getCalendarsEvents";
|
||||
|
||||
vi.mock("./crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedSymmetricDecrypt = vi.mocked(symmetricDecrypt);
|
||||
|
||||
function buildDelegationCredential(credential: CredentialPayload): CredentialForCalendarService {
|
||||
return {
|
||||
@@ -49,6 +59,12 @@ function buildSelectedCalendar(credential: {
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastErrorAt: null,
|
||||
watchAttempts: 0,
|
||||
unwatchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
...credential,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +89,8 @@ describe("getCalendarsEvents", () => {
|
||||
teamId: null,
|
||||
appId: "exampleApp",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
user: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -324,6 +342,7 @@ describe("getCalendarsEventsWithTimezones", () => {
|
||||
user: null,
|
||||
appId: "exampleApp",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -471,3 +490,214 @@ describe("getCalendarsEventsWithTimezones", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// CalDAV Credential Leak Prevention Tests
|
||||
describe("CalDAV credential leak prevention", () => {
|
||||
function buildCalDAVCredential(data: {
|
||||
id: number;
|
||||
key: string;
|
||||
userId?: number;
|
||||
}): CredentialForCalendarService {
|
||||
return {
|
||||
id: data.id,
|
||||
type: "caldav_calendar",
|
||||
key: data.key,
|
||||
userId: data.userId || 1,
|
||||
user: { email: "test@example.com" },
|
||||
teamId: null,
|
||||
appId: "caldav-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCalDAVSelectedCalendar(data: {
|
||||
id: string;
|
||||
externalId: string;
|
||||
credentialId?: number;
|
||||
}): SelectedCalendar {
|
||||
return {
|
||||
id: data.id,
|
||||
userId: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: data.externalId,
|
||||
credentialId: data.credentialId || null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
googleChannelId: null,
|
||||
googleChannelKind: null,
|
||||
googleChannelResourceId: null,
|
||||
googleChannelResourceUri: null,
|
||||
googleChannelExpiration: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
error: null,
|
||||
lastErrorAt: null,
|
||||
watchAttempts: 0,
|
||||
unwatchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
eventTypeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("filterSelectedCalendarsForCredential", () => {
|
||||
it("prevents CalDAV credential leak by matching server URLs", () => {
|
||||
// Setup: Two CalDAV servers with different URLs
|
||||
const serverACredential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_server_a_key",
|
||||
});
|
||||
|
||||
const serverBCredential = buildCalDAVCredential({
|
||||
id: 2,
|
||||
key: "encrypted_server_b_key",
|
||||
});
|
||||
|
||||
// Mock encrypted credential data for different servers
|
||||
mockedSymmetricDecrypt
|
||||
.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
username: "user_a",
|
||||
password: "pass_a",
|
||||
url: "https://server-a.example.com/dav/calendars/user/",
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
username: "user_b",
|
||||
password: "pass_b",
|
||||
url: "https://server-b.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
// Selected calendars from both servers
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
credentialId: 1,
|
||||
}),
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_2",
|
||||
externalId: "https://server-b.example.com/dav/calendars/user/calendar2/",
|
||||
credentialId: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
// Test Server A credential - should only return Server A calendars
|
||||
const serverACalendars = filterSelectedCalendarsForCredential(selectedCalendars, serverACredential);
|
||||
expect(serverACalendars).toHaveLength(1);
|
||||
expect(serverACalendars[0].externalId).toBe(
|
||||
"https://server-a.example.com/dav/calendars/user/calendar1/"
|
||||
);
|
||||
|
||||
// Test Server B credential - should only return Server B calendars
|
||||
const serverBCalendars = filterSelectedCalendarsForCredential(selectedCalendars, serverBCredential);
|
||||
expect(serverBCalendars).toHaveLength(1);
|
||||
expect(serverBCalendars[0].externalId).toBe(
|
||||
"https://server-b.example.com/dav/calendars/user/calendar2/"
|
||||
);
|
||||
});
|
||||
|
||||
it("demonstrates the credential leak that existed before the fix", () => {
|
||||
// This test shows what WOULD happen with naive filtering (integration type only)
|
||||
const serverACredential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_server_a_key",
|
||||
});
|
||||
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
credentialId: 1,
|
||||
}),
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_2",
|
||||
externalId: "https://server-b.example.com/dav/calendars/user/calendar2/",
|
||||
credentialId: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
// Legacy filtering (type-only) would return ALL CalDAV calendars
|
||||
const legacyFiltering = selectedCalendars.filter((sc) => sc.integration === "caldav_calendar");
|
||||
expect(legacyFiltering).toHaveLength(2); // This demonstrates the leak - both calendars returned
|
||||
|
||||
// Our new filtering prevents this
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user_a",
|
||||
password: "pass_a",
|
||||
url: "https://server-a.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const secureFiltering = filterSelectedCalendarsForCredential(selectedCalendars, serverACredential);
|
||||
expect(secureFiltering).toHaveLength(1); // Only calendars from matching server
|
||||
expect(secureFiltering[0].externalId).toContain("server-a.example.com");
|
||||
});
|
||||
|
||||
it("handles non-CalDAV calendars normally", () => {
|
||||
const googleCredential: CredentialForCalendarService = {
|
||||
id: 1,
|
||||
type: "google_calendar",
|
||||
key: "google_key",
|
||||
userId: 1,
|
||||
user: { email: "test@example.com" },
|
||||
teamId: null,
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
}),
|
||||
{
|
||||
...buildCalDAVSelectedCalendar({
|
||||
id: "cal_2",
|
||||
externalId: "primary",
|
||||
}),
|
||||
integration: "google_calendar",
|
||||
},
|
||||
];
|
||||
|
||||
const googleCalendars = filterSelectedCalendarsForCredential(selectedCalendars, googleCredential);
|
||||
expect(googleCalendars).toHaveLength(1);
|
||||
expect(googleCalendars[0].integration).toBe("google_calendar");
|
||||
});
|
||||
|
||||
it("handles invalid CalDAV credential URLs gracefully", () => {
|
||||
const invalidCredential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_invalid_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "invalid-url-format",
|
||||
})
|
||||
);
|
||||
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterSelectedCalendarsForCredential(selectedCalendars, invalidCredential);
|
||||
expect(result).toHaveLength(0); // Should return empty array for safety
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
import { symmetricDecrypt } from "./crypto";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
|
||||
|
||||
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
// only for Google Calendar for now
|
||||
export const getCalendarsEventsWithTimezones = async (
|
||||
withCredentials: CredentialForCalendarService[],
|
||||
@@ -35,15 +39,16 @@ export const getCalendarsEventsWithTimezones = async (
|
||||
if (!c) return [];
|
||||
/** We rely on the index so we can match credentials with calendars */
|
||||
const { type } = calendarCredentials[i];
|
||||
const credential = calendarToCredentialMap.get(c);
|
||||
/** We just pass the calendars that matched the credential type,
|
||||
* TODO: Migrate credential type or appId
|
||||
*/
|
||||
const passedSelectedCalendars = selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
|
||||
const credential = calendarToCredentialMap.get(c);
|
||||
const passedSelectedCalendars = credential
|
||||
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
|
||||
: selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
|
||||
// We want to fallback to primary calendar when no selectedCalendars are passed
|
||||
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
|
||||
@@ -103,15 +108,17 @@ const getCalendarsEvents = async (
|
||||
if (!calendarService) return [];
|
||||
/** We rely on the index so we can match credentials with calendars */
|
||||
const { type, appId } = calendarCredentials[i];
|
||||
const credential = calendarToCredentialMap.get(calendarService);
|
||||
/** We just pass the calendars that matched the credential type,
|
||||
* TODO: Migrate credential type or appId
|
||||
*/
|
||||
// Important to have them unique so that
|
||||
const passedSelectedCalendars = selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
const credential = calendarToCredentialMap.get(calendarService);
|
||||
const passedSelectedCalendars = credential
|
||||
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
|
||||
: selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
|
||||
// We want to fallback to primary calendar when no selectedCalendars are passed
|
||||
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
|
||||
@@ -179,3 +186,90 @@ const getCalendarsEvents = async (
|
||||
};
|
||||
|
||||
export default getCalendarsEvents;
|
||||
|
||||
/**
|
||||
* Extract server URL from CalDAV calendar externalId
|
||||
*/
|
||||
function getServerUrlFromCalendarExternalId(externalId: string): string | null {
|
||||
try {
|
||||
const url = new URL(externalId);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract server URL from CalDAV credential
|
||||
*/
|
||||
function getServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
|
||||
try {
|
||||
if (credential.type !== "caldav_calendar") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
if (!decryptedData.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(decryptedData.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter selected calendars for the specific credential, handling CalDAV server URL matching
|
||||
*/
|
||||
export function filterSelectedCalendarsForCredential(
|
||||
selectedCalendars: SelectedCalendar[],
|
||||
credential: CredentialForCalendarService
|
||||
): SelectedCalendar[] {
|
||||
const { type } = credential;
|
||||
|
||||
// For all other calendar types, use the existing logic
|
||||
if (type !== "caldav_calendar") {
|
||||
return selectedCalendars.filter((sc) => sc.integration === type);
|
||||
}
|
||||
|
||||
const credentialServerUrl = getServerUrlFromCredential(credential);
|
||||
|
||||
if (!credentialServerUrl) {
|
||||
log.warn("Could not extract server URL from CalDAV credential", {
|
||||
credentialId: credential.id,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectedCalendars.filter((sc) => {
|
||||
if (sc.integration !== type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const calendarServerUrl = getServerUrlFromCalendarExternalId(sc.externalId);
|
||||
|
||||
if (!calendarServerUrl) {
|
||||
log.warn("Could not extract server URL from calendar externalId", {
|
||||
externalId: sc.externalId,
|
||||
integration: sc.integration,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = credentialServerUrl === calendarServerUrl;
|
||||
|
||||
if (!matches) {
|
||||
log.debug("CalDAV calendar server URL does not match credential server URL", {
|
||||
credentialId: credential.id,
|
||||
credentialServerUrl,
|
||||
calendarServerUrl,
|
||||
calendarExternalId: sc.externalId,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user