fix(mobile): validate external URL schemes before opening (port #2494)#2498
fix(mobile): validate external URL schemes before opening (port #2494)#2498Gilbert09 wants to merge 2 commits into
Conversation
Port the desktop hardening from #2494 to the mobile app: before handing a URL to `Linking.openURL` / `WebBrowser.openBrowserAsync`, validate its scheme against the shared allowlist (`http:`/`https:`/`mailto:`) so tampered or attacker-supplied URLs from markdown, chat, signal reports, or MCP app content can't trigger unsafe schemes (`file:`, `data:`, `javascript:`, custom deep-links, etc.). - Add `openExternalUrl(url)` helper in `apps/mobile/src/lib` that gates `Linking.openURL` behind `@posthog/shared`'s `isSafeExternalUrl` and keeps the existing silent no-op on failure. - Route the untrusted Linking call sites through it: MarkdownText, MarkdownImage, GithubRefChip, PostHogRefChip, SignalCard, SuggestedReviewers, PrStatusBadge, and the MCP template docs link. - Gate the MCP app bridge `WebBrowser.openBrowserAsync` behind `isSafeExternalUrl`. - Add unit tests covering the allow/deny matrix and that a rejected URL never reaches `Linking.openURL`. The test also exercises `isSafeExternalUrl` inside the mobile package to confirm it resolves and runs there. RN 0.81's `URL` extracts schemes via regex in its `protocol` getter, so no polyfill fallback is needed. Generated-By: PostHog Code Task-Id: 4fe18724-3034-4b84-8924-5b52a4b933fe
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/mobile/src/features/mcp/components/McpAppHost.tsx:111-114
**Silent drop on blocked WebView URL**
`handleOpenLink` returns early without logging when the URL fails `isSafeExternalUrl`, whereas `openExternalUrl` always emits a `log.warn`. The MCP WebView bridge is the highest-risk source of attacker-controlled URLs, so a blocked attempt here would go completely undetected in logs. Consider adding a `log.warn` before the early return to match the helper's behaviour.
### Issue 2 of 2
apps/mobile/src/lib/openExternalUrl.test.ts:43-54
**Non-parameterised tests in `openExternalUrl` block**
The `isSafeExternalUrl` tests above correctly use `it.each`, but the two `openExternalUrl` tests are written as standalone cases. Given the team's preference for parameterised tests, both cases could be expressed as a single `it.each` table with columns `[url, expectedCall]` — checking `toHaveBeenCalledWith(url)` for safe URLs and `not.toHaveBeenCalled()` for unsafe ones (using a sentinel like `null` to distinguish). With only two cases the gain is small, but it would also make it easier to add future URL examples.
Reviews (1): Last reviewed commit: "fix(mobile): validate external URL schem..." | Re-trigger Greptile |
| const handleOpenLink = useCallback(async (args: { url: string }) => { | ||
| if (!isSafeExternalUrl(args.url)) return; | ||
| await WebBrowser.openBrowserAsync(args.url); | ||
| }, []); |
There was a problem hiding this comment.
Silent drop on blocked WebView URL
handleOpenLink returns early without logging when the URL fails isSafeExternalUrl, whereas openExternalUrl always emits a log.warn. The MCP WebView bridge is the highest-risk source of attacker-controlled URLs, so a blocked attempt here would go completely undetected in logs. Consider adding a log.warn before the early return to match the helper's behaviour.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/features/mcp/components/McpAppHost.tsx
Line: 111-114
Comment:
**Silent drop on blocked WebView URL**
`handleOpenLink` returns early without logging when the URL fails `isSafeExternalUrl`, whereas `openExternalUrl` always emits a `log.warn`. The MCP WebView bridge is the highest-risk source of attacker-controlled URLs, so a blocked attempt here would go completely undetected in logs. Consider adding a `log.warn` before the early return to match the helper's behaviour.
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!
| }); | ||
|
|
||
| it("opens a safe URL", () => { | ||
| openExternalUrl("https://example.com"); | ||
| expect(openURL).toHaveBeenCalledWith("https://example.com"); | ||
| }); | ||
|
|
||
| it("does not open an unsafe URL", () => { | ||
| openExternalUrl("javascript:alert(1)"); | ||
| expect(openURL).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Non-parameterised tests in
openExternalUrl block
The isSafeExternalUrl tests above correctly use it.each, but the two openExternalUrl tests are written as standalone cases. Given the team's preference for parameterised tests, both cases could be expressed as a single it.each table with columns [url, expectedCall] — checking toHaveBeenCalledWith(url) for safe URLs and not.toHaveBeenCalled() for unsafe ones (using a sentinel like null to distinguish). With only two cases the gain is small, but it would also make it easier to add future URL examples.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/lib/openExternalUrl.test.ts
Line: 43-54
Comment:
**Non-parameterised tests in `openExternalUrl` block**
The `isSafeExternalUrl` tests above correctly use `it.each`, but the two `openExternalUrl` tests are written as standalone cases. Given the team's preference for parameterised tests, both cases could be expressed as a single `it.each` table with columns `[url, expectedCall]` — checking `toHaveBeenCalledWith(url)` for safe URLs and `not.toHaveBeenCalled()` for unsafe ones (using a sentinel like `null` to distinguish). With only two cases the gain is small, but it would also make it easier to add future URL examples.
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!
Address Greptile review feedback: - McpAppHost.handleOpenLink now logs a warning when it drops an unsafe URL, matching openExternalUrl's behaviour. The MCP WebView bridge is the highest-risk URL source, so blocked attempts shouldn't be silent. - Collapse the two openExternalUrl test cases into a single it.each table. Generated-By: PostHog Code Task-Id: 4fe18724-3034-4b84-8924-5b52a4b933fe
Summary
Ports the desktop hardening from #2494 to the mobile app. Before handing a URL to
Linking.openURL/WebBrowser.openBrowserAsync, the scheme is validated against the shared allowlist (http:/https:/mailto:via@posthog/shared'sisSafeExternalUrl), so a tampered or attacker-supplied URL — from a markdown link, chat content, signal report, or MCP app — can't trigger an unsafe scheme (file:,smb:,data:,javascript:,ms-msdt:, custom app deep-links, etc.). Unsafe URLs are silently dropped, preserving the existing no-op UX.Changes
apps/mobile/src/lib/openExternalUrl.ts— gatesLinking.openURLbehindisSafeExternalUrland keeps the.catch(() => {})no-op on failure.chat/MarkdownText.tsx(arbitrary markdown links)chat/MarkdownImage.tsx,chat/GithubRefChip.tsx,chat/PostHogRefChip.tsxinbox/SignalCard.tsx(externalUrl),inbox/SuggestedReviewers.tsxtasks/PrStatusBadge.tsxmcp-servers/template/[id].tsx(docs_url)mcp/McpAppHost.tsx— gatesWebBrowser.openBrowserAsyncbehindisSafeExternalUrl(the URL comes from the sandboxed WebView).OAuth flows (
openAuthSessionAsync) and the fixed-domain settings link open trusted PostHog/GitHub URLs and are intentionally left unchanged.RN
URLnoteThe task flagged that React Native's
URLpolyfill can parse differently. Confirmed against RN 0.81'sLibraries/Blob/URL.js: itsprotocolgetter extracts the scheme via/^([a-zA-Z][a-zA-Z\d+\-.]*):/, soisSafeExternalUrlclassifies schemes correctly on Hermes — no fallback scheme check is needed.Tests
apps/mobile/src/lib/openExternalUrl.test.ts:javascript:/file:/data:/smb:/custom-scheme and unparseable input rejected.Linking.openURL; a safe URL does.isSafeExternalUrlinside the mobile package to confirm it resolves and runs in this bundle.Full mobile suite: 215 passing. Lint clean; typecheck adds no new errors. No desktop or
packages/sharedcode was touched.