From fbf6510dd85be1a4c5fad9eb88013df30f875812 Mon Sep 17 00:00:00 2001 From: Lawrence Christian <67164412+laurenschristian@users.noreply.github.com> Date: Sat, 28 Mar 2026 08:08:52 -0500 Subject: [PATCH 1/4] fix: join Reply-To addresses as string for SMTP compatibility (#28611) * fix: join multiple Reply-To addresses as comma-separated string Some SMTP providers (e.g., SendLayer) reject emails when Reply-To is passed as an array to nodemailer, which serializes it as multiple Reply-To headers. Using a comma-joined string is RFC 2822 compliant and works universally across all SMTP providers. Fixes #28610 * test: add unit tests for getReplyToHeader SMTP compatibility - Verify replyTo is always returned as comma-separated string, not array - Test single email, multiple emails, and empty email cases - Add RFC 5322 compliance test for SMTP compatibility Co-Authored-By: Claude Opus 4.5 * docs: add RFC 5322 reference comment to getReplyToHeader tests Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Lawrence Christian <67164412+LCNDevs@users.noreply.github.com> Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com> Co-authored-by: Romit Co-authored-by: Claude Opus 4.5 --- packages/lib/getReplyToHeader.test.ts | 92 +++++++++++++++++++++++++++ packages/lib/getReplyToHeader.ts | 2 +- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/lib/getReplyToHeader.test.ts diff --git a/packages/lib/getReplyToHeader.test.ts b/packages/lib/getReplyToHeader.test.ts new file mode 100644 index 00000000000000..0b558d948a228f --- /dev/null +++ b/packages/lib/getReplyToHeader.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getReplyToHeader } from "./getReplyToHeader"; + +/** + * RFC 5322 (Internet Message Format) specifies that the Reply-To header must be + * a comma-separated list of addresses, not an array. Many SMTP servers reject arrays. + * Spec: https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 + */ + +vi.mock("./getReplyToEmail", () => ({ + getReplyToEmail: vi.fn((calEvent, excludeOrganizerEmail) => { + if (excludeOrganizerEmail) return null; + return calEvent.organizer?.email || null; + }), +})); + +const createMockCalEvent = (organizerEmail: string) => ({ + organizer: { email: organizerEmail }, + hideOrganizerEmail: false, +}); + +describe("getReplyToHeader", () => { + describe("return type", () => { + it("always returns replyTo as a string, never an array", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["attendee1@test.com", "attendee2@test.com"]); + + expect(result).toHaveProperty("replyTo"); + expect(typeof result.replyTo).toBe("string"); + // Should NOT be an array + expect(Array.isArray(result.replyTo)).toBe(false); + }); + }); + + describe("with single email", () => { + it("returns single email as string", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any); + + expect(result).toEqual({ replyTo: "organizer@test.com" }); + }); + + it("returns additionalEmail as string when provided alone", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, "additional@test.com", true); + + expect(result).toEqual({ replyTo: "additional@test.com" }); + }); + }); + + describe("with multiple emails", () => { + it("returns comma-separated string for multiple emails", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["attendee1@test.com", "attendee2@test.com"]); + + expect(result).toEqual({ + replyTo: "attendee1@test.com, attendee2@test.com, organizer@test.com", + }); + }); + + it("returns comma-separated string when additionalEmails is array", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["a@test.com", "b@test.com", "c@test.com"], true); + + expect(result).toEqual({ + replyTo: "a@test.com, b@test.com, c@test.com", + }); + }); + }); + + describe("with no emails", () => { + it("returns empty object when no emails available", () => { + const calEvent = { organizer: { email: "" }, hideOrganizerEmail: true }; + const result = getReplyToHeader(calEvent as any, undefined, true); + + expect(result).toEqual({}); + }); + }); + + describe("SMTP compatibility", () => { + it("produces RFC 5322 compliant Reply-To header format", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["a@test.com", "b@test.com"]); + + // RFC 5322 specifies comma-separated list for multiple addresses + expect(result.replyTo).toMatch(/^[^,]+, [^,]+, [^,]+$/); + expect(result.replyTo).not.toContain("["); + expect(result.replyTo).not.toContain("]"); + }); + }); +}); diff --git a/packages/lib/getReplyToHeader.ts b/packages/lib/getReplyToHeader.ts index 36254dfe54000a..cff6e458e1aab9 100644 --- a/packages/lib/getReplyToHeader.ts +++ b/packages/lib/getReplyToHeader.ts @@ -26,6 +26,6 @@ export function getReplyToHeader( return {}; } - const replyTo = emailArray.length === 1 ? emailArray[0] : emailArray; + const replyTo = emailArray.join(", "); return { replyTo }; } From ad791f8ea5baaef062d13e5aeabf9a3a0e61923e Mon Sep 17 00:00:00 2001 From: Keon Date: Sat, 28 Mar 2026 09:21:58 -0400 Subject: [PATCH 2/4] fix: block localhost and loopback addresses in SSRF protection (#28622) * fix: block localhost and loopback addresses in SSRF protection Co-Authored-By: Claude Opus 4.6 (1M context) * fix: block loopback IPs by hostname in SSRF protection Add 127.0.0.1, ::1, [::1], and 0.0.0.0 to blocked hostnames list for defense-in-depth protection against SSRF attacks targeting localhost. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Romit Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com> --- packages/lib/ssrfProtection.test.ts | 21 +++++++++++++-------- packages/lib/ssrfProtection.ts | 6 ++++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/lib/ssrfProtection.test.ts b/packages/lib/ssrfProtection.test.ts index d65dfd5c818d2e..685bd7145277f2 100644 --- a/packages/lib/ssrfProtection.test.ts +++ b/packages/lib/ssrfProtection.test.ts @@ -57,12 +57,16 @@ describe("isPrivateIP", () => { describe("isBlockedHostname", () => { it.each([ - "localhost", // loopback hostname - "169.254.169.254", // AWS/Azure/DigitalOcean - "metadata.google.internal", // GCP - "169.254.169.254.", // trailing dot normalization - "METADATA.GOOGLE.INTERNAL", // case insensitive - ])("blocks cloud metadata endpoint %s", (hostname) => { + "localhost", + "127.0.0.1", + "::1", + "[::1]", + "0.0.0.0", + "169.254.169.254", + "metadata.google.internal", + "169.254.169.254.", + "METADATA.GOOGLE.INTERNAL", + ])("blocks %s", (hostname) => { expect(isBlockedHostname(hostname)).toBe(true); }); @@ -104,7 +108,8 @@ describe("validateUrlForSSRFSync", () => { ["http://example.com/logo.png", "Only HTTPS URLs are allowed"], ["ftp://example.com/file", "Only HTTPS URLs are allowed"], ["data:text/html,", "Non-image data URL"], - ["https://127.0.0.1/logo.png", "Private IP address"], + ["https://127.0.0.1/logo.png", "Blocked hostname"], + ["https://0.0.0.0/logo.png", "Blocked hostname"], ["https://169.254.169.254/latest/meta-data/", "Blocked hostname"], ["https://localhost/logo.png", "Blocked hostname"], ["not-a-url", "Invalid URL format"], @@ -114,7 +119,7 @@ describe("validateUrlForSSRFSync", () => { }); it.each([ - ["https://[::1]/", "Private IP address"], + ["https://[::1]/", "Blocked hostname"], ["https://[fe80::1]/path", "Private IP address"], ["https://[fc00::1]:8080/", "Private IP address"], ["https://[::ffff:127.0.0.1]/", "Private IP address"], diff --git a/packages/lib/ssrfProtection.ts b/packages/lib/ssrfProtection.ts index 242cfebfaa59b9..1333d6b9845da9 100644 --- a/packages/lib/ssrfProtection.ts +++ b/packages/lib/ssrfProtection.ts @@ -31,8 +31,10 @@ const CLOUD_METADATA_ENDPOINTS: string[] = [ "metadata.google.com", // GCP alternate ]; -// Hostnames blocked on Cal.com SaaS (includes metadata + localhost) -const BLOCKED_HOSTNAMES: string[] = [...CLOUD_METADATA_ENDPOINTS, "localhost"]; +const LOOPBACK_HOSTNAMES: string[] = ["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]; + +// Hostnames blocked on Cal.com SaaS (includes metadata + loopback) +const BLOCKED_HOSTNAMES: string[] = [...CLOUD_METADATA_ENDPOINTS, ...LOOPBACK_HOSTNAMES]; const CAL_AVATAR_PATH_REGEX = /^\/api\/avatar\/.+\.png$/; From 0f4717e0be7da347c3c7cb0b4d241eb7e5b39ba4 Mon Sep 17 00:00:00 2001 From: Romit <85230081+romitg2@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:07:18 +0530 Subject: [PATCH 3/4] fix: mock @calcom/lib/i18n in EventManager.test.ts to prevent vitest worker shutdown flake (#28630) * fix: mock @calcom/lib/i18n in EventManager.test.ts to prevent vitest worker shutdown flake TranslationService transitively imports @calcom/lib/i18n which triggers slow module resolution via vite's RPC. When the vitest worker shuts down before it completes, it causes 'Closing rpc while fetch was pending' errors. Mocking the module prevents the actual module resolution during test loading. Co-Authored-By: romitgabani1 * chore: remove explanatory comments per review feedback Co-Authored-By: romitgabani1 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/features/bookings/lib/EventManager.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/features/bookings/lib/EventManager.test.ts b/packages/features/bookings/lib/EventManager.test.ts index 516a5e7b478ce6..1eff2eb1a2f232 100644 --- a/packages/features/bookings/lib/EventManager.test.ts +++ b/packages/features/bookings/lib/EventManager.test.ts @@ -18,6 +18,11 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro vi.mock("@calcom/features/watchlist/lib/telemetry", () => ({ sentrySpan: vi.fn(), })); +vi.mock("@calcom/lib/i18n", () => ({ + locales: ["en"], + localeOptions: [{ value: "en", label: "English" }], + defaultLocaleOption: { value: "en", label: "English" }, +})); import process from "node:process"; import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; From 31f40764da370a1e6351e53e4895da7407de2697 Mon Sep 17 00:00:00 2001 From: Romit <85230081+romitg2@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:07:55 +0530 Subject: [PATCH 4/4] fix: stabilize flaky E2E booking sheet tests by waiting for button visibility (#28631) Both booking-sheet-keyboard.e2e.ts and bookings-list.e2e.ts intermittently timeout in CI when clicking the booking item button before the DOM has finished rendering. Adding explicit waitFor({ state: 'visible' }) on the role=button element after the parent booking item is visible ensures the click target is fully ready. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/playwright/booking-sheet-keyboard.e2e.ts | 4 +++- apps/web/playwright/bookings-list.e2e.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/playwright/booking-sheet-keyboard.e2e.ts b/apps/web/playwright/booking-sheet-keyboard.e2e.ts index 91a849903ac235..617efdbfcec479 100644 --- a/apps/web/playwright/booking-sheet-keyboard.e2e.ts +++ b/apps/web/playwright/booking-sheet-keyboard.e2e.ts @@ -100,7 +100,9 @@ async function setupBookingsAndOpenSheet({ const firstBookingItem = page.locator(`[data-booking-uid="${fixtures[0].uid}"]`); await expect(firstBookingItem).toBeVisible(); - await firstBookingItem.locator('[role="button"]').first().click(); + const firstButton = firstBookingItem.locator('[role="button"]').first(); + await firstButton.waitFor({ state: "visible" }); + await firstButton.click(); const sheet = page.locator('[role="dialog"]'); await expect(sheet).toBeVisible(); diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 41a3c1f20320e7..30251fdf7bd6c5 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -798,8 +798,9 @@ test.describe("Bookings", () => { const bookingItem = page.locator(`[data-booking-uid="${bookingFixture.uid}"]`); await expect(bookingItem).toBeVisible(); - - await bookingItem.locator('[role="button"]').first().click(); + const bookingButton = bookingItem.locator('[role="button"]').first(); + await bookingButton.waitFor({ state: "visible" }); + await bookingButton.click(); await expect(page).toHaveURL(new RegExp(`[?&]uid=${bookingFixture.uid}(&|$)`)); } finally {