Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit d2afabe

Browse files
authored
fix(canvas): securely open external links (#3645)
1 parent 7f1dbba commit d2afabe

10 files changed

Lines changed: 327 additions & 4 deletions

File tree

packages/core/src/canvas/canvasTemplates.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const FREEFORM_BASE = [
4848
" • A SQL insight → `{ columns: string[], results: rows[][] }` — each row an array of cell values in `columns` order; read `results[rowIndex][colIndex]`.",
4949
'- `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.',
5050
'- `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.',
51+
'- `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.',
5152
"- 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.",
5253
"- 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.",
5354
];
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { canvasToHostMessageSchema } from "./freeformSchemas";
3+
4+
describe("canvasToHostMessageSchema open-external", () => {
5+
const message = (url: string) => ({
6+
channel: "posthog-canvas",
7+
type: "open-external",
8+
url,
9+
});
10+
11+
it.each([
12+
"https://posthog.com/docs",
13+
"https://us.posthog.com/project/2",
14+
"https://app.posthog.com",
15+
])("accepts %s", (url) => {
16+
expect(canvasToHostMessageSchema.safeParse(message(url)).success).toBe(
17+
true,
18+
);
19+
});
20+
21+
it.each([
22+
"https://example.com",
23+
"http://posthog.com",
24+
"https://posthog.com.evil.com",
25+
"mailto:hi@posthog.com",
26+
"javascript:alert(1)",
27+
"file:///etc/passwd",
28+
"/relative/path",
29+
"",
30+
])("rejects %s", (url) => {
31+
expect(canvasToHostMessageSchema.safeParse(message(url)).success).toBe(
32+
false,
33+
);
34+
});
35+
});

packages/core/src/canvas/freeformSchemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isSafePostHogUrl } from "@posthog/shared";
12
import { z } from "zod";
23

34
// 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", [
253254
type: z.literal("navigate"),
254255
nav: canvasNavIntentSchema,
255256
}),
257+
// Open a URL outside the sandbox. The PostHog-only https allowlist is part
258+
// of the schema, so no consumer can forward an unvalidated URL.
259+
z.object({
260+
channel: z.literal(CANVAS_CHANNEL),
261+
type: z.literal("open-external"),
262+
url: z.string().refine(isSafePostHogUrl),
263+
}),
256264
]);
257265
export type CanvasToHostMessage = z.infer<typeof canvasToHostMessageSchema>;

