Skip to content

Open external links from inside canvases in the browser#3662

Open
raquelmsmith wants to merge 2 commits into
mainfrom
posthog-code/canvas-external-links
Open

Open external links from inside canvases in the browser#3662
raquelmsmith wants to merge 2 commits into
mainfrom
posthog-code/canvas-external-links

Conversation

@raquelmsmith

Copy link
Copy Markdown
Member

Problem

Freeform canvases render agent-authored React apps inside a null-origin sandboxed iframe. When a canvas linked out — e.g. an insight card linking to that insight on PostHog.com — clicking did nothing. Two independent causes:

  1. No external-navigation primitive existed. The only navigation the iframe could request (ph.navigate.{toTask,toNewTask,toCanvas,toNewCanvas}) was all in-app; the host allowlist had no case for opening a URL.
  2. The sandbox swallows raw anchors. A raw <a target="_blank"> is suppressed by Chromium's sandbox (no allow-popups/allow-top-navigation) before it ever reaches the host's external-link handler.

The agent prompt also never documented ph.navigate at all, so the agent reached for raw anchors that can't work.

Why: so a canvas can legitimately link to PostHog.com (and elsewhere) and have it open in the user's browser.

Changes

  • Add an external target (carrying a URL) to the canvas nav allowlist, and a ph.navigate.toExternal(url) shim, routed through the app's existing hardened external-open path (openUrlInBrowseros.openExternalshell.openExternal).
  • Security: the URL scheme is validated with the shared isSafeExternalUrl at the postMessage boundary and re-validated at every downstream layer, so only http(s)/mailto open — javascript:/file:/custom schemes are dropped. A min-interval guard in the host handler stops a buggy/malicious canvas from spamming the OS browser with tabs (a navigate frame can't carry a trusted user-gesture bit).
  • Document the whole ph.navigate surface in the agent prompt, emphasising that outbound links must use toExternal (a raw anchor is silently blocked).

How did you test this?

  • pnpm --filter @posthog/core typecheck and pnpm --filter @posthog/ui typecheck — clean.
  • New freeformSchemas.test.ts (allowlist accepts http(s)/mailto + legacy targets, rejects javascript:/file:/custom/non-URL/missing-url) and an added sandboxRuntime.test.ts case asserting the toExternal shim is emitted — all pass.
  • biome lint on the changed files — clean.
  • Not yet exercised in the running Electron app; would benefit from a manual click-through of a canvas link opening in the browser.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

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
`<a target="_blank">` 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
@trunk-io

trunk-io Bot commented Jul 22, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 9c5fecd.

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
@raquelmsmith
raquelmsmith marked this pull request as ready for review July 22, 2026 02:50
if (now - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS)
break;
lastExternalOpenRef.current = now;
void openUrlInBrowser(intent.url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: External navigation does not require user interaction

A malicious canvas can call ph.navigate.toExternal() during rendering and open an arbitrary web or mail link without a click, repeating the action every 750 ms. The scheme allowlist prevents custom-protocol execution, but the timer only slows unsolicited navigation; require a host-controlled confirmation or another trusted user action before invoking openUrlInBrowser.

@veria-ai

veria-ai Bot commented Jul 22, 2026

Copy link
Copy Markdown

PR overview

This pull request adds support for opening external links from within canvas content in the user's browser. The touched canvas view code wires canvas-triggered external navigation through the browser-opening path.

There is one open security issue: a malicious canvas can trigger external web or mail navigation during rendering without a trusted click or confirmation, and can repeat it on a timer. The scheme allowlist reduces the risk of arbitrary protocol execution, but the remaining impact is unsolicited browser or mail-client navigation that could be abused for phishing or disruption. No issues have been fixed yet, so the PR still needs a user-interaction or host-confirmation gate before this behavior is safe.

Open issues (1)

Fixed/addressed: 0 · PR risk: 5/10

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts:53
**Async Open Failure Escapes Fallback**

When `os.openExternal.mutate()` rejects asynchronously, `openUrlInBrowser` does not catch that rejection because its internal `openExternalUrl(url)` call is not awaited. This fire-and-forget call then leaves an unhandled rejection and never reaches the `window.open` fallback, so external canvas links silently fail when the host procedure is unavailable or errors.

### Issue 2 of 3
packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts:53
**Web Fallback Loses User Gesture**

On a web host without the external-open host procedure, this call reaches `window.open` only after a `postMessage` event rather than during the original click. Browser popup blocking can therefore reject every documented `toExternal` request, leaving canvas links unresponsive for web users.

### Issue 3 of 3
packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts:53
**Stale Frame Opens Old Link**

When a warm iframe slot is reassigned, its `contentWindow` and message listener remain active while the navigation handler is replaced. A delayed `toExternal` message from the previous canvas still passes the source check and reaches this new side-effecting branch, opening a URL after the user has moved to another canvas.

Reviews (1): Last reviewed commit: "Apply Biome formatting to canvas nav pro..." | Re-trigger Greptile

if (now - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS)
break;
lastExternalOpenRef.current = now;
void openUrlInBrowser(intent.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Async Open Failure Escapes Fallback

When os.openExternal.mutate() rejects asynchronously, openUrlInBrowser does not catch that rejection because its internal openExternalUrl(url) call is not awaited. This fire-and-forget call then leaves an unhandled rejection and never reaches the window.open fallback, so external canvas links silently fail when the host procedure is unavailable or errors.

Rule Used: Always wrap asynchronous calls that may fail, such... (source)

Learned From
PostHog/posthog#32098

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts
Line: 53

Comment:
**Async Open Failure Escapes Fallback**

When `os.openExternal.mutate()` rejects asynchronously, `openUrlInBrowser` does not catch that rejection because its internal `openExternalUrl(url)` call is not awaited. This fire-and-forget call then leaves an unhandled rejection and never reaches the `window.open` fallback, so external canvas links silently fail when the host procedure is unavailable or errors.

**Rule Used:** Always wrap asynchronous calls that may fail, such... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=df81f021-48c3-45e4-981f-7348133f5f7a))

**Learned From**
[PostHog/posthog#32098](https://github.com/PostHog/posthog/pull/32098)

How can I resolve this? If you propose a fix, please make it concise.

if (now - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS)
break;
lastExternalOpenRef.current = now;
void openUrlInBrowser(intent.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Web Fallback Loses User Gesture

On a web host without the external-open host procedure, this call reaches window.open only after a postMessage event rather than during the original click. Browser popup blocking can therefore reject every documented toExternal request, leaving canvas links unresponsive for web users.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts
Line: 53

Comment:
**Web Fallback Loses User Gesture**

On a web host without the external-open host procedure, this call reaches `window.open` only after a `postMessage` event rather than during the original click. Browser popup blocking can therefore reject every documented `toExternal` request, leaving canvas links unresponsive for web users.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

if (now - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS)
break;
lastExternalOpenRef.current = now;
void openUrlInBrowser(intent.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale Frame Opens Old Link

When a warm iframe slot is reassigned, its contentWindow and message listener remain active while the navigation handler is replaced. A delayed toExternal message from the previous canvas still passes the source check and reaches this new side-effecting branch, opening a URL after the user has moved to another canvas.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts
Line: 53

Comment:
**Stale Frame Opens Old Link**

When a warm iframe slot is reassigned, its `contentWindow` and message listener remain active while the navigation handler is replaced. A delayed `toExternal` message from the previous canvas still passes the source check and reaches this new side-effecting branch, opening a URL after the user has moved to another canvas.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant