Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit e81d068

Browse files
authored
fix(mobile): don't auto-fetch remote images in artifact previews
Generated artifacts are untrusted content. A markdown artifact containing `![img](http://127.0.0.1/...)` previously caused MarkdownImage to fetch the URL via Image.getSize on mount, letting an attacker make the device issue requests to arbitrary Internet or local-network services just by having the user open the preview. Add a `disableRemoteImages` option to MarkdownText/MarkdownImage and set it for artifact previews. When enabled, remote (http/https) image URLs are not fetched automatically; they render as a tap-to-open placeholder so the request only happens on an explicit user action. Chat message rendering is unchanged. Generated-By: PostHog Code Task-Id: fbd4c004-e4af-482d-9eb1-d946a1990e44
1 parent a377920 commit e81d068

4 files changed

Lines changed: 123 additions & 6 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { createElement } from "react";
2+
import { Image } from "react-native";
3+
import { act, create } from "react-test-renderer";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { openExternalUrl } from "@/lib/openExternalUrl";
6+
import { MarkdownImage } from "./MarkdownImage";
7+
8+
vi.mock("@/lib/openExternalUrl", () => ({ openExternalUrl: vi.fn() }));
9+
10+
vi.mock("@/lib/theme", () => ({
11+
useThemeColors: () => ({ gray: { 9: "#777", 11: "#555" } }),
12+
}));
13+
14+
vi.mock("phosphor-react-native", () => ({
15+
ArrowSquareOut: (props: Record<string, unknown>) =>
16+
createElement("ArrowSquareOut", props),
17+
ImageBroken: (props: Record<string, unknown>) =>
18+
createElement("ImageBroken", props),
19+
}));
20+
21+
function render(props: {
22+
url: string;
23+
alt?: string;
24+
disableRemoteImages?: boolean;
25+
}) {
26+
let renderer: ReturnType<typeof create> | null = null;
27+
act(() => {
28+
renderer = create(createElement(MarkdownImage, props));
29+
});
30+
if (!renderer) throw new Error("Renderer not created");
31+
return renderer as ReturnType<typeof create>;
32+
}
33+
34+
describe("MarkdownImage", () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
});
38+
39+
it("fetches image size for remote images by default", () => {
40+
const getSize = vi.spyOn(Image, "getSize").mockImplementation(() => {});
41+
render({ url: "http://127.0.0.1/secret", alt: "x" });
42+
expect(getSize).toHaveBeenCalledWith(
43+
"http://127.0.0.1/secret",
44+
expect.any(Function),
45+
expect.any(Function),
46+
);
47+
});
48+
49+
it("does not fetch remote images when disableRemoteImages is set", () => {
50+
const getSize = vi.spyOn(Image, "getSize").mockImplementation(() => {});
51+
const tree = JSON.stringify(
52+
render({
53+
url: "http://127.0.0.1/secret",
54+
alt: "sneaky",
55+
disableRemoteImages: true,
56+
}).toJSON(),
57+
);
58+
expect(getSize).not.toHaveBeenCalled();
59+
// Renders a tap-to-open placeholder that shows the alt text.
60+
expect(tree).toContain("sneaky");
61+
expect(openExternalUrl).not.toHaveBeenCalled();
62+
});
63+
64+
it("still fetches non-remote images even when disableRemoteImages is set", () => {
65+
const getSize = vi.spyOn(Image, "getSize").mockImplementation(() => {});
66+
render({
67+
url: "data:image/png;base64,AAAA",
68+
disableRemoteImages: true,
69+
});
70+
expect(getSize).toHaveBeenCalled();
71+
});
72+
});

apps/mobile/src/features/chat/components/MarkdownImage.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ImageBroken } from "phosphor-react-native";
1+
import { ArrowSquareOut, ImageBroken } from "phosphor-react-native";
22
import { useEffect, useState } from "react";
33
import { ActivityIndicator, Image, Pressable, Text, View } from "react-native";
44
import { openExternalUrl } from "@/lib/openExternalUrl";
@@ -7,6 +7,12 @@ import { useThemeColors } from "@/lib/theme";
77
interface MarkdownImageProps {
88
url: string;
99
alt?: string;
10+
// When true, remote (http/https) images are not fetched automatically.
11+
// Untrusted content (e.g. a generated artifact) could otherwise make the
12+
// device issue requests to arbitrary Internet or local-network URLs just by
13+
// being previewed. Instead we render a placeholder that only opens the image
14+
// externally on an explicit user tap.
15+
disableRemoteImages?: boolean;
1016
}
1117

