fix: prevent IP leaks via external chat images (#27362)

This commit is contained in:
Thomas Kosiewski
2026-08-11 11:50:56 +02:00
committed by GitHub
parent c97f4da3ac
commit 496ed757ed
5 changed files with 244 additions and 1 deletions
@@ -0,0 +1,59 @@
import { ImageIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "#/utils/cn";
import {
externalImageHost,
isExternalImageSource,
} from "#/utils/externalImageSources";
/**
* Renders chat markdown images. External sources render a
* click-to-load placeholder so viewing a chat never discloses the
* viewer's IP to the image host (Cure53 CDM-02-006).
*/
export const MarkdownImage = ({ src, alt }: { src?: string; alt?: string }) => {
const [consented, setConsented] = useState(false);
if (!src) {
return null;
}
if (consented || !isExternalImageSource(src)) {
return (
<img src={src} alt={alt ?? ""} loading="lazy" className="max-w-full" />
);
}
const host = externalImageHost(src);
// Sources without a resolvable host (for example javascript: or
// otherwise malformed URLs) are never safe to load, so they get a
// placeholder without a load affordance.
if (!host) {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-solid border-border-default bg-surface-secondary px-2 py-1 text-xs text-content-secondary">
<ImageIcon aria-hidden className="size-3.5 shrink-0" />
Blocked image{alt ? `: ${alt}` : ""}
</span>
);
}
return (
<button
type="button"
onClick={() => setConsented(true)}
aria-label={`Load external image from ${host}`}
className={cn(
"inline-flex max-w-full cursor-pointer items-center gap-1.5",
"rounded-md border border-solid border-border-default bg-surface-secondary",
"px-2 py-1 text-xs text-content-secondary",
"hover:bg-surface-tertiary hover:text-content-primary",
)}
>
<ImageIcon aria-hidden className="size-3.5 shrink-0" />
<span className="truncate">
{alt ? `${alt}: ` : ""}external image from {host}
</span>
<span className="shrink-0 font-medium text-content-link">Load</span>
</button>
);
};
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, waitFor, within } from "storybook/test";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { Response } from "./Response";
const sampleMarkdown = `
@@ -191,6 +191,90 @@ export const JsxInProse: Story = {
},
};
// A 1x1 transparent PNG. Streamdown's sanitize plugin strips data:
// image sources before our img component sees them, so these render
// as nothing: inert, and never a network request.
const dataImagePNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
const externalImageURL = "https://external-image-host.invalid/image.png";
// Verifies the IP-leak fix for Cure53 CDM-02-006: externally hosted
// markdown images must not be fetched when a chat is rendered. The
// viewer gets a consent placeholder and the <img> element only
// appears after clicking it.
export const ExternalImageConsentGate: Story = {
args: {
children: `Before\n\n![diagram](${externalImageURL})\n\nAfter`,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The placeholder must render instead of the image.
const loadButton = await canvas.findByRole("button", {
name: /load external image from external-image-host\.invalid/i,
});
expect(loadButton).toBeInTheDocument();
// No <img> in the document may point at the external host.
expect(canvasElement.querySelector("img")).toBeNull();
// Clicking the placeholder opts in and renders the image.
await userEvent.click(loadButton);
await waitFor(() => {
const img = canvasElement.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBe(externalImageURL);
});
},
};
// data: image sources are stripped by the sanitize plugin, so they
// render as nothing: no <img>, no consent gate, no request.
export const DataImageStrippedBySanitizer: Story = {
args: {
children: `Before\n\n![inline](${dataImagePNG})\n\nAfter`,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("After");
expect(canvasElement.querySelector("img")).toBeNull();
expect(canvas.queryByRole("button")).toBeNull();
},
};
// Deployment-relative images (for example emoji or uploaded icons)
// are same-origin, so they render immediately without a consent gate.
export const RelativeImageRendersImmediately: Story = {
args: {
children: "![emoji](/emojis/1f4bb.png)",
},
play: async ({ canvasElement }) => {
await waitFor(() => {
const img = canvasElement.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBe("/emojis/1f4bb.png");
});
expect(within(canvasElement).queryByRole("button")).toBeNull();
},
};
// The consent gate must also apply while streaming.
export const StreamingExternalImageConsentGate: Story = {
args: {
children: `![diagram](${externalImageURL})`,
streaming: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const loadButton = await canvas.findByRole("button", {
name: /load external image/i,
});
expect(loadButton).toBeInTheDocument();
expect(canvasElement.querySelector("img")).toBeNull();
},
};
// Verifies that streaming mode closes incomplete inline markdown via
// remend so the user never sees raw syntax during the reveal animation.
export const StreamingInlineMarkdown: Story = {
@@ -12,6 +12,7 @@ import {
} from "streamdown";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { cn } from "#/utils/cn";
import { MarkdownImage } from "./MarkdownImage";
interface ResponseProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
children: string;
@@ -44,6 +45,8 @@ type HastNode = {
type MarkdownComponentProps = {
href?: string;
src?: string;
alt?: string;
children?: ReactNode;
node?: HastNode;
type?: string;
@@ -184,6 +187,12 @@ const createComponents = (
);
},
// Gate externally hosted images behind viewer consent so
// rendering a chat never discloses the viewer's IP address
// to an attacker-controlled host (Cure53 CDM-02-006).
img: ({ src, alt }: MarkdownComponentProps) => (
<MarkdownImage src={src} alt={alt} />
),
// Horizontal rule: reset browser default inset/ridge border
// (preflight is disabled) to a clean 1px solid line.
hr: () => (
@@ -0,0 +1,42 @@
import {
externalImageHost,
isExternalImageSource,
} from "./externalImageSources";
describe("isExternalImageSource", () => {
// jsdom serves tests from http://localhost/.
it.each([
["", false],
[" ", false],
["/emojis/1f4bb.png", false],
["relative/path.png", false],
["./relative.png", false],
["data:image/png;base64,iVBORw0KGgo=", false],
["blob:http://localhost/1234-5678", false],
[`${location.origin}/icon/aws.svg`, false],
["https://attacker.example.com/img.png", true],
["http://attacker.example.com/img.png", true],
["HTTPS://ATTACKER.EXAMPLE.COM/img.png", true],
[" https://attacker.example.com/img.png ", true],
["//attacker.example.com/img.png", true],
["/\\attacker.example.com/img.png", true],
["\\\\attacker.example.com\\img.png", true],
["javascript:alert(1)", true],
["file:///etc/passwd", true],
["ftp://attacker.example.com/img.png", true],
])("isExternalImageSource(%j) === %j", (src, expected) => {
expect(isExternalImageSource(src)).toBe(expected);
});
});
describe("externalImageHost", () => {
it("returns the hostname for absolute URLs", () => {
expect(externalImageHost("https://cdn.example.com/a.png")).toBe(
"cdn.example.com",
);
});
it("returns undefined for unparsable sources", () => {
expect(externalImageHost("https://[")).toBeUndefined();
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Classifies image sources rendered from untrusted content (for
* example LLM-generated chat markdown). Fetching an external source
* discloses the viewer's IP address to that host (Cure53 CDM-02-006),
* so callers must not render one without explicit viewer consent.
*/
/**
* Returns true when loading `src` in an <img> would issue a request
* to a host other than the current deployment. Unparsable sources are
* treated as external so the failure mode is "blocked", never
* "leaked".
*/
export const isExternalImageSource = (src: string): boolean => {
// Browsers treat backslashes in http(s) URLs as slashes, so
// "/\evil.com" navigates to "//evil.com". Treat any backslash as
// external rather than trying to mirror WHATWG parsing quirks.
if (src.includes("\\")) {
return true;
}
let parsed: URL;
try {
parsed = new URL(src, location.origin);
} catch {
return true;
}
switch (parsed.protocol) {
case "data:":
case "blob:":
return false;
case "http:":
case "https:":
return parsed.origin !== location.origin;
default:
// javascript:, file:, ftp:, and anything else is never a
// safe image source.
return true;
}
};
/** Hostname shown in the consent placeholder, if determinable. */
export const externalImageHost = (src: string): string | undefined => {
try {
const host = new URL(src, location.origin).hostname;
return host === "" ? undefined : host;
} catch {
return undefined;
}
};