diff --git a/apps/web/playwright/fixtures/apps.ts b/apps/web/playwright/fixtures/apps.ts index 05127f978b..43ce8f6c83 100644 --- a/apps/web/playwright/fixtures/apps.ts +++ b/apps/web/playwright/fixtures/apps.ts @@ -48,11 +48,11 @@ export function createAppsFixture(page: Page) { await page.click(`[data-testid="save-event-types"]`); - // adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it + // adding valid GTM container ID to gtm-tracking-id-input because this field is required and the test fails without it if (app === "gtm") { await page.waitForLoadState("domcontentloaded"); for (let index = 0; index < eventTypeIds.length; index++) { - await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id"); + await page.getByTestId("gtm-tracking-id-input").nth(index).fill("GTM-ABC123"); } } await page.click(`[data-testid="configure-step-save"]`); diff --git a/packages/app-store/_lib/analytics-schemas.ts b/packages/app-store/_lib/analytics-schemas.ts new file mode 100644 index 0000000000..5bc8466fcf --- /dev/null +++ b/packages/app-store/_lib/analytics-schemas.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +export const safeUrlSchema = z + .string() + .transform((val) => val.trim()) + .refine( + (val) => { + if (!val) return true; + try { + const url = new URL(val); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }, + { message: "Invalid URL format. Must be a valid http or https URL" } + ); + +// Schema for tracking IDs that should only contain letters, numbers, underscores, and hyphens +export const alphanumericIdSchema = z + .string() + .transform((val) => val.trim()) + .refine((val) => !val || /^[A-Za-z0-9_-]+$/.test(val), { + message: "Invalid ID format. Expected alphanumeric characters, underscores, or hyphens", + }); + +// Schema for tracking IDs that should only contain digits +export const numericIdSchema = z + .string() + .transform((val) => val.trim()) + .refine((val) => !val || /^[0-9]+$/.test(val), { + message: "Invalid ID format. Expected a numeric ID", + }); + +// Factory for creating prefixed ID schemas (GTM, GA4, Fathom, etc.) +export const createPrefixedIdSchema = (options: { + prefix?: string; + addPrefixIfMissing?: boolean; + allowEmpty?: boolean; +}) => { + const { prefix = "", addPrefixIfMissing = false, allowEmpty = true } = options; + + return z + .string() + .transform((val) => { + let result = val.trim().toUpperCase(); + if (prefix && addPrefixIfMissing) { + const clean = result.replace(new RegExp(`^${prefix}`, "i"), ""); + result = `${prefix}${clean}`; + } + return result; + }) + .refine( + (val) => { + if (allowEmpty && val === "") return true; + const pattern = prefix ? new RegExp(`^${prefix}[A-Z0-9]{1,20}$`) : /^[A-Z0-9]{1,20}$/; + return pattern.test(val); + }, + { message: `Invalid ID format${prefix ? `. Expected: ${prefix}XXXXXX` : ""}` } + ); +}; diff --git a/packages/app-store/analytics-apps.test.ts b/packages/app-store/analytics-apps.test.ts new file mode 100644 index 0000000000..7b0b6fea95 --- /dev/null +++ b/packages/app-store/analytics-apps.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; + +import { appDataSchema as databuddySchema } from "./databuddy/zod"; +import { appDataSchema as fathomSchema } from "./fathom/zod"; +import { appDataSchema as ga4Schema } from "./ga4/zod"; +import { appDataSchema as gtmSchema } from "./gtm/zod"; +import { appDataSchema as insihtsSchema } from "./insihts/zod"; +import { appDataSchema as matomoSchema } from "./matomo/zod"; +import { appDataSchema as metapixelSchema } from "./metapixel/zod"; +import { appDataSchema as plausibleSchema } from "./plausible/zod"; +import { appDataSchema as posthogSchema } from "./posthog/zod"; +import { appDataSchema as twiplaSchema } from "./twipla/zod"; +import { appDataSchema as umamiSchema } from "./umami/zod"; + +// Common XSS payloads that should be rejected by all schemas +const xssPayloads = [ + "';alert(1)//", + '">', + "javascript:alert(1)", + "", + "' onclick=alert(1) data-x='", +]; + +describe("Analytics Apps - Input Validation", () => { + describe("GTM", () => { + it("accepts valid GTM container IDs", () => { + expect(gtmSchema.parse({ trackingId: "GTM-ABC123" }).trackingId).toBe("GTM-ABC123"); + expect(gtmSchema.parse({ trackingId: "abc123" }).trackingId).toBe("GTM-ABC123"); + expect(gtmSchema.parse({ trackingId: "gtm-xyz789" }).trackingId).toBe("GTM-XYZ789"); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => gtmSchema.parse({ trackingId: payload })).toThrow(); + } + }); + }); + + describe("GA4", () => { + it("accepts valid GA4 measurement IDs", () => { + expect(ga4Schema.parse({ trackingId: "G-ABC1234567" }).trackingId).toBe("G-ABC1234567"); + expect(ga4Schema.parse({ trackingId: "g-abc1234567" }).trackingId).toBe("G-ABC1234567"); + expect(ga4Schema.parse({ trackingId: "" }).trackingId).toBe(""); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => ga4Schema.parse({ trackingId: payload })).toThrow(); + } + }); + }); + + describe("Meta Pixel", () => { + it("accepts valid pixel IDs (numeric)", () => { + expect(metapixelSchema.parse({ trackingId: "1234567890123456" }).trackingId).toBe("1234567890123456"); + expect(metapixelSchema.parse({ trackingId: "" }).trackingId).toBe(""); + }); + + it("rejects non-numeric values", () => { + expect(() => metapixelSchema.parse({ trackingId: "abc123" })).toThrow(); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => metapixelSchema.parse({ trackingId: payload })).toThrow(); + } + }); + }); + + describe("PostHog", () => { + it("accepts valid PostHog credentials", () => { + const result = posthogSchema.parse({ + TRACKING_ID: "phc_abc123XYZ", + API_HOST: "https://app.posthog.com", + }); + expect(result.TRACKING_ID).toBe("phc_abc123XYZ"); + expect(result.API_HOST).toBe("https://app.posthog.com"); + }); + + it("accepts legacy alphanumeric TRACKING_IDs", () => { + expect(posthogSchema.parse({ TRACKING_ID: "legacy_key_123" }).TRACKING_ID).toBe("legacy_key_123"); + }); + + it("rejects javascript: URLs", () => { + expect(() => posthogSchema.parse({ API_HOST: "javascript:alert(1)" })).toThrow(); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => posthogSchema.parse({ TRACKING_ID: payload })).toThrow(); + expect(() => posthogSchema.parse({ API_HOST: payload })).toThrow(); + } + }); + }); + + describe("Fathom", () => { + it("accepts valid site IDs", () => { + expect(fathomSchema.parse({ trackingId: "ABCDEFG" }).trackingId).toBe("ABCDEFG"); + expect(fathomSchema.parse({ trackingId: "abcdefg" }).trackingId).toBe("ABCDEFG"); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => fathomSchema.parse({ trackingId: payload })).toThrow(); + } + }); + }); + + describe("Plausible", () => { + it("accepts valid domain and URL", () => { + const result = plausibleSchema.parse({ + trackingId: "example.com", + PLAUSIBLE_URL: "https://plausible.io/js/script.js", + }); + expect(result.trackingId).toBe("example.com"); + expect(result.PLAUSIBLE_URL).toBe("https://plausible.io/js/script.js"); + }); + + it("accepts valid subdomains", () => { + expect(plausibleSchema.parse({ trackingId: "sub.example.com" }).trackingId).toBe("sub.example.com"); + expect(plausibleSchema.parse({ trackingId: "deep.sub.example.com" }).trackingId).toBe( + "deep.sub.example.com" + ); + }); + + it("accepts single-label domains", () => { + expect(plausibleSchema.parse({ trackingId: "localhost" }).trackingId).toBe("localhost"); + }); + + it("accepts domains with hyphens in labels", () => { + expect(plausibleSchema.parse({ trackingId: "my-site.example.com" }).trackingId).toBe( + "my-site.example.com" + ); + }); + + it("rejects consecutive dots", () => { + expect(() => plausibleSchema.parse({ trackingId: "example..com" })).toThrow(); + }); + + it("rejects hyphens at label boundaries", () => { + expect(() => plausibleSchema.parse({ trackingId: "-example.com" })).toThrow(); + expect(() => plausibleSchema.parse({ trackingId: "example-.com" })).toThrow(); + expect(() => plausibleSchema.parse({ trackingId: "example.-com" })).toThrow(); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => plausibleSchema.parse({ trackingId: payload })).toThrow(); + expect(() => plausibleSchema.parse({ PLAUSIBLE_URL: payload })).toThrow(); + } + }); + }); + + describe("Matomo", () => { + it("accepts valid URL and numeric site ID", () => { + const result = matomoSchema.parse({ + MATOMO_URL: "https://matomo.example.com", + SITE_ID: "42", + }); + expect(result.MATOMO_URL).toBe("https://matomo.example.com"); + expect(result.SITE_ID).toBe("42"); + }); + + it("rejects non-numeric SITE_ID", () => { + expect(() => matomoSchema.parse({ SITE_ID: "abc" })).toThrow(); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => matomoSchema.parse({ MATOMO_URL: payload })).toThrow(); + expect(() => matomoSchema.parse({ SITE_ID: payload })).toThrow(); + } + }); + }); + + describe("Umami", () => { + it("accepts UUID (v2) and URL", () => { + const result = umamiSchema.parse({ + SITE_ID: "4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b", + SCRIPT_URL: "https://umami.example.com/script.js", + }); + expect(result.SITE_ID).toBe("4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b"); + expect(result.SCRIPT_URL).toBe("https://umami.example.com/script.js"); + }); + + it("accepts numeric ID (v1)", () => { + expect(umamiSchema.parse({ SITE_ID: "12345" }).SITE_ID).toBe("12345"); + }); + + it("rejects invalid format", () => { + expect(() => umamiSchema.parse({ SITE_ID: "not-a-valid-id!" })).toThrow(); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => umamiSchema.parse({ SITE_ID: payload })).toThrow(); + expect(() => umamiSchema.parse({ SCRIPT_URL: payload })).toThrow(); + } + }); + }); + + describe("Twipla", () => { + it("accepts valid site IDs", () => { + expect(twiplaSchema.parse({ SITE_ID: "abc123" }).SITE_ID).toBe("abc123"); + expect(twiplaSchema.parse({ SITE_ID: "4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b" }).SITE_ID).toBe( + "4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b" + ); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => twiplaSchema.parse({ SITE_ID: payload })).toThrow(); + } + }); + }); + + describe("Insihts", () => { + it("accepts valid site ID and URL", () => { + const result = insihtsSchema.parse({ + SITE_ID: "site_abc123", + SCRIPT_URL: "https://collector.insihts.com/script.js", + }); + expect(result.SITE_ID).toBe("site_abc123"); + expect(result.SCRIPT_URL).toBe("https://collector.insihts.com/script.js"); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => insihtsSchema.parse({ SITE_ID: payload })).toThrow(); + expect(() => insihtsSchema.parse({ SCRIPT_URL: payload })).toThrow(); + } + }); + }); + + describe("Databuddy", () => { + it("accepts valid client ID and URLs", () => { + const result = databuddySchema.parse({ + CLIENT_ID: "client_abc123", + DATABUDDY_SCRIPT_URL: "https://cdn.databuddy.cc/databuddy.js", + DATABUDDY_API_URL: "https://basket.databuddy.cc", + }); + expect(result.CLIENT_ID).toBe("client_abc123"); + expect(result.DATABUDDY_SCRIPT_URL).toBe("https://cdn.databuddy.cc/databuddy.js"); + expect(result.DATABUDDY_API_URL).toBe("https://basket.databuddy.cc"); + }); + + it("rejects XSS payloads", () => { + for (const payload of xssPayloads) { + expect(() => databuddySchema.parse({ CLIENT_ID: payload })).toThrow(); + expect(() => databuddySchema.parse({ DATABUDDY_SCRIPT_URL: payload })).toThrow(); + expect(() => databuddySchema.parse({ DATABUDDY_API_URL: payload })).toThrow(); + } + }); + }); +}); diff --git a/packages/app-store/databuddy/zod.ts b/packages/app-store/databuddy/zod.ts index 71315dd1c9..6c2887d44b 100644 --- a/packages/app-store/databuddy/zod.ts +++ b/packages/app-store/databuddy/zod.ts @@ -1,16 +1,13 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; +import { alphanumericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas"; export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - DATABUDDY_SCRIPT_URL: z - .string() - .optional() - .default("https://cdn.databuddy.cc/databuddy.js") - .or(z.undefined()), - DATABUDDY_API_URL: z.string().optional().default("https://basket.databuddy.cc").or(z.undefined()), - CLIENT_ID: z.string().default("").optional(), + DATABUDDY_SCRIPT_URL: safeUrlSchema.optional().default("https://cdn.databuddy.cc/databuddy.js").or(z.undefined()), + DATABUDDY_API_URL: safeUrlSchema.optional().default("https://basket.databuddy.cc").or(z.undefined()), + CLIENT_ID: alphanumericIdSchema.optional(), }) ); diff --git a/packages/app-store/fathom/zod.ts b/packages/app-store/fathom/zod.ts index 674bf3405d..a8fb1d04c1 100644 --- a/packages/app-store/fathom/zod.ts +++ b/packages/app-store/fathom/zod.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; +import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas"; export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - trackingId: z.string().default("").optional(), + trackingId: createPrefixedIdSchema({ allowEmpty: true }).optional(), }) ); diff --git a/packages/app-store/ga4/zod.ts b/packages/app-store/ga4/zod.ts index 674bf3405d..d043adfbab 100644 --- a/packages/app-store/ga4/zod.ts +++ b/packages/app-store/ga4/zod.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; +import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas"; export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - trackingId: z.string().default("").optional(), + trackingId: createPrefixedIdSchema({ prefix: "G-", allowEmpty: true }).optional(), }) ); diff --git a/packages/app-store/gtm/zod.ts b/packages/app-store/gtm/zod.ts index 297950930c..71ee5440ab 100644 --- a/packages/app-store/gtm/zod.ts +++ b/packages/app-store/gtm/zod.ts @@ -2,14 +2,11 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas"; + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - trackingId: z.string().transform((val) => { - let trackingId = val.trim(); - // Ensure that trackingId is transformed if needed to begin with "GTM-" always - trackingId = !trackingId.startsWith("GTM-") ? `GTM-${trackingId}` : trackingId; - return trackingId; - }), + trackingId: createPrefixedIdSchema({ prefix: "GTM-", addPrefixIfMissing: true, allowEmpty: false }), }) ); diff --git a/packages/app-store/insihts/zod.ts b/packages/app-store/insihts/zod.ts index 83716cb0a5..d8f0b3a24b 100644 --- a/packages/app-store/insihts/zod.ts +++ b/packages/app-store/insihts/zod.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import { alphanumericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas"; + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - SITE_ID: z.string().optional(), - SCRIPT_URL: z.string().optional(), + SITE_ID: alphanumericIdSchema.optional(), + SCRIPT_URL: safeUrlSchema.optional(), }) ); diff --git a/packages/app-store/matomo/zod.ts b/packages/app-store/matomo/zod.ts index c13304b444..ee39ec1697 100644 --- a/packages/app-store/matomo/zod.ts +++ b/packages/app-store/matomo/zod.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import { numericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas"; + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - MATOMO_URL: z.string().optional(), - SITE_ID: z.string().optional(), + MATOMO_URL: safeUrlSchema.optional(), + SITE_ID: numericIdSchema.optional(), }) ); diff --git a/packages/app-store/metapixel/zod.ts b/packages/app-store/metapixel/zod.ts index 367b0dd2a3..532f72d529 100644 --- a/packages/app-store/metapixel/zod.ts +++ b/packages/app-store/metapixel/zod.ts @@ -2,9 +2,18 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +// Meta Pixel IDs are numeric strings of 15-16 digits +const metaPixelIdSchema = z + .string() + .transform((val) => val.trim()) + .refine((val) => val === "" || /^[0-9]{15,16}$/.test(val), { + message: "Invalid Meta Pixel ID format. Expected a numeric ID (e.g., 1234567890123456)", + }) + .optional(); + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - trackingId: z.string().default("").optional(), + trackingId: metaPixelIdSchema, }) ); export const appKeysSchema = z.object({}); diff --git a/packages/app-store/plausible/zod.ts b/packages/app-store/plausible/zod.ts index ee8d9a8dc4..df2c16b8bf 100644 --- a/packages/app-store/plausible/zod.ts +++ b/packages/app-store/plausible/zod.ts @@ -1,11 +1,25 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; +import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas"; + +// Domain schema for Plausible tracking (e.g., example.com, sub.example.com) +const domainSchema = z + .string() + .transform((val) => val.trim().toLowerCase()) + .refine( + (val) => + val === "" || /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(val), + { + message: "Invalid domain format. Expected format: example.com", + } + ) + .optional(); export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - PLAUSIBLE_URL: z.string().optional().default("https://plausible.io/js/script.js").or(z.undefined()), - trackingId: z.string().default("").optional(), + PLAUSIBLE_URL: safeUrlSchema.optional().default("https://plausible.io/js/script.js").or(z.undefined()), + trackingId: domainSchema, }) ); diff --git a/packages/app-store/posthog/zod.ts b/packages/app-store/posthog/zod.ts index d1eba3b034..26ccc8cbba 100644 --- a/packages/app-store/posthog/zod.ts +++ b/packages/app-store/posthog/zod.ts @@ -2,10 +2,21 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas"; + +// PostHog Project API Keys (typically start with phc_) - allow alphanumeric to not break legacy data +const posthogIdSchema = z + .string() + .transform((val) => val.trim()) + .refine((val) => !val || /^[A-Za-z0-9_]+$/.test(val), { + message: "Invalid PostHog Project API Key format. Expected alphanumeric characters or underscores", + }) + .optional(); + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - TRACKING_ID: z.string().optional(), - API_HOST: z.string().optional(), + TRACKING_ID: posthogIdSchema, + API_HOST: safeUrlSchema.optional(), }) ); diff --git a/packages/app-store/twipla/zod.ts b/packages/app-store/twipla/zod.ts index 3339ccae14..b337018986 100644 --- a/packages/app-store/twipla/zod.ts +++ b/packages/app-store/twipla/zod.ts @@ -2,9 +2,18 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +// Twipla Site IDs can be UUID or alphanumeric strings +const twiplaSiteIdSchema = z + .string() + .transform((val) => val.trim()) + .refine((val) => !val || /^[A-Za-z0-9-]+$/.test(val), { + message: "Invalid Twipla Site ID format. Expected alphanumeric characters or UUID", + }) + .optional(); + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - SITE_ID: z.string().optional(), + SITE_ID: twiplaSiteIdSchema, }) ); diff --git a/packages/app-store/umami/zod.ts b/packages/app-store/umami/zod.ts index 9318935021..10b654ce66 100644 --- a/packages/app-store/umami/zod.ts +++ b/packages/app-store/umami/zod.ts @@ -2,10 +2,27 @@ import { z } from "zod"; import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas"; + +// Umami Website IDs: UUID in v2 (e.g., 4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b) or numeric in v1 +const umamiSiteIdSchema = z + .string() + .transform((val) => val.trim().toLowerCase()) + .refine( + (val) => + !val || + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(val) || + /^[0-9]+$/.test(val), + { + message: "Invalid Umami Website ID format. Expected UUID or numeric ID", + } + ) + .optional(); + export const appDataSchema = eventTypeAppCardZod.merge( z.object({ - SITE_ID: z.string().optional(), - SCRIPT_URL: z.string().optional().default("https://cloud.umami.is/script.js").or(z.undefined()), + SITE_ID: umamiSiteIdSchema, + SCRIPT_URL: safeUrlSchema.default("https://cloud.umami.is/script.js").or(z.undefined()), }) );