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 { 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"; 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 }; } 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$/;