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..e1f7638e7b 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"; @@ -14,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"; @@ -32,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; @@ -108,6 +112,10 @@ export function McpAppHost(props: McpAppHostProps) { ); const handleOpenLink = useCallback(async (args: { url: string }) => { + 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/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..d4db07b5a0 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -0,0 +1,56 @@ +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.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); + } + }); +}); 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(() => {}); +}