Skip to content

Commit c43c48b

Browse files
fix: add input validation to analytics app schemas (calcom#26976)
* fix(analytics): add input validation to analytics app schemas Add strict input validation for tracking IDs and URLs in analytics app integrations to ensure data conforms to expected formats * fix: remove optional/default to fix type inference Remove .optional() and .default("") from schemas with transform/refine chains to preserve correct TypeScript type inference * fix: restore .optional() for type compatibility * fix(e2e): use valid GTM container ID format in analytics test Co-Authored-By: pedro@cal.com <pedro.castro@ideware.com.br> * fix(analytics): address Cubic AI review feedback - Tighten Meta Pixel ID regex from {1,20} to {15,16} digits (confidence 9.5/10) - Fix PostHog error message to mention underscores are allowed (confidence 9/10) Co-Authored-By: unknown <> * fix(analytics): enforce domain label boundaries and prevent consecutive dots Co-Authored-By: pedro@cal.com <pedro.castro@ideware.com.br> * refactor: extract shared validation schemas - Create analytics-schemas.ts with reusable safeUrlSchema, alphanumericIdSchema, and numericIdSchema - Update databuddy, insihts, matomo, plausible, posthog, and umami to use shared schemas - Keep app-specific schemas local (domain validation, UUID patterns, etc.) * fix(analytics): enforce exactly 10 characters for GA4 Measurement ID Tighten GA4 regex from {1,20} to {10} to match the documented format G-XXXXXXXXXX. This addresses Cubic AI review feedback (confidence 9/10) that the regex was too permissive compared to the error message. Co-Authored-By: unknown <> * refactor: add createPrefixedIdSchema factory for GTM/GA4/Fathom - Add factory function to handle prefixed IDs with configurable options (prefix, addPrefixIfMissing, allowEmpty) - Consolidate GTM, GA4, and Fathom schemas using the shared factory - Standardize imports to use @calcom/app-store alias * fix: reject prefix-only IDs like "G-" or "GTM-" without content --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 1a0f70e commit c43c48b

14 files changed

Lines changed: 405 additions & 29 deletions

File tree

apps/web/playwright/fixtures/apps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ export function createAppsFixture(page: Page) {
4848

4949
await page.click(`[data-testid="save-event-types"]`);
5050

51-
// adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it
51+
// adding valid GTM container ID to gtm-tracking-id-input because this field is required and the test fails without it
5252
if (app === "gtm") {
5353
await page.waitForLoadState("domcontentloaded");
5454
for (let index = 0; index < eventTypeIds.length; index++) {
55-
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id");
55+
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("GTM-ABC123");
5656
}
5757
}
5858
await page.click(`[data-testid="configure-step-save"]`);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { z } from "zod";
2+
3+
export const safeUrlSchema = z
4+
.string()
5+
.transform((val) => val.trim())
6+
.refine(
7+
(val) => {
8+
if (!val) return true;
9+
try {
10+
const url = new URL(val);
11+
return url.protocol === "http:" || url.protocol === "https:";
12+
} catch {
13+
return false;
14+
}
15+
},
16+
{ message: "Invalid URL format. Must be a valid http or https URL" }
17+
);
18+
19+
// Schema for tracking IDs that should only contain letters, numbers, underscores, and hyphens
20+
export const alphanumericIdSchema = z
21+
.string()
22+
.transform((val) => val.trim())
23+
.refine((val) => !val || /^[A-Za-z0-9_-]+$/.test(val), {
24+
message: "Invalid ID format. Expected alphanumeric characters, underscores, or hyphens",
25+
});
26+
27+
// Schema for tracking IDs that should only contain digits
28+
export const numericIdSchema = z
29+
.string()
30+
.transform((val) => val.trim())
31+
.refine((val) => !val || /^[0-9]+$/.test(val), {
32+
message: "Invalid ID format. Expected a numeric ID",
33+
});
34+
35+
// Factory for creating prefixed ID schemas (GTM, GA4, Fathom, etc.)
36+
export const createPrefixedIdSchema = (options: {
37+
prefix?: string;
38+
addPrefixIfMissing?: boolean;
39+
allowEmpty?: boolean;
40+
}) => {
41+
const { prefix = "", addPrefixIfMissing = false, allowEmpty = true } = options;
42+
43+
return z
44+
.string()
45+
.transform((val) => {
46+
let result = val.trim().toUpperCase();
47+
if (prefix && addPrefixIfMissing) {
48+
const clean = result.replace(new RegExp(`^${prefix}`, "i"), "");
49+
result = `${prefix}${clean}`;
50+
}
51+
return result;
52+
})
53+
.refine(
54+
(val) => {
55+
if (allowEmpty && val === "") return true;
56+
const pattern = prefix ? new RegExp(`^${prefix}[A-Z0-9]{1,20}$`) : /^[A-Z0-9]{1,20}$/;
57+
return pattern.test(val);
58+
},
59+
{ message: `Invalid ID format${prefix ? `. Expected: ${prefix}XXXXXX` : ""}` }
60+
);
61+
};
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { appDataSchema as databuddySchema } from "./databuddy/zod";
4+
import { appDataSchema as fathomSchema } from "./fathom/zod";
5+
import { appDataSchema as ga4Schema } from "./ga4/zod";
6+
import { appDataSchema as gtmSchema } from "./gtm/zod";
7+
import { appDataSchema as insihtsSchema } from "./insihts/zod";
8+
import { appDataSchema as matomoSchema } from "./matomo/zod";
9+
import { appDataSchema as metapixelSchema } from "./metapixel/zod";
10+
import { appDataSchema as plausibleSchema } from "./plausible/zod";
11+
import { appDataSchema as posthogSchema } from "./posthog/zod";
12+
import { appDataSchema as twiplaSchema } from "./twipla/zod";
13+
import { appDataSchema as umamiSchema } from "./umami/zod";
14+
15+
// Common XSS payloads that should be rejected by all schemas
16+
const xssPayloads = [
17+
"';alert(1)//",
18+
'"><script>alert(1)</script>',
19+
"javascript:alert(1)",
20+
"<img src=x onerror=alert(1)>",
21+
"' onclick=alert(1) data-x='",
22+
];
23+
24+
describe("Analytics Apps - Input Validation", () => {
25+
describe("GTM", () => {
26+
it("accepts valid GTM container IDs", () => {
27+
expect(gtmSchema.parse({ trackingId: "GTM-ABC123" }).trackingId).toBe("GTM-ABC123");
28+
expect(gtmSchema.parse({ trackingId: "abc123" }).trackingId).toBe("GTM-ABC123");
29+
expect(gtmSchema.parse({ trackingId: "gtm-xyz789" }).trackingId).toBe("GTM-XYZ789");
30+
});
31+
32+
it("rejects XSS payloads", () => {
33+
for (const payload of xssPayloads) {
34+
expect(() => gtmSchema.parse({ trackingId: payload })).toThrow();
35+
}
36+
});
37+
});
38+
39+
describe("GA4", () => {
40+
it("accepts valid GA4 measurement IDs", () => {
41+
expect(ga4Schema.parse({ trackingId: "G-ABC1234567" }).trackingId).toBe("G-ABC1234567");
42+
expect(ga4Schema.parse({ trackingId: "g-abc1234567" }).trackingId).toBe("G-ABC1234567");
43+
expect(ga4Schema.parse({ trackingId: "" }).trackingId).toBe("");
44+
});
45+
46+
it("rejects XSS payloads", () => {
47+
for (const payload of xssPayloads) {
48+
expect(() => ga4Schema.parse({ trackingId: payload })).toThrow();
49+
}
50+
});
51+
});
52+
53+
describe("Meta Pixel", () => {
54+
it("accepts valid pixel IDs (numeric)", () => {
55+
expect(metapixelSchema.parse({ trackingId: "1234567890123456" }).trackingId).toBe("1234567890123456");
56+
expect(metapixelSchema.parse({ trackingId: "" }).trackingId).toBe("");
57+
});
58+
59+
it("rejects non-numeric values", () => {
60+
expect(() => metapixelSchema.parse({ trackingId: "abc123" })).toThrow();
61+
});
62+
63+
it("rejects XSS payloads", () => {
64+
for (const payload of xssPayloads) {
65+
expect(() => metapixelSchema.parse({ trackingId: payload })).toThrow();
66+
}
67+
});
68+
});
69+
70+
describe("PostHog", () => {
71+
it("accepts valid PostHog credentials", () => {
72+
const result = posthogSchema.parse({
73+
TRACKING_ID: "phc_abc123XYZ",
74+
API_HOST: "https://app.posthog.com",
75+
});
76+
expect(result.TRACKING_ID).toBe("phc_abc123XYZ");
77+
expect(result.API_HOST).toBe("https://app.posthog.com");
78+
});
79+
80+
it("accepts legacy alphanumeric TRACKING_IDs", () => {
81+
expect(posthogSchema.parse({ TRACKING_ID: "legacy_key_123" }).TRACKING_ID).toBe("legacy_key_123");
82+
});
83+
84+
it("rejects javascript: URLs", () => {
85+
expect(() => posthogSchema.parse({ API_HOST: "javascript:alert(1)" })).toThrow();
86+
});
87+
88+
it("rejects XSS payloads", () => {
89+
for (const payload of xssPayloads) {
90+
expect(() => posthogSchema.parse({ TRACKING_ID: payload })).toThrow();
91+
expect(() => posthogSchema.parse({ API_HOST: payload })).toThrow();
92+
}
93+
});
94+
});
95+
96+
describe("Fathom", () => {
97+
it("accepts valid site IDs", () => {
98+
expect(fathomSchema.parse({ trackingId: "ABCDEFG" }).trackingId).toBe("ABCDEFG");
99+
expect(fathomSchema.parse({ trackingId: "abcdefg" }).trackingId).toBe("ABCDEFG");
100+
});
101+
102+
it("rejects XSS payloads", () => {
103+
for (const payload of xssPayloads) {
104+
expect(() => fathomSchema.parse({ trackingId: payload })).toThrow();
105+
}
106+
});
107+
});
108+
109+
describe("Plausible", () => {
110+
it("accepts valid domain and URL", () => {
111+
const result = plausibleSchema.parse({
112+
trackingId: "example.com",
113+
PLAUSIBLE_URL: "https://plausible.io/js/script.js",
114+
});
115+
expect(result.trackingId).toBe("example.com");
116+
expect(result.PLAUSIBLE_URL).toBe("https://plausible.io/js/script.js");
117+
});
118+
119+
it("accepts valid subdomains", () => {
120+
expect(plausibleSchema.parse({ trackingId: "sub.example.com" }).trackingId).toBe("sub.example.com");
121+
expect(plausibleSchema.parse({ trackingId: "deep.sub.example.com" }).trackingId).toBe(
122+
"deep.sub.example.com"
123+
);
124+
});
125+
126+
it("accepts single-label domains", () => {
127+
expect(plausibleSchema.parse({ trackingId: "localhost" }).trackingId).toBe("localhost");
128+
});
129+
130+
it("accepts domains with hyphens in labels", () => {
131+
expect(plausibleSchema.parse({ trackingId: "my-site.example.com" }).trackingId).toBe(
132+
"my-site.example.com"
133+
);
134+
});
135+
136+
it("rejects consecutive dots", () => {
137+
expect(() => plausibleSchema.parse({ trackingId: "example..com" })).toThrow();
138+
});
139+
140+
it("rejects hyphens at label boundaries", () => {
141+
expect(() => plausibleSchema.parse({ trackingId: "-example.com" })).toThrow();
142+
expect(() => plausibleSchema.parse({ trackingId: "example-.com" })).toThrow();
143+
expect(() => plausibleSchema.parse({ trackingId: "example.-com" })).toThrow();
144+
});
145+
146+
it("rejects XSS payloads", () => {
147+
for (const payload of xssPayloads) {
148+
expect(() => plausibleSchema.parse({ trackingId: payload })).toThrow();
149+
expect(() => plausibleSchema.parse({ PLAUSIBLE_URL: payload })).toThrow();
150+
}
151+
});
152+
});
153+
154+
describe("Matomo", () => {
155+
it("accepts valid URL and numeric site ID", () => {
156+
const result = matomoSchema.parse({
157+
MATOMO_URL: "https://matomo.example.com",
158+
SITE_ID: "42",
159+
});
160+
expect(result.MATOMO_URL).toBe("https://matomo.example.com");
161+
expect(result.SITE_ID).toBe("42");
162+
});
163+
164+
it("rejects non-numeric SITE_ID", () => {
165+
expect(() => matomoSchema.parse({ SITE_ID: "abc" })).toThrow();
166+
});
167+
168+
it("rejects XSS payloads", () => {
169+
for (const payload of xssPayloads) {
170+
expect(() => matomoSchema.parse({ MATOMO_URL: payload })).toThrow();
171+
expect(() => matomoSchema.parse({ SITE_ID: payload })).toThrow();
172+
}
173+
});
174+
});
175+
176+
describe("Umami", () => {
177+
it("accepts UUID (v2) and URL", () => {
178+
const result = umamiSchema.parse({
179+
SITE_ID: "4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b",
180+
SCRIPT_URL: "https://umami.example.com/script.js",
181+
});
182+
expect(result.SITE_ID).toBe("4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b");
183+
expect(result.SCRIPT_URL).toBe("https://umami.example.com/script.js");
184+
});
185+
186+
it("accepts numeric ID (v1)", () => {
187+
expect(umamiSchema.parse({ SITE_ID: "12345" }).SITE_ID).toBe("12345");
188+
});
189+
190+
it("rejects invalid format", () => {
191+
expect(() => umamiSchema.parse({ SITE_ID: "not-a-valid-id!" })).toThrow();
192+
});
193+
194+
it("rejects XSS payloads", () => {
195+
for (const payload of xssPayloads) {
196+
expect(() => umamiSchema.parse({ SITE_ID: payload })).toThrow();
197+
expect(() => umamiSchema.parse({ SCRIPT_URL: payload })).toThrow();
198+
}
199+
});
200+
});
201+
202+
describe("Twipla", () => {
203+
it("accepts valid site IDs", () => {
204+
expect(twiplaSchema.parse({ SITE_ID: "abc123" }).SITE_ID).toBe("abc123");
205+
expect(twiplaSchema.parse({ SITE_ID: "4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b" }).SITE_ID).toBe(
206+
"4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b"
207+
);
208+
});
209+
210+
it("rejects XSS payloads", () => {
211+
for (const payload of xssPayloads) {
212+
expect(() => twiplaSchema.parse({ SITE_ID: payload })).toThrow();
213+
}
214+
});
215+
});
216+
217+
describe("Insihts", () => {
218+
it("accepts valid site ID and URL", () => {
219+
const result = insihtsSchema.parse({
220+
SITE_ID: "site_abc123",
221+
SCRIPT_URL: "https://collector.insihts.com/script.js",
222+
});
223+
expect(result.SITE_ID).toBe("site_abc123");
224+
expect(result.SCRIPT_URL).toBe("https://collector.insihts.com/script.js");
225+
});
226+
227+
it("rejects XSS payloads", () => {
228+
for (const payload of xssPayloads) {
229+
expect(() => insihtsSchema.parse({ SITE_ID: payload })).toThrow();
230+
expect(() => insihtsSchema.parse({ SCRIPT_URL: payload })).toThrow();
231+
}
232+
});
233+
});
234+
235+
describe("Databuddy", () => {
236+
it("accepts valid client ID and URLs", () => {
237+
const result = databuddySchema.parse({
238+
CLIENT_ID: "client_abc123",
239+
DATABUDDY_SCRIPT_URL: "https://cdn.databuddy.cc/databuddy.js",
240+
DATABUDDY_API_URL: "https://basket.databuddy.cc",
241+
});
242+
expect(result.CLIENT_ID).toBe("client_abc123");
243+
expect(result.DATABUDDY_SCRIPT_URL).toBe("https://cdn.databuddy.cc/databuddy.js");
244+
expect(result.DATABUDDY_API_URL).toBe("https://basket.databuddy.cc");
245+
});
246+
247+
it("rejects XSS payloads", () => {
248+
for (const payload of xssPayloads) {
249+
expect(() => databuddySchema.parse({ CLIENT_ID: payload })).toThrow();
250+
expect(() => databuddySchema.parse({ DATABUDDY_SCRIPT_URL: payload })).toThrow();
251+
expect(() => databuddySchema.parse({ DATABUDDY_API_URL: payload })).toThrow();
252+
}
253+
});
254+
});
255+
});

packages/app-store/databuddy/zod.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
4+
import { alphanumericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
45

56
export const appDataSchema = eventTypeAppCardZod.merge(
67
z.object({
7-
DATABUDDY_SCRIPT_URL: z
8-
.string()
9-
.optional()
10-
.default("https://cdn.databuddy.cc/databuddy.js")
11-
.or(z.undefined()),
12-
DATABUDDY_API_URL: z.string().optional().default("https://basket.databuddy.cc").or(z.undefined()),
13-
CLIENT_ID: z.string().default("").optional(),
8+
DATABUDDY_SCRIPT_URL: safeUrlSchema.optional().default("https://cdn.databuddy.cc/databuddy.js").or(z.undefined()),
9+
DATABUDDY_API_URL: safeUrlSchema.optional().default("https://basket.databuddy.cc").or(z.undefined()),
10+
CLIENT_ID: alphanumericIdSchema.optional(),
1411
})
1512
);
1613

packages/app-store/fathom/zod.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
4+
import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas";
45

56
export const appDataSchema = eventTypeAppCardZod.merge(
67
z.object({
7-
trackingId: z.string().default("").optional(),
8+
trackingId: createPrefixedIdSchema({ allowEmpty: true }).optional(),
89
})
910
);
1011

packages/app-store/ga4/zod.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
4+
import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas";
45

56
export const appDataSchema = eventTypeAppCardZod.merge(
67
z.object({
7-
trackingId: z.string().default("").optional(),
8+
trackingId: createPrefixedIdSchema({ prefix: "G-", allowEmpty: true }).optional(),
89
})
910
);
1011

packages/app-store/gtm/zod.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,11 @@ import { z } from "zod";
22

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

5+
import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas";
6+
57
export const appDataSchema = eventTypeAppCardZod.merge(
68
z.object({
7-
trackingId: z.string().transform((val) => {
8-
let trackingId = val.trim();
9-
// Ensure that trackingId is transformed if needed to begin with "GTM-" always
10-
trackingId = !trackingId.startsWith("GTM-") ? `GTM-${trackingId}` : trackingId;
11-
return trackingId;
12-
}),
9+
trackingId: createPrefixedIdSchema({ prefix: "GTM-", addPrefixIfMissing: true, allowEmpty: false }),
1310
})
1411
);
1512

0 commit comments

Comments
 (0)