From 6035020eedb1a5bc52e0bf3d00f10c088c3bcf12 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 9 Jun 2026 22:51:06 +0100 Subject: [PATCH 1/2] fix(webapp): sanitize streamed agent URLs before rendering in the agent view URLs in source-url and file message parts come from streamed agent/tool data, so an unsafe scheme like javascript: rendered straight into an href/src was a clickable XSS payload. Allow only http(s)/blob (and data:image for inline images); unsafe values render as plain text instead of a link or image. --- .server-changes/sanitize-agent-view-urls.md | 6 +++ .../runs/v3/agent/AgentMessageView.tsx | 50 +++++++++++++++++-- .../runs/v3/agent/AgentMessageView.test.ts | 33 ++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 .server-changes/sanitize-agent-view-urls.md create mode 100644 apps/webapp/test/components/runs/v3/agent/AgentMessageView.test.ts diff --git a/.server-changes/sanitize-agent-view-urls.md b/.server-changes/sanitize-agent-view-urls.md new file mode 100644 index 00000000000..c534a03623d --- /dev/null +++ b/.server-changes/sanitize-agent-view-urls.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Sanitize URLs from streamed agent and tool data before rendering them in the dashboard's Agent view, so an unsafe scheme such as `javascript:` can no longer produce a clickable link or image source. diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 6d3365752a6..4a72897c895 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -77,6 +77,27 @@ export const MessageBubble = memo(function MessageBubble({ return null; }); +// URLs in `source-url`/`file` parts come from streamed agent/tool data, so an +// unsafe scheme like `javascript:` would become a clickable XSS payload once it +// reaches an href/src. Allow only http(s)/blob (and data: for inline images), +// and return null for anything else so the caller can skip the link/image. +export function toSafeUrl(value: unknown, allowDataImage = false): string | null { + if (typeof value !== "string") return null; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return null; + } + if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:") { + return value; + } + if (allowDataImage && parsed.protocol === "data:" && /^data:image\//i.test(value)) { + return value; + } + return null; +} + export function renderPart(part: UIMessage["parts"][number], i: number) { const p = part as any; const type = part.type as string; @@ -159,15 +180,25 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { // Source URL — clickable citation link if (type === "source-url") { + const safeUrl = toSafeUrl(p.url); + const label = p.title || p.url; + // Unsafe scheme: render the citation text without a clickable link. + if (!safeUrl) { + return label ? ( +
+ {label} +
+ ) : null; + } return (
- {p.title || p.url} + {label}
); @@ -187,19 +218,30 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { if (type === "file") { const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/"); if (isImage) { + const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images + if (!safeSrc) return null; return ( {p.filename ); } + const safeUrl = toSafeUrl(p.url); + // Unsafe scheme: show the filename without a clickable download link. + if (!safeUrl) { + return p.filename ? ( +
+ {p.filename} +
+ ) : null; + } return (
{ + it("allows http(s) and blob URLs", () => { + expect(toSafeUrl("https://example.com/x")).toBe("https://example.com/x"); + expect(toSafeUrl("http://example.com/x")).toBe("http://example.com/x"); + expect(toSafeUrl("blob:https://example.com/uuid")).toBe("blob:https://example.com/uuid"); + }); + + it("rejects javascript: and other dangerous schemes", () => { + expect(toSafeUrl("javascript:alert(1)")).toBeNull(); + expect(toSafeUrl("JavaScript:alert(1)")).toBeNull(); + expect(toSafeUrl("vbscript:msgbox(1)")).toBeNull(); + expect(toSafeUrl("file:///etc/passwd")).toBeNull(); + }); + + it("rejects data: URLs unless inline images are explicitly allowed", () => { + const dataImage = "data:image/png;base64,iVBORw0KGgo="; + expect(toSafeUrl(dataImage)).toBeNull(); + expect(toSafeUrl(dataImage, true)).toBe(dataImage); + // Only image data is allowed, even in image context — never data:text/html. + expect(toSafeUrl("data:text/html,", true)).toBeNull(); + }); + + it("rejects relative URLs and non-string/malformed input", () => { + expect(toSafeUrl("/relative/path")).toBeNull(); + expect(toSafeUrl("not a url")).toBeNull(); + expect(toSafeUrl(undefined)).toBeNull(); + expect(toSafeUrl(null)).toBeNull(); + expect(toSafeUrl(42)).toBeNull(); + }); +}); From cff297f6e476296b2bb7ddfee84473ed6c556668 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 9 Jun 2026 23:02:24 +0100 Subject: [PATCH 2/2] fix(webapp): show filename fallback for unsafe image URLs in the agent view --- .../app/components/runs/v3/agent/AgentMessageView.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 4a72897c895..fbd7faf2298 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -219,7 +219,14 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/"); if (isImage) { const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images - if (!safeSrc) return null; + // Unsafe scheme: fall back to the filename, matching the non-image branch. + if (!safeSrc) { + return p.filename ? ( +
+ {p.filename} +
+ ) : null; + } return (