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 =
+ " " +
+ " " +
+ "[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}>
+ ),
+ a: ({ node: _n, href, ...props }) =>
+ isSameOrigin(href) ? (
+
+ ) : (
+
+ ),
}}
>
{content}