From d72fd02e3c54d28367014f2465675cfa50d614b1 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 17:32:42 -0700 Subject: [PATCH 1/2] Open external links from inside canvases in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeform canvases run in a null-origin sandboxed iframe, and clicking an outbound link (e.g. an insight on PostHog.com) did nothing: there was no sanctioned navigation primitive for external URLs, and a raw `` is silently swallowed by the sandbox before it reaches the host's external-link handler. Add a `ph.navigate.toExternal(url)` shim backed by a new `external` target on the canvas nav allowlist, routed through the app's existing hardened external-open path (`openUrlInBrowser` → `os.openExternal` → `shell.openExternal`). The scheme is validated with `isSafeExternalUrl` at the postMessage boundary and again at every downstream layer, so only http(s)/mailto URLs open. A min-interval guard in the host handler prevents a buggy or malicious canvas from spamming the OS browser with tabs. The agent prompt now documents the whole `ph.navigate` surface. Generated-By: PostHog Code Task-Id: d2750f9b-efd7-44fc-9bec-1fecb7dd0c7b --- packages/core/src/canvas/canvasTemplates.ts | 3 ++ .../core/src/canvas/freeformSchemas.test.ts | 28 +++++++++++++++++++ packages/core/src/canvas/freeformSchemas.ts | 20 +++++++++++-- .../canvas/freeform/sandboxRuntime.test.ts | 14 ++++++++++ .../canvas/freeform/sandboxRuntime.ts | 6 +++- .../canvas/freeform/useHomeCanvasView.ts | 19 ++++++++++++- 6 files changed, 85 insertions(+), 5 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 d75cfba72b..ccbc593e95 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -49,6 +49,9 @@ const FREEFORM_BASE = [ '- `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.', "- 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.", + "NAVIGATION — `ph.navigate` is the ONLY way to move the user. A raw `` / `target=\"_blank\"` is silently swallowed by the sandbox and does NOTHING, so NEVER use one:", + '- OUTBOUND/external links (e.g. an insight on PostHog.com, docs, any http(s) URL) MUST call `ph.navigate.toExternal(url)` — wire it to an `onClick`, e.g. a Quill `Button variant="link"`. Only http(s)/mailto URLs open; anything else is dropped by the host.', + "- IN-APP moves: `ph.navigate.toTask(taskId)`, `ph.navigate.toNewTask()`, `ph.navigate.toCanvas(dashboardId)`, `ph.navigate.toNewCanvas()`. All fire-and-forget and stay within the current channel.", "- 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..0012bbb54f --- /dev/null +++ b/packages/core/src/canvas/freeformSchemas.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { canvasNavIntentSchema } from "./freeformSchemas"; + +describe("canvasNavIntentSchema", () => { + it.each([ + { target: "task", taskId: "t1" }, + { target: "new-task" }, + { target: "canvas", dashboardId: "d1" }, + { target: "new-canvas" }, + { target: "external", url: "https://posthog.com/insight/abc" }, + { target: "external", url: "http://example.com" }, + { target: "external", url: "mailto:support@posthog.com" }, + ])("accepts a valid intent: %o", (intent) => { + expect(canvasNavIntentSchema.safeParse(intent).success).toBe(true); + }); + + // The url refine is the first scheme-validation layer: a disallowed scheme (or + // a missing url) must be dropped before it can reach the host's browser open. + it.each([ + { name: "javascript: scheme", intent: { target: "external", url: "javascript:alert(1)" } }, + { name: "file: scheme", intent: { target: "external", url: "file:///etc/passwd" } }, + { name: "custom deep-link scheme", intent: { target: "external", url: "ms-msdt://x" } }, + { name: "non-URL string", intent: { target: "external", url: "not a url" } }, + { name: "missing url", intent: { target: "external" } }, + ])("rejects $name", ({ intent }) => { + expect(canvasNavIntentSchema.safeParse(intent).success).toBe(false); + }); +}); diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index 8f683bfa33..62ce597117 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -1,3 +1,4 @@ +import { isSafeExternalUrl } from "@posthog/shared"; import { z } from "zod"; // The template id for freeform-React canvases. Stored on a canvas's meta so the @@ -203,14 +204,27 @@ export type HostToCanvasMessage = z.infer; // The ONLY navigations a canvas may request of the host. The canvas runs // untrusted code in a null-origin iframe, so this nested union IS the security -// allowlist: there is no free-form path/route field, only these four targets. -// `channelId` is intentionally absent — the host supplies it from the loaded -// record so the iframe can never pick the channel, only which task/dashboard. +// allowlist: there is no free-form path/route field, only these targets. +// `channelId` is intentionally absent from the in-app targets — the host +// supplies it from the loaded record so the iframe can never pick the channel, +// only which task/dashboard. +// +// `external` is the one target that carries a URL, for opening an outbound link +// in the user's browser. The `isSafeExternalUrl` refine is the FIRST of several +// scheme-validation layers (repeated in openUrlInBrowser, the os.openExternal +// tRPC input, and the Electron main handler): a navigate frame whose url fails +// it is dropped at safeParse and never reaches the host. export const canvasNavIntentSchema = z.discriminatedUnion("target", [ z.object({ target: z.literal("task"), taskId: z.string().min(1) }), z.object({ target: z.literal("new-task") }), z.object({ target: z.literal("canvas"), dashboardId: z.string().min(1) }), z.object({ target: z.literal("new-canvas") }), + z.object({ + target: z.literal("external"), + url: z + .string() + .refine(isSafeExternalUrl, "Only http(s)/mailto URLs may be opened"), + }), ]); export type CanvasNavIntent = z.infer; diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts index 214cca4461..4c4b06386b 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -55,4 +55,18 @@ describe("buildSandboxDocument", () => { ); expect(html).toContain("jsxUnicodeEscapesPlugin"); }); + + it("exposes the ph.navigate shim including toExternal", () => { + const html = buildSandboxDocument("edit"); + for (const method of [ + "toTask", + "toNewTask", + "toCanvas", + "toNewCanvas", + "toExternal", + ]) { + expect(html).toContain(`${method}:`); + } + expect(html).toContain('nav: { target: "external", url }'); + }); }); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 78b26e0f26..5d9e73921e 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -228,12 +228,16 @@ export function buildSandboxDocument( }, // 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. + // cannot pick the channel or an arbitrary path — only these targets. + // toExternal opens an outbound link in the user's browser (a raw + // is swallowed by the sandbox); the host only opens + // http(s)/mailto URLs and drops anything else. navigate: { toTask: (taskId) => post({ type: "navigate", nav: { target: "task", taskId } }), toNewTask: () => post({ type: "navigate", nav: { target: "new-task" } }), toCanvas: (dashboardId) => post({ type: "navigate", nav: { target: "canvas", dashboardId } }), toNewCanvas: () => post({ type: "navigate", nav: { target: "new-canvas" } }), + toExternal: (url) => post({ type: "navigate", nav: { target: "external", url } }), }, }; diff --git a/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts b/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts index 6c06041574..eff31380ea 100644 --- a/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts +++ b/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts @@ -9,8 +9,16 @@ import { navigateToChannelNewTask, navigateToChannelTask, } from "@posthog/ui/router/navigationBridge"; +import { openUrlInBrowser } from "@posthog/ui/utils/browser"; import { useMutation } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; + +// Minimum gap between two accepted `toExternal` opens. A `navigate` frame is +// fire-and-forget over postMessage and can't carry a trusted user-gesture bit, +// so a buggy/malicious canvas could call it in a loop and spam the OS browser +// with tabs. A human can't click faster than this, so real clicks always pass; +// only a programmatic burst is throttled. +const EXTERNAL_OPEN_MIN_INTERVAL_MS = 750; /** * Routes a canvas's allowlisted nav intent to real host navigation. channelId is @@ -21,6 +29,7 @@ export function useCanvasNavigation( channelId: string, ): (intent: CanvasNavIntent) => void { const createAndOpen = useCreateAndOpenDashboard(channelId); + const lastExternalOpenRef = useRef(0); return useCallback( (intent: CanvasNavIntent) => { switch (intent.target) { @@ -36,6 +45,14 @@ export function useCanvasNavigation( case "new-canvas": void createAndOpen(); break; + case "external": { + const now = Date.now(); + if (now - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS) + break; + lastExternalOpenRef.current = now; + void openUrlInBrowser(intent.url); + break; + } } }, [channelId, createAndOpen], From 9c5fecd180428a99c8df44a43ced6f5074bf56c4 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 21 Jul 2026 17:53:59 -0700 Subject: [PATCH 2/2] Apply Biome formatting to canvas nav prompt and schema test The `quality` CI check runs `biome ci .`, which includes a format check. The navigation prompt bullets and the new `freeformSchemas.test.ts` cases weren't formatter-clean; run `biome format --write` on both. No behavior change. Generated-By: PostHog Code Task-Id: d2750f9b-efd7-44fc-9bec-1fecb7dd0c7b --- packages/core/src/canvas/canvasTemplates.ts | 2 +- .../core/src/canvas/freeformSchemas.test.ts | 20 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index ccbc593e95..f059d3b1a2 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -49,7 +49,7 @@ const FREEFORM_BASE = [ '- `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.', "- 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.", - "NAVIGATION — `ph.navigate` is the ONLY way to move the user. A raw `` / `target=\"_blank\"` is silently swallowed by the sandbox and does NOTHING, so NEVER use one:", + 'NAVIGATION — `ph.navigate` is the ONLY way to move the user. A raw `` / `target="_blank"` is silently swallowed by the sandbox and does NOTHING, so NEVER use one:', '- OUTBOUND/external links (e.g. an insight on PostHog.com, docs, any http(s) URL) MUST call `ph.navigate.toExternal(url)` — wire it to an `onClick`, e.g. a Quill `Button variant="link"`. Only http(s)/mailto URLs open; anything else is dropped by the host.', "- IN-APP moves: `ph.navigate.toTask(taskId)`, `ph.navigate.toNewTask()`, `ph.navigate.toCanvas(dashboardId)`, `ph.navigate.toNewCanvas()`. All fire-and-forget and stay within the current channel.", "- 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 index 0012bbb54f..bffd439ab7 100644 --- a/packages/core/src/canvas/freeformSchemas.test.ts +++ b/packages/core/src/canvas/freeformSchemas.test.ts @@ -17,10 +17,22 @@ describe("canvasNavIntentSchema", () => { // The url refine is the first scheme-validation layer: a disallowed scheme (or // a missing url) must be dropped before it can reach the host's browser open. it.each([ - { name: "javascript: scheme", intent: { target: "external", url: "javascript:alert(1)" } }, - { name: "file: scheme", intent: { target: "external", url: "file:///etc/passwd" } }, - { name: "custom deep-link scheme", intent: { target: "external", url: "ms-msdt://x" } }, - { name: "non-URL string", intent: { target: "external", url: "not a url" } }, + { + name: "javascript: scheme", + intent: { target: "external", url: "javascript:alert(1)" }, + }, + { + name: "file: scheme", + intent: { target: "external", url: "file:///etc/passwd" }, + }, + { + name: "custom deep-link scheme", + intent: { target: "external", url: "ms-msdt://x" }, + }, + { + name: "non-URL string", + intent: { target: "external", url: "not a url" }, + }, { name: "missing url", intent: { target: "external" } }, ])("rejects $name", ({ intent }) => { expect(canvasNavIntentSchema.safeParse(intent).success).toBe(false);