packages/shared/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ export {
306306
readParentToolCallId,
307307
} from "./tool-meta";
308308
export { TypedEventEmitter } from "./typed-event-emitter";
309-
export { isSafeExternalUrl } from "./url";
309+
export { isSafeExternalUrl, isSafePostHogUrl } from "./url";
310310
export { getCloudUrlFromRegion } from "./urls";
311311
export {
312312
ALLOWED_VIDEO_MIME_TYPES,

packages/shared/src/url.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { isSafeExternalUrl } from "./url";
2+
import { isSafeExternalUrl, isSafePostHogUrl } from "./url";
33

44
describe("isSafeExternalUrl", () => {
55
it.each([
@@ -28,3 +28,31 @@ describe("isSafeExternalUrl", () => {
2828
expect(isSafeExternalUrl(url)).toBe(false);
2929
});
3030
});
31+
32+
describe("isSafePostHogUrl", () => {
33+
it.each([
34+
"https://posthog.com",
35+
"https://posthog.com/docs?q=1#frag",
36+
"https://us.posthog.com/project/2",
37+
"https://app.posthog.com",
38+
"HTTPS://POSTHOG.COM/pricing",
39+
])("allows %s", (url) => {
40+
expect(isSafePostHogUrl(url)).toBe(true);
41+
});
42+
43+
it.each([
44+
"http://posthog.com",
45+
"https://example.com",
46+
"https://myposthog.com",
47+
"https://evilposthog.com",
48+
"https://posthog.com.evil.com",
49+
"mailto:hi@posthog.com",
50+
"javascript:alert(1)",
51+
"file:///etc/passwd",
52+
"posthog.com/docs",
53+
"/relative/path",
54+
"",
55+
])("blocks %s", (url) => {
56+
expect(isSafePostHogUrl(url)).toBe(false);
57+
});
58+
});

packages/shared/src/url.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,21 @@ export function isSafeExternalUrl(url: string): boolean {
2020
}
2121
return SAFE_EXTERNAL_URL_SCHEMES.has(parsed.protocol);
2222
}
23+
24+
/**
25+
* Whether a URL from untrusted code (the freeform-canvas sandbox) may be
26+
* opened externally: absolute https URLs on posthog.com or a subdomain only.
27+
*/
28+
export function isSafePostHogUrl(url: string): boolean {
29+
let parsed: URL;
30+
try {
31+
parsed = new URL(url);
32+
} catch {
33+
return false;
34+
}
35+
return (
36+
parsed.protocol === "https:" &&
37+
(parsed.hostname === "posthog.com" ||
38+
parsed.hostname.endsWith(".posthog.com"))
39+
);
40+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
2+
import { render, screen } from "@testing-library/react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { FreeformCanvas } from "./FreeformCanvas";
5+
6+
vi.mock("@posthog/ui/shell/openExternal", () => ({
7+
openExternalUrl: vi.fn(),
8+
}));
9+
10+
const renderCanvas = () => {
11+
render(
12+
<FreeformCanvas
13+
code="export default function Canvas() { return null }"
14+
mode="edit"
15+
onDataRequest={vi.fn()}
16+
/>,
17+
);
18+
return screen.getByTitle("Canvas") as HTMLIFrameElement;
19+
};
20+
21+
const postFromCanvas = (iframe: HTMLIFrameElement, url: string) => {
22+
window.dispatchEvent(
23+
new MessageEvent("message", {
24+
data: { channel: "posthog-canvas", type: "open-external", url },
25+
source: iframe.contentWindow,
26+
}),
27+
);
28+
};
29+
30+
describe("FreeformCanvas", () => {
31+
it("does not grant the sandbox popup permission", () => {
32+
renderCanvas();
33+
34+
expect(screen.getByTitle("Canvas")).toHaveAttribute(
35+
"sandbox",
36+
"allow-scripts",
37+
);
38+
});
39+
40+
describe("open-external", () => {
41+
beforeEach(() => {
42+
vi.useFakeTimers();
43+
});
44+
45+
afterEach(() => {
46+
vi.useRealTimers();
47+
vi.mocked(openExternalUrl).mockClear();
48+
});
49+
50+
it("opens PostHog https URLs once the user has focused the canvas", () => {
51+
const iframe = renderCanvas();
52+
iframe.focus();
53+
54+
postFromCanvas(iframe, "https://posthog.com/docs");
55+
56+
expect(openExternalUrl).toHaveBeenCalledWith("https://posthog.com/docs");
57+
});
58+
59+
it("drops opens when the user has not interacted with the canvas", () => {
60+
const iframe = renderCanvas();
61+
62+
postFromCanvas(iframe, "https://posthog.com/docs");
63+
64+
expect(openExternalUrl).not.toHaveBeenCalled();
65+
});
66+
67+
it("drops non-PostHog URLs", () => {
68+
const iframe = renderCanvas();
69+
iframe.focus();
70+
71+
postFromCanvas(iframe, "https://example.com");
72+
postFromCanvas(iframe, "javascript:alert(1)");
73+
postFromCanvas(iframe, "mailto:hi@posthog.com");
74+
75+
expect(openExternalUrl).not.toHaveBeenCalled();
76+
});
77+
78+
it("throttles rapid opens so canvas code cannot spam the launcher", () => {
79+
const iframe = renderCanvas();
80+
iframe.focus();
81+
82+
postFromCanvas(iframe, "https://posthog.com/a");
83+
postFromCanvas(iframe, "https://posthog.com/b");
84+
expect(openExternalUrl).toHaveBeenCalledTimes(1);
85+
86+
vi.advanceTimersByTime(1_001);
87+
postFromCanvas(iframe, "https://posthog.com/c");
88+
expect(openExternalUrl).toHaveBeenCalledTimes(2);
89+
expect(openExternalUrl).toHaveBeenLastCalledWith("https://posthog.com/c");
90+
});
91+
});
92+
});

packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
canvasToHostMessageSchema,
66
type HostToCanvasMessage,
77
} from "@posthog/core/canvas/freeformSchemas";
8+
import { isSafePostHogUrl } from "@posthog/shared";
89
import { logger } from "@posthog/ui/shell/logger";
10+
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
911
import { useThemeStore } from "@posthog/ui/shell/themeStore";
1012
import {
1113
useCallback,
@@ -18,6 +20,9 @@ import { buildSandboxDocument, type SandboxMode } from "./sandboxRuntime";
1820

1921
const log = logger.scope("freeform-canvas");
2022

23+
// Canvas code can post open-external without a gesture, so opens are limited.
24+
const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000;
25+
2126
export interface FreeformCanvasProps {
2227
/** The single-file React source to render. */
2328
code: string;
@@ -67,6 +72,7 @@ export function FreeformCanvas({
6772
// only gates an imperative postMessage and is never shown on screen, so it
6873
// shouldn't trigger re-renders.
6974
const readyRef = useRef(false);
75+
const lastExternalOpenRef = useRef(0);
7076

7177
// The document is keyed on mode + the analytics host (which the CSP must open
7278
// for posthog-js), not on code: code is injected via `init`, so changing it
@@ -173,6 +179,28 @@ export function FreeformCanvas({
173179
// msg.nav is already allowlist-validated by safeParse below.
174180
latest.current.onNavigate?.(msg.nav);
175181
break;
182+
case "open-external":
183+
// Re-checks the schema's allowlist refine in case it ever drifts.
184+
if (!isSafePostHogUrl(msg.url)) {
185+
log.warn("Blocked non-PostHog canvas external URL", {
186+
url: msg.url,
187+
});
188+
} else if (document.activeElement !== iframeRef.current) {
189+
// A real link click moves focus into the iframe; requiring focus
190+
// stops code from auto-opening URLs on load (e.g. thumbnails).
191+
log.warn("Ignored canvas external URL open without interaction", {
192+
url: msg.url,
193+
});
194+
} else if (
195+
Date.now() - lastExternalOpenRef.current <
196+
EXTERNAL_OPEN_MIN_INTERVAL_MS
197+
) {
198+
log.warn("Throttled canvas external URL open", { url: msg.url });
199+
} else {
200+
lastExternalOpenRef.current = Date.now();
201+
openExternalUrl(msg.url);
202+
}
203+
break;
176204
}
177205
};
178206

@@ -228,8 +256,8 @@ export function FreeformCanvas({
228256
ref={iframeRef}
229257
title="Canvas"
230258
// allow-scripts WITHOUT allow-same-origin = null origin = no access to host
231-
// cookies/storage/DOM. Do not add allow-same-origin (it collapses the
232-
// isolation boundary).
259+
// cookies/storage/DOM. External navigation is brokered over postMessage;
260+
// do not add allow-popups or allow-same-origin.
233261
sandbox="allow-scripts"
234262
srcDoc={srcDoc}
235263
// Race-free init: by `load`, the iframe's module bootstrap has executed

packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
buildSandboxDocument,
44
decodeJsxUnicodeEscapes,
5+
resolveExternalAnchorUrl,
56
} from "./sandboxRuntime";
67

78
describe("decodeJsxUnicodeEscapes", () => {
@@ -55,4 +56,77 @@ describe("buildSandboxDocument", () => {
5556
);
5657
expect(html).toContain("jsxUnicodeEscapesPlugin");
5758
});
59+
60+
it("inlines the external-anchor resolver into the bootstrap", () => {
61+
const html = buildSandboxDocument("edit");
62+
expect(html).toContain(
63+
"const resolveExternalAnchorUrl = function resolveExternalAnchorUrl(",
64+
);
65+
expect(html).toContain('"open-external"');
66+
expect(html).toContain("event.defaultPrevented");
67+
});
68+
});
69+
70+
describe("resolveExternalAnchorUrl", () => {
71+
const clickTarget = (html: string, selector: string): Element => {
72+
const container = document.createElement("div");
73+
container.innerHTML = html;
74+
const el = container.querySelector(selector);
75+
if (!el) throw new Error(`selector ${selector} not found`);
76+
return el;
77+
};
78+
79+
it("resolves a click inside a target=_blank anchor to its absolute URL", () => {
80+
const target = clickTarget(
81+
'<a href="https://posthog.com/docs" target="_blank"><span>docs</span></a>',
82+
"span",
83+
);
84+
expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/docs");
85+
});
86+
87+
it("matches the _blank keyword case-insensitively", () => {
88+
const target = clickTarget(
89+
'<a href="https://posthog.com" target="_Blank">x</a>',
90+
"a",
91+
);
92+
expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/");
93+
});
94+
95+
it("resolves SVG anchors via the href attribute", () => {
96+
const target = clickTarget(
97+
'<svg><a href="https://posthog.com" target="_blank"><text>x</text></a></svg>',
98+
"text",
99+
);
100+
expect(resolveExternalAnchorUrl(target)).toBe("https://posthog.com/");
101+
});
102+
103+
it.each([
104+
{
105+
name: "anchors without target=_blank",
106+
html: '<a href="https://posthog.com">x</a>',
107+
selector: "a",
108+
},
109+
{
110+
name: "relative hrefs (would resolve against the host base URL)",
111+
html: '<a href="/settings" target="_blank">x</a>',
112+
selector: "a",
113+
},
114+
{
115+
name: "empty hrefs",
116+
html: '<a href="" target="_blank">x</a>',
117+
selector: "a",
118+
},
119+
{
120+
name: "clicks outside any anchor",
121+
html: "<button>x</button>",
122+
selector: "button",
123+
},
124+
])("returns null for $name", ({ html, selector }) => {
125+
expect(resolveExternalAnchorUrl(clickTarget(html, selector))).toBeNull();
126+
});
127+
128+
it("returns null for non-Element targets", () => {
129+
expect(resolveExternalAnchorUrl(null)).toBeNull();
130+
expect(resolveExternalAnchorUrl(document.createTextNode("x"))).toBeNull();
131+
});
58132
});

0 commit comments

Comments
 (0)