Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/playwright/booking-sheet-keyboard.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions apps/web/playwright/bookings-list.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions packages/features/bookings/lib/EventManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
92 changes: 92 additions & 0 deletions packages/lib/getReplyToHeader.test.ts
Original file line number Diff line number Diff line change
@@ -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("]");
});
});
});
2 changes: 1 addition & 1 deletion packages/lib/getReplyToHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ export function getReplyToHeader(
return {};
}

const replyTo = emailArray.length === 1 ? emailArray[0] : emailArray;
const replyTo = emailArray.join(", ");
return { replyTo };
}
21 changes: 13 additions & 8 deletions packages/lib/ssrfProtection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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,<script>alert(1)</script>", "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"],
Expand All @@ -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"],
Expand Down
6 changes: 4 additions & 2 deletions packages/lib/ssrfProtection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;

Expand Down
Loading