fix: When checking email domains to block replace includes with endsWith (#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>
This commit is contained in:
Joe Au-Yeung
2025-12-23 19:56:43 -03:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 14edd6c42f
commit 8ac3d423cb
2 changed files with 251 additions and 8 deletions
@@ -1345,7 +1345,7 @@ describe("getBookingResponsesPartialSchema - Prefill validation", () => {
describe("excluded email/domain validation", () => {
test("should fail if the email is present in excluded emails", async () => {
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo, @gmail.com";
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
@@ -1382,7 +1382,7 @@ describe("excluded email/domain validation", () => {
});
test("should pass if the email is not present in excluded emails", async () => {
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo, @gmail.com";
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
@@ -1417,11 +1417,130 @@ describe("excluded email/domain validation", () => {
email: "harry@workmail.com",
});
});
test("should not block email domains that contain excluded domain as substring", async () => {
// This tests the fix for the bug where `includes` was used instead of `endsWith`
// e.g., blocking "test.co" should not block "test.com"
const excludedEmails = "test.co";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// test.com should NOT be blocked when test.co is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "user@test.com",
});
expect(parsedResponses.success).toBe(true);
if (!parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.data).toEqual({
name: "test",
email: "user@test.com",
});
});
test("should block exact domain match when using endsWith", async () => {
const excludedEmails = "test.co";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// test.co should be blocked
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "user@test.co",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
test("should not block emails where excluded domain appears in local part", async () => {
// Ensures that the @ anchor prevents matching domains in the local part of email
const excludedEmails = "blocked.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// Email with blocked.com in local part should not be blocked
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "blocked.com@allowed.com",
});
expect(parsedResponses.success).toBe(true);
if (!parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.data).toEqual({
name: "test",
email: "blocked.com@allowed.com",
});
});
});
describe("require email/domain validation", () => {
test("should fail if the required email/domain is not present", async () => {
const requiredEmails = "gmail.com, user@hotmail.com, @yahoo.com";
const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
@@ -1459,7 +1578,7 @@ describe("require email/domain validation", () => {
});
test("should pass if the required email/domain is present", async () => {
const requiredEmails = "gmail.com, user@hotmail.com, @yahoo.com";
const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com";
const schema = getBookingResponsesSchema({
bookingFields: [
@@ -1494,4 +1613,125 @@ describe("require email/domain validation", () => {
email: "test@gmail.com",
});
});
test("should not match email domains that contain required domain as substring", async () => {
// This tests the fix for the bug where `includes` was used instead of `endsWith`
// e.g., requiring "test.co" should not match "test.com"
const requiredEmails = "test.co";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
requireEmails: requiredEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// test.com should NOT match when test.co is required
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "user@test.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`,
})
);
});
test("should match exact domain when using endsWith", async () => {
const requiredEmails = "test.co";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
requireEmails: requiredEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// test.co should match
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "user@test.co",
});
expect(parsedResponses.success).toBe(true);
if (!parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.data).toEqual({
name: "test",
email: "user@test.co",
});
});
test("should not match emails where required domain appears in local part", async () => {
// Ensures that the @ anchor prevents matching domains in the local part of email
const requiredEmails = "required.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
requireEmails: requiredEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// Email with required.com in local part should not match
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "required.com@other.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`,
})
);
});
});
@@ -4,10 +4,10 @@ import z from "zod";
import type { ALL_VIEWS } from "@calcom/features/form-builder/schema";
import { fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema";
import logger from "@calcom/lib/logger";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
// eslint-disable-next-line @typescript-eslint/ban-types
type View = ALL_VIEWS | (string & {});
type BookingFields = (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">) | null;
type TranslationFunction = (key: string, options?: Record<string, unknown>) => string;
@@ -72,6 +72,7 @@ function preprocess<T extends z.ZodType>({
isPartialSchema: boolean;
checkOptional?: boolean;
}): z.ZodType<z.infer<T>, z.infer<T>, z.infer<T>> {
const log = logger.getSubLogger({ prefix: ["getBookingResponsesSchema"] });
const preprocessed = z.preprocess(
(responses) => {
const parsedResponses = z.record(z.any()).nullable().parse(responses) || {};
@@ -117,7 +118,9 @@ function preprocess<T extends z.ZodType>({
};
try {
parsedValue = JSON.parse(value);
} catch (e) {}
} catch (e) {
log.error(`Failed to parse JSON for field ${field.name}: ${value}`, e);
}
const optionsInputs = field.optionsInputs;
const optionInputField = optionsInputs?.[parsedValue.value];
if (optionInputField && optionInputField.type === "phone") {
@@ -205,7 +208,7 @@ function preprocess<T extends z.ZodType>({
const excludedEmails =
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
const match = excludedEmails.find((email) => bookerEmail.includes(email));
const match = excludedEmails.find((email) => bookerEmail.endsWith("@" + email));
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -217,7 +220,7 @@ function preprocess<T extends z.ZodType>({
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean) || [];
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.includes(email));
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.endsWith("@" + email));
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
ctx.addIssue({
code: z.ZodIssueCode.custom,