Skip to content
Closed
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
5 changes: 3 additions & 2 deletions packages/features/bookings/lib/getBookingResponsesSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
22 changes: 21 additions & 1 deletion packages/lib/CalEventParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => key) as TFunction;
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/lib/CalEventParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
13 changes: 11 additions & 2 deletions packages/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +140 to +150

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Parse the environment value itself and validate the result.

Wrapping the value in brackets makes valid JSON arrays become nested arrays (["a"][["a"]]), while JSON.parse can also return non-string values despite the string[] annotation. Parse envVar directly, accept only arrays of strings, then use the CSV fallback.

Proposed fix
 const safeParseCsvEnvVar = (envVar: string | undefined): string[] => {
   if (!envVar) return [];
   try {
-    return JSON.parse(`[${envVar}]`);
+    const parsed: unknown = JSON.parse(envVar);
+    if (Array.isArray(parsed) && parsed.every((value) => typeof value === "string")) {
+      return parsed;
+    }
   } catch {
-    return envVar.split(",").map((s) => s.trim().replace(/^"|"$/g, ""));
   }
+  return envVar.split(",").map((s) => s.trim().replace(/^"|"$/g, ""));
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
const safeParseCsvEnvVar = (envVar: string | undefined): string[] => {
if (!envVar) return [];
try {
const parsed: unknown = JSON.parse(envVar);
if (Array.isArray(parsed) && parsed.every((value) => typeof value === "string")) {
return parsed;
}
} 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);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/constants.ts` around lines 140 - 150, Update safeParseCsvEnvVar
to parse envVar directly, accept the JSON result only when it is an array
containing strings, and otherwise use the existing comma-separated fallback.
Preserve trimming and quote removal in the fallback while ensuring the returned
value always satisfies string[].


export const ORGANIZATION_SELF_SERVE_PRICE = parseFloat(
process.env.NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW || "37"
Expand Down
1 change: 1 addition & 0 deletions packages/lib/contructEmailFromPhoneNumber.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const contructEmailFromPhoneNumber = (phoneNumber: string) => {
if (!phoneNumber) return "";
const cleanedPhoneNumber = phoneNumber.replace(/\+/g, "");
return `${cleanedPhoneNumber}@sms.cal.com`;
};
52 changes: 52 additions & 0 deletions packages/lib/csvUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 6 additions & 0 deletions packages/lib/csvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
2 changes: 1 addition & 1 deletion packages/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export async function handleErrorsJson<Type>(response: Response): Promise<Type>
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);
}
Expand Down
21 changes: 21 additions & 0 deletions packages/lib/extract-base-email.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
5 changes: 4 additions & 1 deletion packages/lib/extract-base-email.ts
Original file line number Diff line number Diff line change
@@ -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}`;
};
8 changes: 6 additions & 2 deletions packages/lib/formatPhoneNumber.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
14 changes: 12 additions & 2 deletions packages/lib/getIP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Comment on lines +40 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not fail open when IP_BANLIST is malformed.

Both catch blocks leave banList empty, so an invalid security configuration disables all IP bans. The invalid value is also reparsed and logged on every invocation. Validate this configuration at startup and surface the misconfiguration, or retain a last-known-good list instead of silently treating it as no bans.

Also applies to: 55-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/getIP.ts` around lines 40 - 45, Update the IP banlist
initialization around banlistSchema.parse and both error-handling paths so
malformed IP_BANLIST never produces an empty fail-open ban list. Validate and
surface the configuration error during startup, or reuse a cached
last-known-good ban list; avoid reparsing and logging the same invalid
configuration on every invocation.

if (banList.includes(IP)) {
logger.warn(`Found banned IP: ${IP} in IP_BANLIST`);
return true;
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/lib/getSafeRedirectUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/`;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/hashedLinksUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/hooks/useCompatSearchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/hooks/useInViewObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions packages/lib/intervalTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ export class IntervalTree<T> {
private buildTree(nodes: IntervalNode<T>[]): IntervalNode<T> | 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);
Expand Down
7 changes: 2 additions & 5 deletions packages/lib/jsonUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines 1 to 7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use an unambiguous parse-failure result.

After accepting every JSON type, validJson("false") returns the same false sentinel as malformed JSON; "0" and "null" are also falsy. Return a tagged result or use undefined for failure, or retain the previous object-only contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/jsonUtils.ts` around lines 1 - 7, Update validJson so parse
failures cannot be confused with valid falsy JSON values such as false, 0, or
null; use undefined or a tagged result for failure, or restore the prior
object-only contract. Preserve returning successfully parsed JSON values without
treating them as invalid.

return false;
};
1 change: 1 addition & 0 deletions packages/lib/recentImpersonations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines 23 to 27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate array entries before filtering.

getRecentImpersonations() returns raw parsed data, so [null] passes the new array check and item.username then throws. The outer catch silently discards the new impersonation. Validate or sanitize each record in the getter, or use a type guard before filtering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/recentImpersonations.ts` around lines 23 - 27, Update the recent
impersonation filtering flow around getRecentImpersonations so malformed array
entries such as null cannot cause item.username to throw. Sanitize the records
in the getter or apply a type guard before the filter, while preserving valid
entries and the existing username comparison behavior.

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/safeStringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";
}
}
24 changes: 23 additions & 1 deletion packages/lib/text.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
7 changes: 5 additions & 2 deletions packages/lib/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 += "...";

Expand Down
3 changes: 2 additions & 1 deletion packages/lib/timezone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading