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..fbd7faf2298 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,37 @@ 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 + // Unsafe scheme: fall back to the filename, matching the non-image branch. + if (!safeSrc) { + return p.filename ? ( +
+ {p.filename} +
+ ) : 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(); + }); +});