diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index e4be05e95db..cf9c396eeb0 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -200,8 +200,9 @@ async function superRefineField({ } const emails = emailsParsed.data; - emails.sort().some((item, i) => { - if (item === emails[i + 1]) { + const sortedEmails = [...emails].sort(); + sortedEmails.some((item, i) => { + if (item === sortedEmails[i + 1]) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("duplicate_email") }); return true; } diff --git a/packages/lib/CalEventParser.test.ts b/packages/lib/CalEventParser.test.ts index bf6a50f4821..12b5350ba42 100644 --- a/packages/lib/CalEventParser.test.ts +++ b/packages/lib/CalEventParser.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { CalendarEvent } from "@calcom/types/Calendar"; -import { getRichDescription, getUserFieldsResponses } from "./CalEventParser"; +import { getProviderName, getRichDescription, getUserFieldsResponses } from "./CalEventParser"; describe("getRichDescription", () => { const t = ((key: string, _args?: Record) => key) as TFunction; @@ -86,6 +86,26 @@ describe("getRichDescription", () => { }); }); +describe("getProviderName", () => { + it("should return empty string for integrations: with no provider", () => { + expect(getProviderName("integrations:")).toBe(""); + }); + + it("should return capitalized provider name", () => { + expect(getProviderName("integrations:daily")).toBe("Cal Video"); + expect(getProviderName("integrations:zoom")).toBe("Zoom"); + }); + + it("should return empty string for null/undefined", () => { + expect(getProviderName(null)).toBe(""); + expect(getProviderName(undefined)).toBe(""); + }); + + it("should return URL as-is for http locations", () => { + expect(getProviderName("https://zoom.us/j/123")).toBe("https://zoom.us/j/123"); + }); +}); + describe("getUserFieldsResponses", () => { const t = ((key: string) => key) as TFunction; diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index c7388c3639a..1fe72ee7b9d 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -200,6 +200,7 @@ export const getLocation = (calEvent: { export const getProviderName = (location?: string | null): string => { if (location && location.includes("integrations:")) { let locationName = location.split(":")[1]; + if (!locationName) return ""; if (locationName === "daily") { locationName = "Cal Video"; } diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 5401ca25ccf..72c092c5c0c 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -137,8 +137,17 @@ export const API_NAME_LENGTH_MAX_LIMIT = 80; export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5"; export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1"; // Needed for orgs -export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[]; -export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[]; +const safeParseCsvEnvVar = (envVar: string | undefined): string[] => { + if (!envVar) return []; + try { + return JSON.parse(`[${envVar}]`); + } catch { + return envVar.split(",").map((s) => s.trim().replace(/^"|"$/g, "")); + } +}; + +export const ALLOWED_HOSTNAMES = safeParseCsvEnvVar(process.env.ALLOWED_HOSTNAMES); +export const RESERVED_SUBDOMAINS = safeParseCsvEnvVar(process.env.RESERVED_SUBDOMAINS); export const ORGANIZATION_SELF_SERVE_PRICE = parseFloat( process.env.NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW || "37" diff --git a/packages/lib/contructEmailFromPhoneNumber.ts b/packages/lib/contructEmailFromPhoneNumber.ts index 63d73a4e383..72477d91465 100644 --- a/packages/lib/contructEmailFromPhoneNumber.ts +++ b/packages/lib/contructEmailFromPhoneNumber.ts @@ -1,4 +1,5 @@ export const contructEmailFromPhoneNumber = (phoneNumber: string) => { + if (!phoneNumber) return ""; const cleanedPhoneNumber = phoneNumber.replace(/\+/g, ""); return `${cleanedPhoneNumber}@sms.cal.com`; }; diff --git a/packages/lib/csvUtils.test.ts b/packages/lib/csvUtils.test.ts new file mode 100644 index 00000000000..759df92921c --- /dev/null +++ b/packages/lib/csvUtils.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { downloadAsCsv, objectsToCsv, sanitizeValue } from "./csvUtils"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("sanitizeValue", () => { + it("returns simple values unchanged", () => { + expect(sanitizeValue("hello")).toBe("hello"); + }); + + it("wraps values with commas in quotes", () => { + expect(sanitizeValue("hello,world")).toBe('"hello,world"'); + }); + + it("wraps values with newlines in quotes", () => { + expect(sanitizeValue("hello\nworld")).toBe('"hello\nworld"'); + }); + + it("doubles quotes and wraps values containing double quotes", () => { + expect(sanitizeValue('he said "hello"')).toBe('"he said ""hello"""'); + }); + + it("handles values with quotes and commas", () => { + expect(sanitizeValue('he said "hello", world')).toBe('"he said ""hello"", world"'); + }); + + it("prefixes formula-trigger characters with single quote to prevent injection", () => { + expect(sanitizeValue("=HYPERLINK(http://evil.com)")).toBe("'=HYPERLINK(http://evil.com)"); + expect(sanitizeValue("+SUM(A1:A10)")).toBe("'+SUM(A1:A10)"); + expect(sanitizeValue("-SUM(A1:A10)")).toBe("'-SUM(A1:A10)"); + expect(sanitizeValue("@SUM(A1:A10)")).toBe("'@SUM(A1:A10)"); + }); + + it("does not prefix non-formula values", () => { + expect(sanitizeValue("hello")).toBe("hello"); + expect(sanitizeValue("123")).toBe("123"); + expect(sanitizeValue("john@example.com")).toBe("john@example.com"); + }); +}); + +describe("objectsToCsv", () => { + it("handles values with newlines correctly", () => { + const data = [ + { name: "Alice", note: "hello\nworld" }, + { name: "Bob", note: "normal" }, + ]; + const csv = objectsToCsv(data); + expect(csv).toBe('name,note\nAlice,"hello\nworld"\nBob,normal'); + }); +}); diff --git a/packages/lib/csvUtils.ts b/packages/lib/csvUtils.ts index 5746c433b3d..bc5cb93f085 100644 --- a/packages/lib/csvUtils.ts +++ b/packages/lib/csvUtils.ts @@ -57,5 +57,11 @@ export const sanitizeValue = (value: string) => { if (value.includes(",") || value.includes("\n")) { return `"${value}"`; } + // Protect against formula injection (OWASP). Values starting with =, +, -, @ + // are interpreted as formulas by Excel/Sheets. Prefix with a single quote to + // force text rendering, which is invisible in most spreadsheet apps. + if (/^[=+\-@]/.test(value)) { + return `'${value}`; + } return value; }; diff --git a/packages/lib/errors.ts b/packages/lib/errors.ts index 29613e129b4..28d34ccd1da 100644 --- a/packages/lib/errors.ts +++ b/packages/lib/errors.ts @@ -64,7 +64,7 @@ export async function handleErrorsJson(response: Response): Promise return new Promise((resolve) => resolve({} as Type)); } - if (!response.ok && (response.status < 200 || response.status >= 300)) { + if (!response.ok) { response.json().then(console.log); throw Error(response.statusText); } diff --git a/packages/lib/extract-base-email.test.ts b/packages/lib/extract-base-email.test.ts new file mode 100644 index 00000000000..7c5aea79669 --- /dev/null +++ b/packages/lib/extract-base-email.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { extractBaseEmail } from "./extract-base-email"; + +describe("extractBaseEmail", () => { + it("should extract base email removing plus aliases", () => { + expect(extractBaseEmail("user+alias@example.com")).toBe("user@example.com"); + }); + + it("should return email unchanged if no plus alias", () => { + expect(extractBaseEmail("user@example.com")).toBe("user@example.com"); + }); + + it("should return input unchanged if no @ sign", () => { + expect(extractBaseEmail("notanemail")).toBe("notanemail"); + }); + + it("should handle empty string", () => { + expect(extractBaseEmail("")).toBe(""); + }); +}); diff --git a/packages/lib/extract-base-email.ts b/packages/lib/extract-base-email.ts index a687011000f..f6f251d28f9 100644 --- a/packages/lib/extract-base-email.ts +++ b/packages/lib/extract-base-email.ts @@ -1,6 +1,9 @@ // Function to extract base email export const extractBaseEmail = (email: string): string => { - const [localPart, domain] = email.split("@"); + const atIndex = email.indexOf("@"); + if (atIndex === -1) return email; + const localPart = email.substring(0, atIndex); + const domain = email.substring(atIndex + 1); const baseLocalPart = localPart.split("+")[0]; return `${baseLocalPart}@${domain}`; }; diff --git a/packages/lib/formatPhoneNumber.ts b/packages/lib/formatPhoneNumber.ts index 1fcf66110cd..940654909a1 100644 --- a/packages/lib/formatPhoneNumber.ts +++ b/packages/lib/formatPhoneNumber.ts @@ -1,6 +1,10 @@ import { parsePhoneNumberWithError } from "libphonenumber-js/max"; export const formatPhoneNumber = (phoneNumber: string) => { - const parsedPhoneNumber = parsePhoneNumberWithError(phoneNumber); - return parsedPhoneNumber?.isValid() ? parsedPhoneNumber.formatInternational() : phoneNumber; + try { + const parsedPhoneNumber = parsePhoneNumberWithError(phoneNumber); + return parsedPhoneNumber?.isValid() ? parsedPhoneNumber.formatInternational() : phoneNumber; + } catch { + return phoneNumber; + } }; diff --git a/packages/lib/getIP.ts b/packages/lib/getIP.ts index 1962b417ced..ed0cc39785c 100644 --- a/packages/lib/getIP.ts +++ b/packages/lib/getIP.ts @@ -37,7 +37,12 @@ const banlistSchema = z.array(z.string()); export function isIpInBanlist(request: Request | NextApiRequest) { const IP = getIP(request); const rawBanListJson = process.env.IP_BANLIST || "[]"; - const banList = banlistSchema.parse(JSON.parse(rawBanListJson)); + let banList: string[] = []; + try { + banList = banlistSchema.parse(JSON.parse(rawBanListJson)); + } catch { + logger.error("Invalid IP_BANLIST JSON; treating as empty banlist"); + } if (banList.includes(IP)) { logger.warn(`Found banned IP: ${IP} in IP_BANLIST`); return true; @@ -47,7 +52,12 @@ export function isIpInBanlist(request: Request | NextApiRequest) { export function isIpInBanListString(identifer: string) { const rawBanListJson = process.env.IP_BANLIST || "[]"; - const banList = banlistSchema.parse(JSON.parse(rawBanListJson)); + let banList: string[] = []; + try { + banList = banlistSchema.parse(JSON.parse(rawBanListJson)); + } catch { + logger.error("Invalid IP_BANLIST JSON; treating as empty banlist"); + } if (banList.includes(identifer)) { logger.warn(`Found banned IP: ${identifer} in IP_BANLIST`); return true; diff --git a/packages/lib/getSafeRedirectUrl.ts b/packages/lib/getSafeRedirectUrl.ts index fb58fd308db..cdba43fca85 100644 --- a/packages/lib/getSafeRedirectUrl.ts +++ b/packages/lib/getSafeRedirectUrl.ts @@ -15,7 +15,10 @@ export const getSafeRedirectUrl = (url = "") => { const urlParsed = new URL(url); // Avoid open redirection security vulnerability - if (![CONSOLE_URL, WEBAPP_URL, WEBSITE_URL].some((u) => new URL(u).origin === urlParsed.origin)) { + if (![CONSOLE_URL, WEBAPP_URL, WEBSITE_URL].some((u) => { + try { return new URL(u).origin === urlParsed.origin; } + catch { return false; } + })) { url = `${WEBAPP_URL}/`; } diff --git a/packages/lib/hashedLinksUtils.ts b/packages/lib/hashedLinksUtils.ts index 7aae1d3d1d1..0f0e3cd53e5 100644 --- a/packages/lib/hashedLinksUtils.ts +++ b/packages/lib/hashedLinksUtils.ts @@ -124,7 +124,7 @@ export function isLinkExpired( }, timezone?: string | null ): boolean { - if (link.expiresAt) return hasExpiryTimePassed(link.expiresAt, timezone); + if (link.expiresAt && hasExpiryTimePassed(link.expiresAt, timezone)) return true; return isUsageBasedExpired(link.usageCount || 0, link.maxUsageCount); } diff --git a/packages/lib/hooks/useCompatSearchParams.ts b/packages/lib/hooks/useCompatSearchParams.ts index 6a637ae1a19..1de88f75291 100644 --- a/packages/lib/hooks/useCompatSearchParams.ts +++ b/packages/lib/hooks/useCompatSearchParams.ts @@ -12,7 +12,7 @@ export const useCompatSearchParams = () => { // Though useParams is supposed to return a string/string[] as the key's value but it is found to return undefined as well. // Maybe it happens for pages dir when using optional catch-all routes. - const param = params[key] || ""; + const param = params[key] ?? ""; const paramArr = typeof param === "string" ? param.split("/") : param; paramArr.forEach((p) => { diff --git a/packages/lib/hooks/useInViewObserver.ts b/packages/lib/hooks/useInViewObserver.ts index 7396f2be469..072ff2ee968 100644 --- a/packages/lib/hooks/useInViewObserver.ts +++ b/packages/lib/hooks/useInViewObserver.ts @@ -24,7 +24,7 @@ export const useInViewObserver = (onInViewCallback: () => void, root?: Element | }, { // We want to accept null as root - root: root !== undefined ? root : document.body, + root: root !== undefined ? root : null, } ); observer.observe(node); diff --git a/packages/lib/intervalTree.ts b/packages/lib/intervalTree.ts index 1e741138564..a228266ca1a 100644 --- a/packages/lib/intervalTree.ts +++ b/packages/lib/intervalTree.ts @@ -32,11 +32,12 @@ export class IntervalTree { private buildTree(nodes: IntervalNode[]): IntervalNode | undefined { if (nodes.length === 0) return undefined; - const mid = Math.floor(nodes.length / 2); - const node = nodes[mid]; + const sorted = [...nodes].sort((a, b) => a.start - b.start); + const mid = Math.floor(sorted.length / 2); + const node = sorted[mid]; - const leftNodes = nodes.slice(0, mid); - const rightNodes = nodes.slice(mid + 1); + const leftNodes = sorted.slice(0, mid); + const rightNodes = sorted.slice(mid + 1); node.left = this.buildTree(leftNodes); node.right = this.buildTree(rightNodes); diff --git a/packages/lib/jsonUtils.ts b/packages/lib/jsonUtils.ts index 3f617cb0cec..53d61c1192a 100644 --- a/packages/lib/jsonUtils.ts +++ b/packages/lib/jsonUtils.ts @@ -1,11 +1,8 @@ export const validJson = (jsonString: string) => { try { - const o = JSON.parse(jsonString); - if (o && typeof o === "object") { - return o; - } + return JSON.parse(jsonString); } catch (e) { console.log("Invalid JSON:", e); + return false; } - return false; }; diff --git a/packages/lib/recentImpersonations.ts b/packages/lib/recentImpersonations.ts index 6f6e9196724..b8a50928430 100644 --- a/packages/lib/recentImpersonations.ts +++ b/packages/lib/recentImpersonations.ts @@ -21,6 +21,7 @@ export function getRecentImpersonations(): RecentImpersonation[] { export function addRecentImpersonation(usernameRaw: string): void { try { const recent = getRecentImpersonations(); + if (!Array.isArray(recent)) return; const username = usernameRaw.trim().toLowerCase(); if (!username) return; const filtered = recent.filter((item) => item.username !== username); diff --git a/packages/lib/safeStringify.ts b/packages/lib/safeStringify.ts index 7bc6b6ba7c3..4d0ae44a11d 100644 --- a/packages/lib/safeStringify.ts +++ b/packages/lib/safeStringify.ts @@ -11,6 +11,6 @@ export function safeStringify(obj: unknown) { // Avoid crashing on circular references return JSON.stringify(obj); } catch (e) { - return obj; + return "[object could not be stringified]"; } } diff --git a/packages/lib/text.test.ts b/packages/lib/text.test.ts index 4d60a46f140..f2999cfe343 100644 --- a/packages/lib/text.test.ts +++ b/packages/lib/text.test.ts @@ -1,8 +1,30 @@ import { describe, expect, it } from "vitest"; -import { truncate } from "./text"; +import { truncate, truncateOnWord } from "./text"; describe("Text util tests", () => { + describe("fn: truncateOnWord", () => { + it("should respect the maxLength parameter instead of a hardcoded constant", () => { + const text = "the quick brown fox jumps over the lazy dog ".repeat(10); + const result158 = truncateOnWord(text, 158); + const result100 = truncateOnWord(text, 100); + expect(result158.length).toBeLessThanOrEqual(158 + 3); + expect(result100.length).toBeLessThanOrEqual(100 + 3); + expect(result158).not.toBe("..."); + expect(result100).not.toBe("..."); + }); + + it("should preserve text when no space exists within maxLength", () => { + const text = "a".repeat(200); + const result = truncateOnWord(text, 100); + expect(result).toBe("a".repeat(100) + "..."); + }); + + it("should return the original text when shorter than maxLength", () => { + expect(truncateOnWord("short", 100)).toBe("short"); + }); + }); + describe("fn: truncate", () => { it("should return the original text when it is shorter than the max length", () => { const cases = [ diff --git a/packages/lib/text.ts b/packages/lib/text.ts index d9d28ea83d6..12f00548a93 100644 --- a/packages/lib/text.ts +++ b/packages/lib/text.ts @@ -8,11 +8,14 @@ export const truncateOnWord = (text: string, maxLength: number, ellipsis = true) if (text.length <= maxLength) return text; // First split on maxLength chars - let truncatedText = text.substring(0, 148); + let truncatedText = text.substring(0, maxLength); // Then split on the last space, this way we split on the last word, // which looks just a bit nicer. - truncatedText = truncatedText.substring(0, Math.min(truncatedText.length, truncatedText.lastIndexOf(" "))); + const lastSpaceIndex = truncatedText.lastIndexOf(" "); + if (lastSpaceIndex !== -1) { + truncatedText = truncatedText.substring(0, lastSpaceIndex); + } if (ellipsis) truncatedText += "..."; diff --git a/packages/lib/timezone.ts b/packages/lib/timezone.ts index 9fd3eaac132..0b6a4a06be9 100644 --- a/packages/lib/timezone.ts +++ b/packages/lib/timezone.ts @@ -29,7 +29,8 @@ const formatOffset = (offset: string) => export const handleOptionLabel = (option: ITimezoneOption, timezones: Timezones) => { const offsetUnit = option.label.split(/[-+]/)[0].substring(1); - const cityName = option.label.split(") ")[1]; + const parts = option.label.split(") "); + const cityName = parts.length > 1 ? parts[1] : option.value.replace(/_/g, " "); const timezoneValue = ` ${offsetUnit} ${formatOffset(dayjs.tz(undefined, option.value).format("Z"))}`; return timezones.length > 0