Skip to content

Commit 8ac3d42

Browse files
fix: When checking email domains to block replace includes with endsWith (calcom#26166)
* Lint fixes * Replace `includes` with `endsWith` * Fix typo * test: add tests for email domain blocking fix Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 14edd6c commit 8ac3d42

2 files changed

Lines changed: 251 additions & 8 deletions

File tree

packages/features/bookings/lib/getBookingResponsesSchema.test.ts

Lines changed: 244 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,7 +1345,7 @@ describe("getBookingResponsesPartialSchema - Prefill validation", () => {
13451345

13461346
describe("excluded email/domain validation", () => {
13471347
test("should fail if the email is present in excluded emails", async () => {
1348-
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo, @gmail.com";
1348+
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com";
13491349

13501350
const schema = getBookingResponsesSchema({
13511351
bookingFields: [
@@ -1382,7 +1382,7 @@ describe("excluded email/domain validation", () => {
13821382
});
13831383

13841384
test("should pass if the email is not present in excluded emails", async () => {
1385-
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo, @gmail.com";
1385+
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com";
13861386

13871387
const schema = getBookingResponsesSchema({
13881388
bookingFields: [
@@ -1417,11 +1417,130 @@ describe("excluded email/domain validation", () => {
14171417
email: "harry@workmail.com",
14181418
});
14191419
});
1420+
1421+
test("should not block email domains that contain excluded domain as substring", async () => {
1422+
// This tests the fix for the bug where `includes` was used instead of `endsWith`
1423+
// e.g., blocking "test.co" should not block "test.com"
1424+
const excludedEmails = "test.co";
1425+
1426+
const schema = getBookingResponsesSchema({
1427+
bookingFields: [
1428+
{
1429+
name: "name",
1430+
type: "name",
1431+
required: true,
1432+
},
1433+
{
1434+
name: "email",
1435+
type: "email",
1436+
required: true,
1437+
excludeEmails: excludedEmails,
1438+
},
1439+
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
1440+
view: "ALL_VIEWS",
1441+
});
1442+
1443+
// test.com should NOT be blocked when test.co is excluded
1444+
const parsedResponses = await schema.safeParseAsync({
1445+
name: "test",
1446+
email: "user@test.com",
1447+
});
1448+
1449+
expect(parsedResponses.success).toBe(true);
1450+
1451+
if (!parsedResponses.success) {
1452+
throw new Error("Should not reach here");
1453+
}
1454+
1455+
expect(parsedResponses.data).toEqual({
1456+
name: "test",
1457+
email: "user@test.com",
1458+
});
1459+
});
1460+
1461+
test("should block exact domain match when using endsWith", async () => {
1462+
const excludedEmails = "test.co";
1463+
1464+
const schema = getBookingResponsesSchema({
1465+
bookingFields: [
1466+
{
1467+
name: "name",
1468+
type: "name",
1469+
required: true,
1470+
},
1471+
{
1472+
name: "email",
1473+
type: "email",
1474+
required: true,
1475+
excludeEmails: excludedEmails,
1476+
},
1477+
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
1478+
view: "ALL_VIEWS",
1479+
});
1480+
1481+
// test.co should be blocked
1482+
const parsedResponses = await schema.safeParseAsync({
1483+
name: "test",
1484+
email: "user@test.co",
1485+
});
1486+
1487+
expect(parsedResponses.success).toBe(false);
1488+
1489+
if (parsedResponses.success) {
1490+
throw new Error("Should not reach here");
1491+
}
1492+
1493+
expect(parsedResponses.error.issues[0]).toEqual(
1494+
expect.objectContaining({
1495+
code: "custom",
1496+
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
1497+
})
1498+
);
1499+
});
1500+
1501+
test("should not block emails where excluded domain appears in local part", async () => {
1502+
// Ensures that the @ anchor prevents matching domains in the local part of email
1503+
const excludedEmails = "blocked.com";
1504+
1505+
const schema = getBookingResponsesSchema({
1506+
bookingFields: [
1507+
{
1508+
name: "name",
1509+
type: "name",
1510+
required: true,
1511+
},
1512+
{
1513+
name: "email",
1514+
type: "email",
1515+
required: true,
1516+
excludeEmails: excludedEmails,
1517+
},
1518+
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
1519+
view: "ALL_VIEWS",
1520+
});
1521+
1522+
// Email with blocked.com in local part should not be blocked
1523+
const parsedResponses = await schema.safeParseAsync({
1524+
name: "test",
1525+
email: "blocked.com@allowed.com",
1526+
});
1527+
1528+
expect(parsedResponses.success).toBe(true);
1529+
1530+
if (!parsedResponses.success) {
1531+
throw new Error("Should not reach here");
1532+
}
1533+
1534+
expect(parsedResponses.data).toEqual({
1535+
name: "test",
1536+
email: "blocked.com@allowed.com",
1537+
});
1538+
});
14201539
});
14211540

14221541
describe("require email/domain validation", () => {
14231542
test("should fail if the required email/domain is not present", async () => {
1424-
const requiredEmails = "gmail.com, user@hotmail.com, @yahoo.com";
1543+
const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com";
14251544
const schema = getBookingResponsesSchema({
14261545
bookingFields: [
14271546
{
@@ -1459,7 +1578,7 @@ describe("require email/domain validation", () => {
14591578
});
14601579

14611580
test("should pass if the required email/domain is present", async () => {
1462-
const requiredEmails = "gmail.com, user@hotmail.com, @yahoo.com";
1581+
const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com";
14631582

14641583
const schema = getBookingResponsesSchema({
14651584
bookingFields: [
@@ -1494,4 +1613,125 @@ describe("require email/domain validation", () => {
14941613
email: "test@gmail.com",
14951614
});
14961615
});
1616+
1617+
test("should not match email domains that contain required domain as substring", async () => {
1618+
// This tests the fix for the bug where `includes` was used instead of `endsWith`
1619+
// e.g., requiring "test.co" should not match "test.com"
1620+
const requiredEmails = "test.co";
1621+
1622+
const schema = getBookingResponsesSchema({
1623+
bookingFields: [
1624+
{
1625+
name: "name",
1626+
type: "name",
1627+
required: true,
1628+
},
1629+
{
1630+
name: "email",
1631+
type: "email",
1632+
required: true,
1633+
requireEmails: requiredEmails,
1634+
},
1635+
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
1636+
view: "ALL_VIEWS",
1637+
});
1638+
1639+
// test.com should NOT match when test.co is required
1640+
const parsedResponses = await schema.safeParseAsync({
1641+
name: "test",
1642+
email: "user@test.com",
1643+
});
1644+
1645+
expect(parsedResponses.success).toBe(false);
1646+
1647+
if (parsedResponses.success) {
1648+
throw new Error("Should not reach here");
1649+
}
1650+
1651+
expect(parsedResponses.error.issues[0]).toEqual(
1652+
expect.objectContaining({
1653+
code: "custom",
1654+
message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`,
1655+
})
1656+
);
1657+
});
1658+
1659+
test("should match exact domain when using endsWith", async () => {
1660+
const requiredEmails = "test.co";
1661+
1662+
const schema = getBookingResponsesSchema({
1663+
bookingFields: [
1664+
{
1665+
name: "name",
1666+
type: "name",
1667+
required: true,
1668+
},
1669+
{
1670+
name: "email",
1671+
type: "email",
1672+
required: true,
1673+
requireEmails: requiredEmails,
1674+
},
1675+
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
1676+
view: "ALL_VIEWS",
1677+
});
1678+
1679+
// test.co should match
1680+
const parsedResponses = await schema.safeParseAsync({
1681+
name: "test",
1682+
email: "user@test.co",
1683+
});
1684+
1685+
expect(parsedResponses.success).toBe(true);
1686+
1687+
if (!parsedResponses.success) {
1688+
throw new Error("Should not reach here");
1689+
}
1690+
1691+
expect(parsedResponses.data).toEqual({
1692+
name: "test",
1693+
email: "user@test.co",
1694+
});
1695+
});
1696+
1697+
test("should not match emails where required domain appears in local part", async () => {
1698+
// Ensures that the @ anchor prevents matching domains in the local part of email
1699+
const requiredEmails = "required.com";
1700+
1701+
const schema = getBookingResponsesSchema({
1702+
bookingFields: [
1703+
{
1704+
name: "name",
1705+
type: "name",
1706+
required: true,
1707+
},
1708+
{
1709+
name: "email",
1710+
type: "email",
1711+
required: true,
1712+
requireEmails: requiredEmails,
1713+
},
1714+
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
1715+
view: "ALL_VIEWS",
1716+
});
1717+
1718+
// Email with required.com in local part should not match
1719+
const parsedResponses = await schema.safeParseAsync({
1720+
name: "test",
1721+
email: "required.com@other.com",
1722+
});
1723+
1724+
expect(parsedResponses.success).toBe(false);
1725+
1726+
if (parsedResponses.success) {
1727+
throw new Error("Should not reach here");
1728+
}
1729+
1730+
expect(parsedResponses.error.issues[0]).toEqual(
1731+
expect.objectContaining({
1732+
code: "custom",
1733+
message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`,
1734+
})
1735+
);
1736+
});
14971737
});

packages/features/bookings/lib/getBookingResponsesSchema.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import z from "zod";
44
import type { ALL_VIEWS } from "@calcom/features/form-builder/schema";
55
import { fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
66
import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema";
7+
import logger from "@calcom/lib/logger";
78
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
89
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
910

10-
// eslint-disable-next-line @typescript-eslint/ban-types
1111
type View = ALL_VIEWS | (string & {});
1212
type BookingFields = (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">) | null;
1313
type TranslationFunction = (key: string, options?: Record<string, unknown>) => string;
@@ -72,6 +72,7 @@ function preprocess<T extends z.ZodType>({
7272
isPartialSchema: boolean;
7373
checkOptional?: boolean;
7474
}): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> {
75+
const log = logger.getSubLogger({ prefix: ["getBookingResponsesSchema"] });
7576
const preprocessed = z.preprocess(
7677
(responses) => {
7778
const parsedResponses = z.record(z.any()).nullable().parse(responses) || {};
@@ -117,7 +118,9 @@ function preprocess<T extends z.ZodType>({
117118
};
118119
try {
119120
parsedValue = JSON.parse(value);
120-
} catch (e) {}
121+
} catch (e) {
122+
log.error(`Failed to parse JSON for field ${field.name}: ${value}`, e);
123+
}
121124
const optionsInputs = field.optionsInputs;
122125
const optionInputField = optionsInputs?.[parsedValue.value];
123126
if (optionInputField && optionInputField.type === "phone") {
@@ -205,7 +208,7 @@ function preprocess<T extends z.ZodType>({
205208
const excludedEmails =
206209
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
207210

208-
const match = excludedEmails.find((email) => bookerEmail.includes(email));
211+
const match = excludedEmails.find((email) => bookerEmail.endsWith("@" + email));
209212
if (match) {
210213
ctx.addIssue({
211214
code: z.ZodIssueCode.custom,
@@ -217,7 +220,7 @@ function preprocess<T extends z.ZodType>({
217220
?.split(",")
218221
.map((domain) => domain.trim())
219222
.filter(Boolean) || [];
220-
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.includes(email));
223+
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.endsWith("@" + email));
221224
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
222225
ctx.addIssue({
223226
code: z.ZodIssueCode.custom,

0 commit comments

Comments
 (0)