Skip to content
Draft
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
4 changes: 2 additions & 2 deletions apps/web/playwright/fixtures/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,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"]`);
Expand Down
228 changes: 228 additions & 0 deletions packages/app-store/analytics-apps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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)//",
'"><script>alert(1)</script>',
"javascript:alert(1)",
"<img src=x onerror=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("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();
}
});
});
});
33 changes: 26 additions & 7 deletions packages/app-store/databuddy/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,34 @@ import { z } from "zod";

import { eventTypeAppCardZod } from "../eventTypeAppCardZod";

// Safe URL schema that only allows http/https protocols
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" });

// Databuddy Client IDs are alphanumeric strings
const clientIdSchema = z
.string()
.transform((val) => val.trim())
.refine((val) => val === "" || /^[A-Za-z0-9_-]+$/.test(val), {
message: "Invalid Client ID format. Expected alphanumeric characters",
})
.optional();

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: clientIdSchema,
})
);

Expand Down
11 changes: 10 additions & 1 deletion packages/app-store/fathom/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import { z } from "zod";

import { eventTypeAppCardZod } from "../eventTypeAppCardZod";

// Fathom Site IDs are short alphanumeric strings (typically 6-8 uppercase chars like ABCDEFG)
const fathomIdSchema = z
.string()
.transform((val) => val.trim().toUpperCase())
.refine((val) => val === "" || /^[A-Z0-9]{1,20}$/.test(val), {
message: "Invalid Fathom Site ID format. Expected alphanumeric characters only",
})
.optional();

export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackingId: z.string().default("").optional(),
trackingId: fathomIdSchema,
})
);

Expand Down
11 changes: 10 additions & 1 deletion packages/app-store/ga4/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import { z } from "zod";

import { eventTypeAppCardZod } from "../eventTypeAppCardZod";

// GA4 Measurement IDs follow the format G-XXXXXXXXXX where X is alphanumeric (typically 10 chars)
const ga4IdSchema = z
.string()
.transform((val) => val.trim().toUpperCase())
.refine((val) => val === "" || /^G-[A-Z0-9]{1,20}$/.test(val), {
message: "Invalid GA4 Measurement ID format. Expected format: G-XXXXXXXXXX",
})
.optional();

export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackingId: z.string().default("").optional(),
trackingId: ga4IdSchema,
})
);

Expand Down
20 changes: 14 additions & 6 deletions packages/app-store/gtm/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import { z } from "zod";

import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";

// GTM Container IDs follow the format GTM-XXXXXX where X is alphanumeric (typically 6-8 chars)
const gtmIdSchema = z
.string()
.transform((val) => {
const trimmed = val.trim().toUpperCase();
// Remove GTM- prefix if present, we'll add it back
const clean = trimmed.replace(/^GTM-/, "");
return `GTM-${clean}`;
})
.refine((val) => /^GTM-[A-Z0-9]{1,20}$/.test(val), {
message: "Invalid GTM Container ID format. Expected format: GTM-XXXXXX",
});

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: gtmIdSchema,
})
);

Expand Down
28 changes: 26 additions & 2 deletions packages/app-store/insihts/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,34 @@ import { z } from "zod";

import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";

// Insihts Site IDs are alphanumeric strings
const insightsSiteIdSchema = z
.string()
.transform((val) => val.trim())
.refine((val) => !val || /^[A-Za-z0-9_-]+$/.test(val), {
message: "Invalid Insihts Site ID format. Expected alphanumeric characters",
})
.optional();

// Safe URL schema that only allows http/https protocols
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" })
.optional();

export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
SITE_ID: z.string().optional(),
SCRIPT_URL: z.string().optional(),
SITE_ID: insightsSiteIdSchema,
SCRIPT_URL: safeUrlSchema,
})
);

Expand Down
Loading
Loading