From 6e0bf11aaaa23087cc600efe7f7be61a224ecf64 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 5 Jun 2026 11:47:17 +0100 Subject: [PATCH 1/2] fix(mobile): validate external URL schemes before opening (port #2494) 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 --- .../src/app/mcp-servers/template/[id].tsx | 4 +- .../chat/components/GithubRefChip.tsx | 5 +- .../chat/components/MarkdownImage.tsx | 12 ++--- .../features/chat/components/MarkdownText.tsx | 5 +- .../chat/components/PostHogRefChip.tsx | 5 +- .../features/inbox/components/SignalCard.tsx | 5 +- .../inbox/components/SuggestedReviewers.tsx | 4 +- .../features/mcp/components/McpAppHost.tsx | 2 + .../tasks/components/PrStatusBadge.tsx | 5 +- apps/mobile/src/lib/openExternalUrl.test.ts | 54 +++++++++++++++++++ apps/mobile/src/lib/openExternalUrl.ts | 13 +++++ 11 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/src/lib/openExternalUrl.test.ts create mode 100644 apps/mobile/src/lib/openExternalUrl.ts diff --git a/apps/mobile/src/app/mcp-servers/template/[id].tsx b/apps/mobile/src/app/mcp-servers/template/[id].tsx index 607a88ce60..1bc61b2e92 100644 --- a/apps/mobile/src/app/mcp-servers/template/[id].tsx +++ b/apps/mobile/src/app/mcp-servers/template/[id].tsx @@ -4,7 +4,6 @@ import { Lock, Warning } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { ActivityIndicator, - Linking, Pressable, ScrollView, TextInput, @@ -21,6 +20,7 @@ import { installTemplateWithOAuth } from "@/features/mcp/oauth"; import { isStdioServer } from "@/features/mcp/types"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import { useThemeColors } from "@/lib/theme"; const log = logger.scope("mcp-template-detail"); @@ -168,7 +168,7 @@ export default function McpTemplateDetailScreen() { {template.docs_url ? ( Linking.openURL(template.docs_url as string)} + onPress={() => openExternalUrl(template.docs_url as string)} className="mb-4 rounded-lg border border-gray-5 bg-card px-3 py-2 active:bg-gray-2" > diff --git a/apps/mobile/src/features/chat/components/GithubRefChip.tsx b/apps/mobile/src/features/chat/components/GithubRefChip.tsx index e7b0a6fb5e..a65f35ad5a 100644 --- a/apps/mobile/src/features/chat/components/GithubRefChip.tsx +++ b/apps/mobile/src/features/chat/components/GithubRefChip.tsx @@ -1,4 +1,5 @@ -import { Linking, Text } from "react-native"; +import { Text } from "react-native"; +import { openExternalUrl } from "@/lib/openExternalUrl"; interface GithubRefChipProps { href: string; @@ -13,7 +14,7 @@ interface GithubRefChipProps { export function GithubRefChip({ href, kind, label }: GithubRefChipProps) { return ( Linking.openURL(href)} + onPress={() => openExternalUrl(href)} className="rounded-md bg-gray-3 px-1.5 py-0.5 font-mono text-[11px] text-accent-11" accessibilityRole="link" accessibilityLabel={`GitHub ${kind === "pr" ? "pull request" : "issue"} ${label}`} diff --git a/apps/mobile/src/features/chat/components/MarkdownImage.tsx b/apps/mobile/src/features/chat/components/MarkdownImage.tsx index 328035c1ca..f7c38661bb 100644 --- a/apps/mobile/src/features/chat/components/MarkdownImage.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownImage.tsx @@ -1,13 +1,7 @@ import { ImageBroken } from "phosphor-react-native"; import { useEffect, useState } from "react"; -import { - ActivityIndicator, - Image, - Linking, - Pressable, - Text, - View, -} from "react-native"; +import { ActivityIndicator, Image, Pressable, Text, View } from "react-native"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import { useThemeColors } from "@/lib/theme"; interface MarkdownImageProps { @@ -67,7 +61,7 @@ export function MarkdownImage({ url, alt }: MarkdownImageProps) { return ( Linking.openURL(url)} + onPress={() => openExternalUrl(url)} accessibilityRole="image" accessibilityLabel={alt || "Image"} className="active:opacity-80" diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index b125a54cd7..ff2e8ace83 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { Linking, ScrollView, Text, View } from "react-native"; +import { ScrollView, Text, View } from "react-native"; import { getCloudUrlFromRegion, useAuthStore } from "@/features/auth"; import { UNIVERSAL_LINK_PREFIX } from "@/lib/deep-links"; import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import { type ParsePostHogUrlOptions, parsePostHogUrl } from "@/lib/posthogUrl"; import { getColorForClass, highlightCode } from "@/lib/syntax-highlight"; import { useThemeColors } from "@/lib/theme"; @@ -223,7 +224,7 @@ function parseBlocks(text: string): Block[] { } function openUrl(url: string) { - Linking.openURL(url); + openExternalUrl(url); } function splitTrailingPunctuation(text: string): { diff --git a/apps/mobile/src/features/chat/components/PostHogRefChip.tsx b/apps/mobile/src/features/chat/components/PostHogRefChip.tsx index 7e53608815..9da4a236aa 100644 --- a/apps/mobile/src/features/chat/components/PostHogRefChip.tsx +++ b/apps/mobile/src/features/chat/components/PostHogRefChip.tsx @@ -1,4 +1,5 @@ -import { Linking, Text } from "react-native"; +import { Text } from "react-native"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import type { PostHogRefKind } from "@/lib/posthogUrl"; interface PostHogRefChipProps { @@ -19,7 +20,7 @@ export function PostHogRefChip({ href, kind, label }: PostHogRefChipProps) { return ( Linking.openURL(href)} + onPress={() => openExternalUrl(href)} className="rounded-md bg-gray-3 px-1.5 py-0.5 font-mono text-[11px] text-accent-11" accessibilityRole="link" accessibilityLabel={`PostHog ${destination} link ${label}`} diff --git a/apps/mobile/src/features/inbox/components/SignalCard.tsx b/apps/mobile/src/features/inbox/components/SignalCard.tsx index ba8521a566..680b88d685 100644 --- a/apps/mobile/src/features/inbox/components/SignalCard.tsx +++ b/apps/mobile/src/features/inbox/components/SignalCard.tsx @@ -14,9 +14,10 @@ import { WarningCircle, } from "phosphor-react-native"; import { useState } from "react"; -import { Linking, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { formatRelativeTime } from "@/lib/format"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import { useThemeColors } from "@/lib/theme"; import type { Signal, SignalFindingContent } from "../types"; @@ -248,7 +249,7 @@ export function SignalCard({ signal, finding }: SignalCardProps) { {externalUrl && ( Linking.openURL(externalUrl)} + onPress={() => openExternalUrl(externalUrl)} hitSlop={6} className="flex-row items-center gap-1 active:opacity-60" > diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx index 4e8de6893c..5821ab2953 100644 --- a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react"; import { ActivityIndicator, Image, - Linking, Pressable, ScrollView, View, @@ -13,6 +12,7 @@ import type { InboxReportActionProperties, InboxReportActionType, } from "@/lib/analytics"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import { useThemeColors } from "@/lib/theme"; import { useUpdateSuggestedReviewers } from "../hooks/useInboxReports"; import type { @@ -159,7 +159,7 @@ export function SuggestedReviewers({ fireAction("click_suggested_reviewer", { suggested_reviewer_login: reviewer.github_login, }); - Linking.openURL( + openExternalUrl( `https://github.com/${reviewer.github_login}`, ); }} diff --git a/apps/mobile/src/features/mcp/components/McpAppHost.tsx b/apps/mobile/src/features/mcp/components/McpAppHost.tsx index 9dd7d02622..c5062f0c7c 100644 --- a/apps/mobile/src/features/mcp/components/McpAppHost.tsx +++ b/apps/mobile/src/features/mcp/components/McpAppHost.tsx @@ -1,6 +1,7 @@ import { Text } from "@components/text"; import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { isSafeExternalUrl } from "@posthog/shared"; import * as WebBrowser from "expo-web-browser"; import { ArrowsIn, ArrowsOut, Warning } from "phosphor-react-native"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -108,6 +109,7 @@ export function McpAppHost(props: McpAppHostProps) { ); const handleOpenLink = useCallback(async (args: { url: string }) => { + if (!isSafeExternalUrl(args.url)) return; await WebBrowser.openBrowserAsync(args.url); }, []); diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx index ead34a0028..f58f6376bb 100644 --- a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx @@ -1,5 +1,6 @@ import { GitMerge, GitPullRequest } from "phosphor-react-native"; -import { Linking, Pressable } from "react-native"; +import { Pressable } from "react-native"; +import { openExternalUrl } from "@/lib/openExternalUrl"; import { toRgba, useThemeColors } from "@/lib/theme"; import { usePrStatus } from "../hooks/usePrStatus"; @@ -17,7 +18,7 @@ export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) { const { data: status } = usePrStatus(prUrl); const handlePress = () => { - Linking.openURL(prUrl).catch(() => {}); + openExternalUrl(prUrl); }; let color: string = themeColors.gray[11]; diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts new file mode 100644 index 0000000000..d0bd55bb86 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -0,0 +1,54 @@ +import { isSafeExternalUrl } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openURL = vi.fn((_url: string) => Promise.resolve(true)); + +vi.mock("react-native", () => ({ + Linking: { openURL: (url: string) => openURL(url) }, +})); + +import { openExternalUrl } from "./openExternalUrl"; + +describe("isSafeExternalUrl in the mobile bundle", () => { + it.each([ + "https://github.com/PostHog/code/pull/42", + "http://example.com", + "https://example.com/path?q=1#frag", + "HTTPS://EXAMPLE.COM", + "mailto:hi@posthog.com", + ])("allows %s", (url) => { + expect(isSafeExternalUrl(url)).toBe(true); + }); + + it.each([ + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,", + "smb://server/share", + "ms-msdt:/id", + "vscode://extension", + "//evil.com", + "/relative/path", + "not a url", + "", + " ", + ])("blocks %s", (url) => { + expect(isSafeExternalUrl(url)).toBe(false); + }); +}); + +describe("openExternalUrl", () => { + beforeEach(() => { + openURL.mockClear(); + }); + + 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(); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts new file mode 100644 index 0000000000..deb6c3a739 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -0,0 +1,13 @@ +import { isSafeExternalUrl } from "@posthog/shared"; +import { Linking } from "react-native"; +import { logger } from "@/lib/logger"; + +const log = logger.scope("openExternalUrl"); + +export function openExternalUrl(url: string): void { + if (!isSafeExternalUrl(url)) { + log.warn("Blocked external URL with unsafe scheme", url); + return; + } + void Linking.openURL(url).catch(() => {}); +} From c94732ac0217e4450ba69403f40990ced2308825 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 5 Jun 2026 12:10:43 +0100 Subject: [PATCH 2/2] fix(mobile): log blocked WebView URLs and parameterise opener tests 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 --- .../src/features/mcp/components/McpAppHost.tsx | 8 +++++++- apps/mobile/src/lib/openExternalUrl.test.ts | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/mcp/components/McpAppHost.tsx b/apps/mobile/src/features/mcp/components/McpAppHost.tsx index c5062f0c7c..e1f7638e7b 100644 --- a/apps/mobile/src/features/mcp/components/McpAppHost.tsx +++ b/apps/mobile/src/features/mcp/components/McpAppHost.tsx @@ -15,6 +15,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import WebView, { type WebViewMessageEvent } from "react-native-webview"; +import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import { useMcpInstallations } from "../hooks"; import { sandboxProxyHtml } from "../sandbox/sandboxProxyHtml"; @@ -33,6 +34,8 @@ interface McpAppHostProps { status: "pending" | "running" | "completed" | "error"; } +const log = logger.scope("McpAppHost"); + const INLINE_MIN_HEIGHT = 180; const INLINE_MAX_HEIGHT = 520; @@ -109,7 +112,10 @@ export function McpAppHost(props: McpAppHostProps) { ); const handleOpenLink = useCallback(async (args: { url: string }) => { - if (!isSafeExternalUrl(args.url)) return; + if (!isSafeExternalUrl(args.url)) { + log.warn("Blocked external URL with unsafe scheme", args.url); + return; + } await WebBrowser.openBrowserAsync(args.url); }, []); diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts index d0bd55bb86..d4db07b5a0 100644 --- a/apps/mobile/src/lib/openExternalUrl.test.ts +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -42,13 +42,15 @@ describe("openExternalUrl", () => { openURL.mockClear(); }); - 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(); + it.each([ + ["https://example.com", "https://example.com"], + ["javascript:alert(1)", null], + ])("opens %s only when safe", (url, expectedCall) => { + openExternalUrl(url); + if (expectedCall === null) { + expect(openURL).not.toHaveBeenCalled(); + } else { + expect(openURL).toHaveBeenCalledWith(expectedCall); + } }); });