diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index d75cfba72b..23ccc010f3 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -48,6 +48,7 @@ const FREEFORM_BASE = [ " • A SQL insight → `{ columns: string[], results: rows[][] }` — each row an array of cell values in `columns` order; read `results[rowIndex][colIndex]`.", '- `await ph.query(arg)` is the SECONDARY/escape path (ad-hoc, NOT saved) — reach for it only when you genuinely cannot save an insight. `arg` is a typed query node `ph.query({ kind: "TrendsQuery", series: [...], dateRange: {...} })` (series-object result, as above) or an inline HogQL string `ph.query("SELECT …")` (rows result, as above). Same result shapes as ph.loadInsight; prefer ph.loadInsight.', '- `ph.capture(event, properties?, distinctId?)` sends an analytics event to the project (fire-and-forget; returns a promise). Use this for click/interaction tracking — e.g. `ph.capture("button_clicked", { label })`. NEVER roll your own posthog client or fetch the capture endpoint yourself.', + '- `ph.openExternal(url)` asks the host to open an absolute `https://posthog.com` (or `*.posthog.com`) URL — anything else is blocked, so do NOT link to other sites. Call it only from a user interaction (e.g. a click handler); the host ignores opens while the canvas is not focused, so calling it on load/in effects does nothing. Sandboxed `target="_blank"` navigation is intentionally blocked.', "- Session replay, $session_id, and person attribution are handled automatically by the host's posthog-js running in the sandbox — you do NOT set session ids or initialise recording; just call ph.capture for custom events.", "- Load data inside `useEffect` with `useState`; show a loading state first, then render. Handle the empty/error case. Keep result sets small — aggregate in the query, don't fetch raw event dumps.", ]; diff --git a/packages/core/src/canvas/freeformSchemas.test.ts b/packages/core/src/canvas/freeformSchemas.test.ts new file mode 100644 index 0000000000..7f1a8381e7 --- /dev/null +++ b/packages/core/src/canvas/freeformSchemas.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { canvasToHostMessageSchema } from "./freeformSchemas"; + +describe("canvasToHostMessageSchema open-external", () => { + const message = (url: string) => ({ + channel: "posthog-canvas", + type: "open-external", + url, + }); + + it.each([ + "https://posthog.com/docs", + "https://us.posthog.com/project/2", + "https://app.posthog.com", + ])("accepts %s", (url) => { + expect(canvasToHostMessageSchema.safeParse(message(url)).success).toBe( + true, + ); + }); + + it.each([ + "https://example.com", + "http://posthog.com", + "https://posthog.com.evil.com", + "mailto:hi@posthog.com", + "javascript:alert(1)", + "file:///etc/passwd", + "/relative/path", + "", + ])("rejects %s", (url) => { + expect(canvasToHostMessageSchema.safeParse(message(url)).success).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index 8f683bfa33..dcc41fc70f 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -1,3 +1,4 @@ +import { isSafePostHogUrl } from "@posthog/shared"; import { z } from "zod"; // The template id for freeform-React canvases. Stored on a canvas's meta so the @@ -253,5 +254,12 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("navigate"), nav: canvasNavIntentSchema, }), + // Open a URL outside the sandbox. The PostHog-only https allowlist is part + // of the schema, so no consumer can forward an unvalidated URL. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("open-external"), + url: z.string().refine(isSafePostHogUrl), + }), ]); export type CanvasToHostMessage = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ab8daaa6ea..744755b7eb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -293,7 +293,7 @@ export { readParentToolCallId, } from "./tool-meta"; export { TypedEventEmitter } from "./typed-event-emitter"; -export { isSafeExternalUrl } from "./url"; +export { isSafeExternalUrl, isSafePostHogUrl } from "./url"; export { getCloudUrlFromRegion } from "./urls"; export { ALLOWED_VIDEO_MIME_TYPES, diff --git a/packages/shared/src/url.test.ts b/packages/shared/src/url.test.ts index 5fb495d609..80b26e27bb 100644 --- a/packages/shared/src/url.test.ts +++ b/packages/shared/src/url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isSafeExternalUrl } from "./url"; +import { isSafeExternalUrl, isSafePostHogUrl } from "./url"; describe("isSafeExternalUrl", () => { it.each([ @@ -28,3 +28,31 @@ describe("isSafeExternalUrl", () => { expect(isSafeExternalUrl(url)).toBe(false); }); }); + +describe("isSafePostHogUrl", () => { + it.each([ + "https://posthog.com", + "https://posthog.com/docs?q=1#frag", + "https://us.posthog.com/project/2", + "https://app.posthog.com", + "HTTPS://POSTHOG.COM/pricing", + ])("allows %s", (url) => { + expect(isSafePostHogUrl(url)).toBe(true); + }); + + it.each([ + "http://posthog.com", + "https://example.com", + "https://myposthog.com", + "https://evilposthog.com", + "https://posthog.com.evil.com", + "mailto:hi@posthog.com", + "javascript:alert(1)", + "file:///etc/passwd", + "posthog.com/docs", + "/relative/path", + "", + ])("blocks %s", (url) => { + expect(isSafePostHogUrl(url)).toBe(false); + }); +}); diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts index ac7d8ddb57..458688f266 100644 --- a/packages/shared/src/url.ts +++ b/packages/shared/src/url.ts @@ -20,3 +20,21 @@ export function isSafeExternalUrl(url: string): boolean { } return SAFE_EXTERNAL_URL_SCHEMES.has(parsed.protocol); } + +/** + * Whether a URL from untrusted code (the freeform-canvas sandbox) may be + * opened externally: absolute https URLs on posthog.com or a subdomain only. + */ +export function isSafePostHogUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return ( + parsed.protocol === "https:" && + (parsed.hostname === "posthog.com" || + parsed.hostname.endsWith(".posthog.com")) + ); +} diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx new file mode 100644 index 0000000000..7d8d142e4e --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx @@ -0,0 +1,92 @@ +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FreeformCanvas } from "./FreeformCanvas"; + +vi.mock("@posthog/ui/shell/openExternal", () => ({ + openExternalUrl: vi.fn(), +})); + +const renderCanvas = () => { + render( + , + ); + return screen.getByTitle("Canvas") as HTMLIFrameElement; +}; + +const postFromCanvas = (iframe: HTMLIFrameElement, url: string) => { + window.dispatchEvent( + new MessageEvent("message", { + data: { channel: "posthog-canvas", type: "open-external", url }, + source: iframe.contentWindow, + }), + ); +}; + +describe("FreeformCanvas", () => { + it("does not grant the sandbox popup permission", () => { + renderCanvas(); + + expect(screen.getByTitle("Canvas")).toHaveAttribute( + "sandbox", + "allow-scripts", + ); + }); + + describe("open-external", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.mocked(openExternalUrl).mockClear(); + }); + + it("opens PostHog https URLs once the user has focused the canvas", () => { + const iframe = renderCanvas(); + iframe.focus(); + + postFromCanvas(iframe, "https://posthog.com/docs"); + + expect(openExternalUrl).toHaveBeenCalledWith("https://posthog.com/docs"); + }); + + it("drops opens when the user has not interacted with the canvas", () => { + const iframe = renderCanvas(); + + postFromCanvas(iframe, "https://posthog.com/docs"); + + expect(openExternalUrl).not.toHaveBeenCalled(); + }); + + it("drops non-PostHog URLs", () => { + const iframe = renderCanvas(); + iframe.focus(); + + postFromCanvas(iframe, "https://example.com"); + postFromCanvas(iframe, "javascript:alert(1)"); + postFromCanvas(iframe, "mailto:hi@posthog.com"); + + expect(openExternalUrl).not.toHaveBeenCalled(); + }); + + it("throttles rapid opens so canvas code cannot spam the launcher", () => { + const iframe = renderCanvas(); + iframe.focus(); + + postFromCanvas(iframe, "https://posthog.com/a"); + postFromCanvas(iframe, "https://posthog.com/b"); + expect(openExternalUrl).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1_001); + postFromCanvas(iframe, "https://posthog.com/c"); + expect(openExternalUrl).toHaveBeenCalledTimes(2); + expect(openExternalUrl).toHaveBeenLastCalledWith("https://posthog.com/c"); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index 9a64218654..caff949ff5 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -5,7 +5,9 @@ import { canvasToHostMessageSchema, type HostToCanvasMessage, } from "@posthog/core/canvas/freeformSchemas"; +import { isSafePostHogUrl } from "@posthog/shared"; import { logger } from "@posthog/ui/shell/logger"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; import { useCallback, @@ -18,6 +20,9 @@ import { buildSandboxDocument, type SandboxMode } from "./sandboxRuntime"; const log = logger.scope("freeform-canvas"); +// Canvas code can post open-external without a gesture, so opens are limited. +const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000; + export interface FreeformCanvasProps { /** The single-file React source to render. */ code: string; @@ -74,6 +79,7 @@ export function FreeformCanvas({ // only gates an imperative postMessage and is never shown on screen, so it // shouldn't trigger re-renders. const readyRef = useRef(false); + const lastExternalOpenRef = useRef(0); // The document is keyed on mode + the analytics host (which the CSP must open // for posthog-js), not on code: code is injected via `init`, so changing it @@ -182,6 +188,28 @@ export function FreeformCanvas({ // msg.nav is already allowlist-validated by safeParse below. latest.current.onNavigate?.(msg.nav); break; + case "open-external": + // Re-checks the schema's allowlist refine in case it ever drifts. + if (!isSafePostHogUrl(msg.url)) { + log.warn("Blocked non-PostHog canvas external URL", { + url: msg.url, + }); + } else if (document.activeElement !== iframeRef.current) { + // A real link click moves focus into the iframe; requiring focus + // stops code from auto-opening URLs on load (e.g. thumbnails). + log.warn("Ignored canvas external URL open without interaction", { + url: msg.url, + }); + } else if ( + Date.now() - lastExternalOpenRef.current < + EXTERNAL_OPEN_MIN_INTERVAL_MS + ) { + log.warn("Throttled canvas external URL open", { url: msg.url }); + } else { + lastExternalOpenRef.current = Date.now(); + openExternalUrl(msg.url); + } + break; } }; @@ -237,8 +265,8 @@ export function FreeformCanvas({ ref={iframeRef} title="Canvas" // allow-scripts WITHOUT allow-same-origin = null origin = no access to host - // cookies/storage/DOM. Do not add allow-same-origin (it collapses the - // isolation boundary). + // cookies/storage/DOM. External navigation is brokered over postMessage; + // do not add allow-popups or allow-same-origin. sandbox="allow-scripts" srcDoc={srcDoc} // Race-free init: by `load`, the iframe's module bootstrap has executed diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts index 214cca4461..617363d19e 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildSandboxDocument, decodeJsxUnicodeEscapes, + resolveExternalAnchorUrl, } from "./sandboxRuntime"; describe("decodeJsxUnicodeEscapes", () => { @@ -55,4 +56,77 @@ describe("buildSandboxDocument", () => { ); expect(html).toContain("jsxUnicodeEscapesPlugin"); }); + + it("inlines the external-anchor resolver into the bootstrap", () => { + const html = buildSandboxDocument("edit"); + expect(html).toContain( + "const resolveExternalAnchorUrl = function resolveExternalAnchorUrl(", + ); + expect(html).toContain('"open-external"'); + expect(html).toContain("event.defaultPrevented"); + }); +}); + +describe("resolveExternalAnchorUrl", () => { + const clickTarget = (html: string, selector: string): Element => { + const container = document.createElement("div"); + container.innerHTML = html; + const el = container.querySelector(selector); + if (!el) throw new Error(`selector ${selector} not found`); + return el; + }; + + it("resolves a click inside a target=_blank anchor to its absolute URL", () => { + const target = clickTarget( + 'docs', + "span", + ); + expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/docs"); + }); + + it("matches the _blank keyword case-insensitively", () => { + const target = clickTarget( + 'x', + "a", + ); + expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/"); + }); + + it("resolves SVG anchors via the href attribute", () => { + const target = clickTarget( + 'x', + "text", + ); + expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/"); + }); + + it.each([ + { + name: "anchors without target=_blank", + html: 'x', + selector: "a", + }, + { + name: "relative hrefs (would resolve against the host base URL)", + html: 'x', + selector: "a", + }, + { + name: "empty hrefs", + html: 'x', + selector: "a", + }, + { + name: "clicks outside any anchor", + html: "", + selector: "button", + }, + ])("returns null for $name", ({ html, selector }) => { + expect(resolveExternalAnchorUrl(clickTarget(html, selector))).toBeNull(); + }); + + it("returns null for non-Element targets", () => { + expect(resolveExternalAnchorUrl(null)).toBeNull(); + expect(resolveExternalAnchorUrl(document.createTextNode("x"))).toBeNull(); + }); }); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 78b26e0f26..641a8c0c43 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -143,6 +143,25 @@ export function decodeJsxUnicodeEscapes(value: string): string { ); } +// Resolves a click target to the absolute URL of an enclosing target="_blank" +// anchor, or null. Interpolated into the sandbox bootstrap; exported for tests. +export function resolveExternalAnchorUrl(target: unknown): string | null { + const anchor = target instanceof Element ? target.closest("a[href]") : null; + if (!anchor) return null; + // HTML matches the _blank keyword ASCII-case-insensitively. + if ((anchor.getAttribute("target") ?? "").toLowerCase() !== "_blank") { + return null; + } + // getAttribute, not the .href property: SVG anchors expose SVGAnimatedString + // there, and relative hrefs would resolve against the host's base URL. + const href = anchor.getAttribute("href") ?? ""; + try { + return new URL(href).href; + } catch { + return null; + } +} + export function buildSandboxDocument( mode: SandboxMode, // The PostHog host, when in-iframe analytics/replay is enabled. Opens CSP for @@ -226,6 +245,9 @@ export function buildSandboxDocument( } return call("capture", { event, properties: properties ?? {}, distinctId }); }, + // Brokered by the host: PostHog-only https URLs, rate-limited, and + // ignored while the canvas is unfocused (no auto-opens on load). + openExternal: (url) => post({ type: "open-external", url }), // Navigate the host app. Fire-and-forget: the host validates the intent // against its allowlist and routes within the current channel. The canvas // cannot pick the channel or an arbitrary path — only these four targets. @@ -237,6 +259,23 @@ export function buildSandboxDocument( }, }; + // Keep target="_blank" anchors working without popup permission. Capture + // phase so stopPropagation() can't swallow the click; the open is deferred + // a tick so preventDefault() is honored (the native popup attempt is + // sandbox-blocked regardless, so we never call preventDefault ourselves). + const resolveExternalAnchorUrl = ${resolveExternalAnchorUrl.toString()}; + document.addEventListener( + "click", + (event) => { + const url = resolveExternalAnchorUrl(event.target); + if (!url) return; + setTimeout(() => { + if (!event.defaultPrevented) window.ph.openExternal(url); + }, 0); + }, + true, + ); + // Boot posthog-js with the PUBLIC key the host passed in (never the read // token). Enables session replay so the author/viewer can be watched. const bootAnalytics = async (cfg) => {