mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: prevent IP leaks via external chat images (#27362)
This commit is contained in:
@@ -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\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\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: "",
|
||||
},
|
||||
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: ``,
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user