Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/canvas/canvasTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
];
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/canvas/freeformSchemas.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
8 changes: 8 additions & 0 deletions packages/core/src/canvas/freeformSchemas.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<typeof canvasToHostMessageSchema>;
2 changes: 1 addition & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 29 additions & 1 deletion packages/shared/src/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isSafeExternalUrl } from "./url";
import { isSafeExternalUrl, isSafePostHogUrl } from "./url";

describe("isSafeExternalUrl", () => {
it.each([
Expand Down Expand Up @@ -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);
});
});
18 changes: 18 additions & 0 deletions packages/shared/src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
);
}
92 changes: 92 additions & 0 deletions packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<FreeformCanvas
code="export default function Canvas() { return null }"
mode="edit"
onDataRequest={vi.fn()}
/>,
);
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");
});
});
});
32 changes: 30 additions & 2 deletions packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
};

Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildSandboxDocument,
decodeJsxUnicodeEscapes,
resolveExternalAnchorUrl,
} from "./sandboxRuntime";

describe("decodeJsxUnicodeEscapes", () => {
Expand Down Expand Up @@ -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(
'<a href="https://posthog.com/docs" target="_blank"><span>docs</span></a>',
"span",
);
expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/docs");
});

it("matches the _blank keyword case-insensitively", () => {
const target = clickTarget(
'<a href="https://posthog.com" target="_Blank">x</a>',
"a",
);
expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/");
});

it("resolves SVG anchors via the href attribute", () => {
const target = clickTarget(
'<svg><a href="https://posthog.com" target="_blank"><text>x</text></a></svg>',
"text",
);
expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/");
});

it.each([
{
name: "anchors without target=_blank",
html: '<a href="https://posthog.com">x</a>',
selector: "a",
},
{
name: "relative hrefs (would resolve against the host base URL)",
html: '<a href="/settings" target="_blank">x</a>',
selector: "a",
},
{
name: "empty hrefs",
html: '<a href="" target="_blank">x</a>',
selector: "a",
},
{
name: "clicks outside any anchor",
html: "<button>x</button>",
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();
});
});
Loading
Loading