1218
type LoadState =
@@ -16,11 +22,21 @@ type LoadState =
1622

1723
const MAX_HEIGHT = 320;
1824

19-
export function MarkdownImage({ url, alt }: MarkdownImageProps) {
25+
function isRemoteUrl(url: string): boolean {
26+
return url.startsWith("http://") || url.startsWith("https://");
27+
}
28+
29+
export function MarkdownImage({
30+
url,
31+
alt,
32+
disableRemoteImages,
33+
}: MarkdownImageProps) {
2034
const themeColors = useThemeColors();
35+
const deferred = Boolean(disableRemoteImages) && isRemoteUrl(url);
2136
const [state, setState] = useState<LoadState>({ status: "loading" });
2237

2338
useEffect(() => {
39+
if (deferred) return;
2440
let cancelled = false;
2541
setState({ status: "loading" });
2642
Image.getSize(
@@ -38,7 +54,23 @@ export function MarkdownImage({ url, alt }: MarkdownImageProps) {
3854
return () => {
3955
cancelled = true;
4056
};
41-
}, [url]);
57+
}, [url, deferred]);
58+
59+
if (deferred) {
60+
return (
61+
<Pressable
62+
onPress={() => openExternalUrl(url)}
63+
accessibilityRole="button"
64+
accessibilityLabel={alt ? `Open image: ${alt}` : "Open image"}
65+
className="flex-row items-center gap-2 rounded-md border border-gray-6 bg-gray-2 px-3 py-2 active:opacity-70"
66+
>
67+
<ArrowSquareOut size={16} color={themeColors.gray[9]} />
68+
<Text className="flex-1 text-[12px] text-gray-11" numberOfLines={1}>
69+
{alt || "Tap to open image"}
70+
</Text>
71+
</Pressable>
72+
);
73+
}
4274

4375
if (state.status === "error") {
4476
return (

apps/mobile/src/features/chat/components/MarkdownText.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ const BARE_POSTHOG_REF_PATTERN =
1818

1919
interface MarkdownTextProps {
2020
content: string;
21+
// When true, remote images embedded in the markdown are not fetched
22+
// automatically. Set this for untrusted content (e.g. generated artifact
23+
// previews) so opening the preview can't make the device issue requests to
24+
// arbitrary URLs; images render as a tap-to-open placeholder instead.
25+
disableRemoteImages?: boolean;
2126
}
2227

2328
function HighlightedCode({
@@ -419,7 +424,10 @@ function renderInline(
419424
return nodes.length > 0 ? nodes : [text];
420425
}
421426

422-
export function MarkdownText({ content }: MarkdownTextProps) {
427+
export function MarkdownText({
428+
content,
429+
disableRemoteImages,
430+
}: MarkdownTextProps) {
423431
const blocks = parseBlocks(content);
424432
const cloudRegion = useAuthStore((state) => state.cloudRegion);
425433
const posthogUrlOptions = useMemo<ParsePostHogUrlOptions>(
@@ -612,7 +620,12 @@ export function MarkdownText({ content }: MarkdownTextProps) {
612620

613621
case "image":
614622
return block.url ? (
615-
<MarkdownImage key={key} url={block.url} alt={block.alt} />
623+
<MarkdownImage
624+
key={key}
625+
url={block.url}
626+
alt={block.alt}
627+
disableRemoteImages={disableRemoteImages}
628+
/>
616629
) : null;
617630

618631
case "hr":

apps/mobile/src/features/tasks/components/ArtifactPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function ArtifactPreview({
119119
className="flex-1"
120120
contentContainerStyle={{ padding: 16 }}
121121
>
122-
<MarkdownText content={text ?? ""} />
122+
<MarkdownText content={text ?? ""} disableRemoteImages />
123123
</ScrollView>
124124
) : (
125125
<WebView

0 commit comments

Comments
 (0)