Open external links from inside canvases in the browser#3662
Open external links from inside canvases in the browser#3662raquelmsmith wants to merge 2 commits into
Conversation
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
|
Merging to
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 |
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
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
| if (now - lastExternalOpenRef.current < EXTERNAL_OPEN_MIN_INTERVAL_MS) | ||
| break; | ||
| lastExternalOpenRef.current = now; | ||
| void openUrlInBrowser(intent.url); |
There was a problem hiding this comment.
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.
PR overviewThis 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 |
Prompt To Fix All With AIFix 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); |
There was a problem hiding this 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)
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); |
There was a problem hiding this 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.
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); |
There was a problem hiding this comment.
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.
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:
ph.navigate.{toTask,toNewTask,toCanvas,toNewCanvas}) was all in-app; the host allowlist had no case for opening a URL.<a target="_blank">is suppressed by Chromium's sandbox (noallow-popups/allow-top-navigation) before it ever reaches the host's external-link handler.The agent prompt also never documented
ph.navigateat 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
externaltarget (carrying a URL) to the canvas nav allowlist, and aph.navigate.toExternal(url)shim, routed through the app's existing hardened external-open path (openUrlInBrowser→os.openExternal→shell.openExternal).isSafeExternalUrlat the postMessage boundary and re-validated at every downstream layer, so onlyhttp(s)/mailtoopen —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 (anavigateframe can't carry a trusted user-gesture bit).ph.navigatesurface in the agent prompt, emphasising that outbound links must usetoExternal(a raw anchor is silently blocked).How did you test this?
pnpm --filter @posthog/core typecheckandpnpm --filter @posthog/ui typecheck— clean.freeformSchemas.test.ts(allowlist accepts http(s)/mailto + legacy targets, rejectsjavascript:/file:/custom/non-URL/missing-url) and an addedsandboxRuntime.test.tscase asserting thetoExternalshim is emitted — all pass.biome linton the changed files — clean.Automatic notifications
Created with PostHog Code