From f117b2df320da1ca3d00c37a3770b2f2f1260165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Mon, 14 Jul 2025 17:32:52 -0700 Subject: [PATCH] fix: CalDAV credential verification between multiple instances (#22331) Co-authored-by: zomars@cal.com Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/lib/EventManager.test.ts | 452 ++++++++++++++++++++++++ packages/lib/EventManager.ts | 106 +++++- packages/lib/getCalendarsEvents.test.ts | 232 +++++++++++- packages/lib/getCalendarsEvents.ts | 116 +++++- 4 files changed, 888 insertions(+), 18 deletions(-) create mode 100644 packages/lib/EventManager.test.ts diff --git a/packages/lib/EventManager.test.ts b/packages/lib/EventManager.test.ts new file mode 100644 index 0000000000..04bb588305 --- /dev/null +++ b/packages/lib/EventManager.test.ts @@ -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); + }); + }); +}); diff --git a/packages/lib/EventManager.ts b/packages/lib/EventManager.ts index 06277cdd51..24ba8509d3 100644 --- a/packages/lib/EventManager.ts +++ b/packages/lib/EventManager.ts @@ -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( diff --git a/packages/lib/getCalendarsEvents.test.ts b/packages/lib/getCalendarsEvents.test.ts index 189643b7ce..bf6a1d7937 100644 --- a/packages/lib/getCalendarsEvents.test.ts +++ b/packages/lib/getCalendarsEvents.test.ts @@ -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 + }); + }); +}); diff --git a/packages/lib/getCalendarsEvents.ts b/packages/lib/getCalendarsEvents.ts index b045e0678d..d382dc6a75 100644 --- a/packages/lib/getCalendarsEvents.ts +++ b/packages/lib/getCalendarsEvents.ts @@ -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; + }); +}