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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
14edd6c42f
commit
8ac3d423cb
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user