From 4288fa4716e749f7c464582a436101e73e50e3f2 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Tue, 16 Jun 2026 12:45:30 -0700 Subject: [PATCH] fix clickmap wildcard origin launch --- .../clickmaps/clickmap-origins.test.ts | 34 +++++++ .../analytics/clickmaps/clickmap-origins.ts | 69 +++++++++++++ .../analytics/clickmaps/page-client.tsx | 98 ++++++++----------- 3 files changed, 145 insertions(+), 56 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.test.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.ts diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.test.ts new file mode 100644 index 000000000..59e075b2f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { getClickmapOriginOptions, normalizeClickmapOrigin } from "./clickmap-origins"; + +describe("clickmap origin options", () => { + it("keeps wildcard domains out of launchable origins", () => { + const options = getClickmapOriginOptions({ + wildcard: { baseUrl: "https://**.stack-auth.com" }, + concrete: { baseUrl: "https://app.stack-auth.com/path?x=1" }, + duplicate: { baseUrl: "https://app.stack-auth.com/other" }, + }); + + expect(options).toMatchInlineSnapshot(` + { + "origins": [ + { + "id": "duplicate", + "origin": "https://app.stack-auth.com", + }, + ], + "wildcardDomains": [ + { + "baseUrl": "https://**.stack-auth.com", + "id": "wildcard", + }, + ], + } + `); + }); + + it("normalizes only HTTP(S) origins", () => { + expect(normalizeClickmapOrigin("https://app.dev.stack-auth.com/dashboard")).toMatchInlineSnapshot(`"https://app.dev.stack-auth.com"`); + expect(normalizeClickmapOrigin("javascript:alert(1)")).toMatchInlineSnapshot(`null`); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.ts new file mode 100644 index 000000000..69276991a --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/clickmap-origins.ts @@ -0,0 +1,69 @@ +export type ClickmapOrigin = { + id: string, + origin: string, +}; + +export type ClickmapWildcardDomain = { + id: string, + baseUrl: string, +}; + +export function normalizeClickmapOrigin(baseUrl: string): string | null { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return null; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return null; + } + + return url.origin; +} + +function isWildcardDomain(baseUrl: string): boolean { + return baseUrl.includes("*"); +} + +function compareStrings(a: string, b: string): number { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} + +export function getClickmapOriginOptions(trustedDomains: Record): { + origins: ClickmapOrigin[], + wildcardDomains: ClickmapWildcardDomain[], +} { + const byOrigin = new Map(); + const wildcardDomains: ClickmapWildcardDomain[] = []; + + for (const id in trustedDomains) { + const domain = trustedDomains[id]; + if (domain.baseUrl == null) { + continue; + } + + if (isWildcardDomain(domain.baseUrl)) { + wildcardDomains.push({ id, baseUrl: domain.baseUrl }); + continue; + } + + const origin = normalizeClickmapOrigin(domain.baseUrl); + if (origin == null) { + continue; + } + byOrigin.set(origin, { id, origin }); + } + + return { + origins: Array.from(byOrigin.values()).sort((a, b) => compareStrings(a.origin, b.origin)), + wildcardDomains: wildcardDomains.sort((a, b) => compareStrings(a.baseUrl, b.baseUrl)), + }; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx index 77fecceee..1573c26b3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx @@ -18,29 +18,16 @@ import { Typography, } from "@/components/ui"; import { DesignAnalyticsCard } from "@/components/design-components/analytics-card"; +import { DesignAlert } from "@/components/design-components/alert"; import { useRouter } from "@/components/router"; import type { AnalyticsClickmapTokenResponse } from "@hexclave/shared/dist/interface/admin-metrics"; import { CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY, CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, } from "@hexclave/shared/dist/utils/analytics-clickmap-overlay"; -import { typedEntries } from "@hexclave/shared/dist/utils/objects"; -import { stringCompare } from "@hexclave/shared/dist/utils/strings"; import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; - -type ClickmapOrigin = { - id: string, - origin: string, -}; - -function normalizeOrigin(baseUrl: string): string | null { - try { - return new URL(baseUrl).origin; - } catch { - return null; - } -} +import { getClickmapOriginOptions, normalizeClickmapOrigin, type ClickmapOrigin } from "./clickmap-origins"; // The clickmap token is a self-describing JWT (its payload carries the project // and origin it was minted for), so the snippet only has to hand over the token @@ -118,19 +105,8 @@ export default function PageClient() { setCustomOrigin(window.location.origin); }, []); - const origins = useMemo(() => { - const byOrigin = new Map(); - for (const [id, domain] of typedEntries(config.domains.trustedDomains)) { - if (domain.baseUrl == null) { - continue; - } - const origin = normalizeOrigin(domain.baseUrl); - if (origin == null) { - continue; - } - byOrigin.set(origin, { id, origin }); - } - return Array.from(byOrigin.values()).sort((a, b) => stringCompare(a.origin, b.origin)); + const { origins, wildcardDomains } = useMemo(() => { + return getClickmapOriginOptions(config.domains.trustedDomains); }, [config.domains.trustedDomains]); async function showClickmap(origin: ClickmapOrigin) { @@ -166,39 +142,41 @@ export default function PageClient() { description="Launch the clickmap toolbar on a trusted domain." fillWidth > - {config.domains.allowLocalhost && ( - -
-
- Localhost origin - - Use the exact origin shown in the browser address bar for your local site. - - setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" /> -
- + +
+
+ Exact page origin + + Use the exact origin shown in the browser address bar, including for domains matched by a wildcard. + + setCustomOrigin(event.target.value)} placeholder="https://app.example.com" />
- - )} + +
+
{origins.length === 0 ? (
- Add a trusted domain before launching a production clickmap. + + {wildcardDomains.length === 0 + ? "Add a trusted domain before launching a production clickmap." + : "Enter an exact origin that matches a wildcard domain, or add a concrete trusted domain."} +