From d26d73fea1fe1861e342271a104e52d406fdb7e8 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 21 Jul 2026 18:49:52 +0200 Subject: [PATCH 1/4] fix(canvas): broker external links through host Generated-By: PostHog Code Task-Id: 2734dc0c-3cac-44d9-bc73-c86e58685df1 --- packages/core/src/canvas/canvasTemplates.ts | 1 + packages/core/src/canvas/freeformSchemas.ts | 7 +++++++ .../canvas/freeform/FreeformCanvas.test.tsx | 20 +++++++++++++++++++ .../canvas/freeform/FreeformCanvas.tsx | 10 ++++++++-- .../canvas/freeform/sandboxRuntime.test.ts | 6 ++++++ .../canvas/freeform/sandboxRuntime.ts | 14 +++++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index d75cfba72b..c96ddecb09 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 `http:`, `https:`, or `mailto:` URL after scheme validation. Use it for links that leave the canvas; 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.ts b/packages/core/src/canvas/freeformSchemas.ts index 8f683bfa33..a6cea8e713 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -253,5 +253,12 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("navigate"), nav: canvasNavIntentSchema, }), + // A request to open a URL outside the sandbox. The host applies the shared + // http/https/mailto scheme allowlist before invoking its external launcher. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("open-external"), + url: z.string(), + }), ]); export type CanvasToHostMessage = z.infer; 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..82bb1647a4 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FreeformCanvas } from "./FreeformCanvas"; + +describe("FreeformCanvas", () => { + it("does not grant the sandbox popup permission", () => { + render( + , + ); + + expect(screen.getByTitle("Canvas")).toHaveAttribute( + "sandbox", + "allow-scripts", + ); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index 9a64218654..c429a70b05 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 { isSafeExternalUrl } 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, @@ -182,6 +184,10 @@ export function FreeformCanvas({ // msg.nav is already allowlist-validated by safeParse below. latest.current.onNavigate?.(msg.nav); break; + case "open-external": + if (isSafeExternalUrl(msg.url)) openExternalUrl(msg.url); + else log.warn("Blocked unsafe canvas external URL"); + break; } }; @@ -237,8 +243,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..b143fe7e2b 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -55,4 +55,10 @@ describe("buildSandboxDocument", () => { ); expect(html).toContain("jsxUnicodeEscapesPlugin"); }); + + it("brokers external links through the host instead of granting popups", () => { + const html = buildSandboxDocument("edit"); + expect(html).toContain('openExternal: (url) => post({ type: "open-external", url })'); + expect(html).toContain('anchor.getAttribute("target") !== "_blank"'); + }); }); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 78b26e0f26..8503058d83 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -226,6 +226,9 @@ export function buildSandboxDocument( } return call("capture", { event, properties: properties ?? {}, distinctId }); }, + // External navigation is brokered by the host. The iframe has no popup + // permission; the host validates the scheme before opening anything. + 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 +240,17 @@ export function buildSandboxDocument( }, }; + // Preserve normal anchor ergonomics for generated canvases while keeping + // navigation behind the validated host capability. Existing canvases that + // render target="_blank" links therefore work without source migrations. + document.addEventListener("click", (event) => { + const target = event.target; + const anchor = target instanceof Element ? target.closest("a[href]") : null; + if (!anchor || anchor.getAttribute("target") !== "_blank") return; + event.preventDefault(); + window.ph.openExternal(anchor.href); + }); + // 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) => { From c1e8a3c5f8240b2f21d22e3dbdce2dfe26a86395 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 22 Jul 2026 11:00:25 +0200 Subject: [PATCH 2/4] fix(canvas): restrict external links to PostHog URLs and harden click broker Address review findings on the open-external path: - New isSafePostHogUrl in @posthog/shared: only absolute https posthog.com (or subdomain) URLs may leave the canvas sandbox. Enforced in the Zod message schema (safe for every consumer by construction) and re-checked in the FreeformCanvas handler with the blocked URL logged. - Rate-limit successful opens host-side: canvas code can post open-external without a user gesture, so opens are throttled to one per second. - Rewrite the sandbox click interceptor as an exported, unit-tested resolveExternalAnchorUrl inlined into the bootstrap: reads the href attribute (fixes SVG anchors and relative hrefs resolving against the host base URL), matches _blank case-insensitively, brokers absolute URLs only, listens in capture phase (immune to stopPropagation) and defers the open a tick so a canvas preventDefault() is honored. - Replace source-string test assertions with behavioral tests, and cover the message path (allowlist, rejection, throttle) in FreeformCanvas. Generated-By: PostHog Code Task-Id: b3fd12d3-df9b-416d-a660-0dc12ad82803 --- packages/core/src/canvas/canvasTemplates.ts | 2 +- .../core/src/canvas/freeformSchemas.test.ts | 35 +++++++++ packages/core/src/canvas/freeformSchemas.ts | 8 +- packages/shared/src/index.ts | 2 +- packages/shared/src/url.test.ts | 30 +++++++- packages/shared/src/url.ts | 20 +++++ .../canvas/freeform/FreeformCanvas.test.tsx | 77 +++++++++++++++++-- .../canvas/freeform/FreeformCanvas.tsx | 25 +++++- .../canvas/freeform/sandboxRuntime.test.ts | 75 +++++++++++++++++- .../canvas/freeform/sandboxRuntime.ts | 49 ++++++++++-- 10 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/canvas/freeformSchemas.test.ts diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index c96ddecb09..d178fc6be8 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -48,7 +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 `http:`, `https:`, or `mailto:` URL after scheme validation. Use it for links that leave the canvas; sandboxed `target="_blank"` navigation is intentionally blocked.', + '- `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. Use it for links that leave the canvas; 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 a6cea8e713..b9e617fcda 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,12 +254,13 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("navigate"), nav: canvasNavIntentSchema, }), - // A request to open a URL outside the sandbox. The host applies the shared - // http/https/mailto scheme allowlist before invoking its external launcher. + // A request to open a URL outside the sandbox. The allowlist is part of the + // schema: only absolute https://posthog.com (or subdomain) URLs parse, so + // every consumer drops anything else before it can reach a launcher. z.object({ channel: z.literal(CANVAS_CHANNEL), type: z.literal("open-external"), - url: z.string(), + 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..23c1cf50b4 100644 --- a/packages/shared/src/url.ts +++ b/packages/shared/src/url.ts @@ -20,3 +20,23 @@ 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. Stricter than `isSafeExternalUrl`: only absolute https + * URLs on posthog.com or a subdomain, so a malicious or generated canvas + * cannot send the viewer to an arbitrary site. + */ +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 index 82bb1647a4..337c8493d9 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx @@ -1,20 +1,81 @@ +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +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", () => { - render( - , - ); + 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", () => { + const iframe = renderCanvas(); + + postFromCanvas(iframe, "https://posthog.com/docs"); + + expect(openExternalUrl).toHaveBeenCalledWith("https://posthog.com/docs"); + }); + + it("drops non-PostHog URLs", () => { + const iframe = renderCanvas(); + + 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(); + + 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 c429a70b05..c83830ab5b 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -5,7 +5,7 @@ import { canvasToHostMessageSchema, type HostToCanvasMessage, } from "@posthog/core/canvas/freeformSchemas"; -import { isSafeExternalUrl } from "@posthog/shared"; +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"; @@ -20,6 +20,10 @@ import { buildSandboxDocument, type SandboxMode } from "./sandboxRuntime"; const log = logger.scope("freeform-canvas"); +// Canvas code is untrusted and can post open-external without a user gesture, +// so successful opens are rate-limited host-side. +const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000; + export interface FreeformCanvasProps { /** The single-file React source to render. */ code: string; @@ -76,6 +80,8 @@ 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); + // Timestamp of the last brokered external open, for the rate limit. + 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 @@ -185,8 +191,21 @@ export function FreeformCanvas({ latest.current.onNavigate?.(msg.nav); break; case "open-external": - if (isSafeExternalUrl(msg.url)) openExternalUrl(msg.url); - else log.warn("Blocked unsafe canvas external URL"); + // The schema already refines on the PostHog-only allowlist; the + // re-check keeps the invariant local if the schema ever drifts. + if (!isSafePostHogUrl(msg.url)) { + log.warn("Blocked non-PostHog canvas external URL", { + 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; } }; diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts index b143fe7e2b..caf9e31984 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", () => { @@ -56,9 +57,77 @@ describe("buildSandboxDocument", () => { expect(html).toContain("jsxUnicodeEscapesPlugin"); }); - it("brokers external links through the host instead of granting popups", () => { + it("inlines the external-anchor resolver into the bootstrap", () => { const html = buildSandboxDocument("edit"); - expect(html).toContain('openExternal: (url) => post({ type: "open-external", url })'); - expect(html).toContain('anchor.getAttribute("target") !== "_blank"'); + expect(html).toContain( + "const resolveExternalAnchorUrl = function resolveExternalAnchorUrl(", + ); + expect(html).toContain('"open-external"'); + // The open decision is deferred so a canvas preventDefault() is honored. + 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 8503058d83..0dd19e5936 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -143,6 +143,29 @@ export function decodeJsxUnicodeEscapes(value: string): string { ); } +// Resolves a click target to the absolute URL of an enclosing target="_blank" +// anchor, or null when the click must not be brokered to the host. Exported for +// tests; its source is interpolated into the sandbox bootstrap below so the +// iframe runs this exact implementation. +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; + } + // Read the attribute, not the .href IDL property: SVG anchors expose an + // SVGAnimatedString there, and the property resolves relative hrefs against + // the srcdoc's inherited base URL (the host app's own URL). Only absolute + // URLs are brokered; the host then enforces its PostHog-only allowlist. + 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 @@ -227,7 +250,8 @@ export function buildSandboxDocument( return call("capture", { event, properties: properties ?? {}, distinctId }); }, // External navigation is brokered by the host. The iframe has no popup - // permission; the host validates the scheme before opening anything. + // permission; the host only opens https://posthog.com (or subdomain) + // URLs and rate-limits opens, since canvas code is untrusted. 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 @@ -243,13 +267,22 @@ export function buildSandboxDocument( // Preserve normal anchor ergonomics for generated canvases while keeping // navigation behind the validated host capability. Existing canvases that // render target="_blank" links therefore work without source migrations. - document.addEventListener("click", (event) => { - const target = event.target; - const anchor = target instanceof Element ? target.closest("a[href]") : null; - if (!anchor || anchor.getAttribute("target") !== "_blank") return; - event.preventDefault(); - window.ph.openExternal(anchor.href); - }); + // Capture phase so a canvas stopPropagation() can't swallow the click; the + // open decision is deferred a tick so a canvas preventDefault() is still + // honored. No preventDefault here — the native popup attempt is blocked by + // the sandbox (no allow-popups) regardless. + 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. From 39d785a540e046e7964711abafa2c3264dc30d5a Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 22 Jul 2026 11:10:32 +0200 Subject: [PATCH 3/4] fix(canvas): require canvas interaction before external opens Addresses the veria-ai review finding: canvas code could call ph.openExternal during module evaluation or an effect, opening URLs as soon as a viewer loads the canvas (including dashboard thumbnails, which render without any click). The host can't observe gestures inside the null-origin iframe, but a real link click moves focus into it, so the open-external handler now ignores requests while the canvas iframe is not the focused element. Combined with the PostHog-only allowlist and the per-second throttle, load-time auto-opens are dropped. Generated-By: PostHog Code Task-Id: b3fd12d3-df9b-416d-a660-0dc12ad82803 --- packages/core/src/canvas/canvasTemplates.ts | 2 +- .../canvas/freeform/FreeformCanvas.test.tsx | 13 ++++++++++++- .../src/features/canvas/freeform/FreeformCanvas.tsx | 8 ++++++++ .../src/features/canvas/freeform/sandboxRuntime.ts | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index d178fc6be8..23ccc010f3 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -48,7 +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. Use it for links that leave the canvas; sandboxed `target="_blank"` navigation is intentionally blocked.', + '- `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/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx index 337c8493d9..7d8d142e4e 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx @@ -47,16 +47,26 @@ describe("FreeformCanvas", () => { vi.mocked(openExternalUrl).mockClear(); }); - it("opens PostHog https URLs", () => { + 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)"); @@ -67,6 +77,7 @@ describe("FreeformCanvas", () => { 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"); diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index c83830ab5b..753dcf39e1 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -197,6 +197,14 @@ export function FreeformCanvas({ log.warn("Blocked non-PostHog canvas external URL", { url: msg.url, }); + } else if (document.activeElement !== iframeRef.current) { + // The host can't observe gestures inside the null-origin iframe, + // but a real link click moves focus INTO it. Requiring the canvas + // to hold focus stops code from auto-opening URLs on load — e.g. + // a shared canvas or a dashboard thumbnail rendering offscreen. + log.warn("Ignored canvas external URL open without interaction", { + url: msg.url, + }); } else if ( Date.now() - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 0dd19e5936..8ddfb53285 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -251,7 +251,8 @@ export function buildSandboxDocument( }, // External navigation is brokered by the host. The iframe has no popup // permission; the host only opens https://posthog.com (or subdomain) - // URLs and rate-limits opens, since canvas code is untrusted. + // URLs, rate-limits opens, and ignores requests while the canvas is + // unfocused (no auto-opens on load), since canvas code is untrusted. 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 From 1e5a48b63bf41ed90970a755c6e65029faf4dc3a Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Wed, 22 Jul 2026 11:40:29 +0200 Subject: [PATCH 4/4] chore(canvas): trim comments to load-bearing constraints Generated-By: PostHog Code Task-Id: b3fd12d3-df9b-416d-a660-0dc12ad82803 --- packages/core/src/canvas/freeformSchemas.ts | 5 ++-- packages/shared/src/url.ts | 6 ++--- .../canvas/freeform/FreeformCanvas.tsx | 13 +++------ .../canvas/freeform/sandboxRuntime.test.ts | 1 - .../canvas/freeform/sandboxRuntime.ts | 27 +++++++------------ 5 files changed, 17 insertions(+), 35 deletions(-) diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index b9e617fcda..dcc41fc70f 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -254,9 +254,8 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("navigate"), nav: canvasNavIntentSchema, }), - // A request to open a URL outside the sandbox. The allowlist is part of the - // schema: only absolute https://posthog.com (or subdomain) URLs parse, so - // every consumer drops anything else before it can reach a launcher. + // 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"), diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts index 23c1cf50b4..458688f266 100644 --- a/packages/shared/src/url.ts +++ b/packages/shared/src/url.ts @@ -22,10 +22,8 @@ export function isSafeExternalUrl(url: string): boolean { } /** - * Whether a URL from UNTRUSTED code (the freeform-canvas sandbox) may be - * opened externally. Stricter than `isSafeExternalUrl`: only absolute https - * URLs on posthog.com or a subdomain, so a malicious or generated canvas - * cannot send the viewer to an arbitrary site. + * 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; diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index 753dcf39e1..caff949ff5 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -20,8 +20,7 @@ import { buildSandboxDocument, type SandboxMode } from "./sandboxRuntime"; const log = logger.scope("freeform-canvas"); -// Canvas code is untrusted and can post open-external without a user gesture, -// so successful opens are rate-limited host-side. +// Canvas code can post open-external without a gesture, so opens are limited. const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000; export interface FreeformCanvasProps { @@ -80,7 +79,6 @@ 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); - // Timestamp of the last brokered external open, for the rate limit. const lastExternalOpenRef = useRef(0); // The document is keyed on mode + the analytics host (which the CSP must open @@ -191,17 +189,14 @@ export function FreeformCanvas({ latest.current.onNavigate?.(msg.nav); break; case "open-external": - // The schema already refines on the PostHog-only allowlist; the - // re-check keeps the invariant local if the schema ever drifts. + // 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) { - // The host can't observe gestures inside the null-origin iframe, - // but a real link click moves focus INTO it. Requiring the canvas - // to hold focus stops code from auto-opening URLs on load — e.g. - // a shared canvas or a dashboard thumbnail rendering offscreen. + // 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, }); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts index caf9e31984..617363d19e 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -63,7 +63,6 @@ describe("buildSandboxDocument", () => { "const resolveExternalAnchorUrl = function resolveExternalAnchorUrl(", ); expect(html).toContain('"open-external"'); - // The open decision is deferred so a canvas preventDefault() is honored. expect(html).toContain("event.defaultPrevented"); }); }); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 8ddfb53285..641a8c0c43 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -144,9 +144,7 @@ export function decodeJsxUnicodeEscapes(value: string): string { } // Resolves a click target to the absolute URL of an enclosing target="_blank" -// anchor, or null when the click must not be brokered to the host. Exported for -// tests; its source is interpolated into the sandbox bootstrap below so the -// iframe runs this exact implementation. +// 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; @@ -154,10 +152,8 @@ export function resolveExternalAnchorUrl(target: unknown): string | null { if ((anchor.getAttribute("target") ?? "").toLowerCase() !== "_blank") { return null; } - // Read the attribute, not the .href IDL property: SVG anchors expose an - // SVGAnimatedString there, and the property resolves relative hrefs against - // the srcdoc's inherited base URL (the host app's own URL). Only absolute - // URLs are brokered; the host then enforces its PostHog-only allowlist. + // 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; @@ -249,10 +245,8 @@ export function buildSandboxDocument( } return call("capture", { event, properties: properties ?? {}, distinctId }); }, - // External navigation is brokered by the host. The iframe has no popup - // permission; the host only opens https://posthog.com (or subdomain) - // URLs, rate-limits opens, and ignores requests while the canvas is - // unfocused (no auto-opens on load), since canvas code is untrusted. + // 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 @@ -265,13 +259,10 @@ export function buildSandboxDocument( }, }; - // Preserve normal anchor ergonomics for generated canvases while keeping - // navigation behind the validated host capability. Existing canvases that - // render target="_blank" links therefore work without source migrations. - // Capture phase so a canvas stopPropagation() can't swallow the click; the - // open decision is deferred a tick so a canvas preventDefault() is still - // honored. No preventDefault here — the native popup attempt is blocked by - // the sandbox (no allow-popups) regardless. + // 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",