Skip to content

Commit b28c6d0

Browse files
authored
fix(webapp): sanitize streamed agent URLs before rendering in the agent view (#3882)
## Summary The dashboard's Agent view rendered `source-url` and `file` message parts by putting their `url` straight into an `href`/`src`. Those URLs come from streamed agent and tool data, so a tool that emitted something like `javascript:alert(1)` produced a clickable XSS payload in the dashboard. ## Fix A `toSafeUrl` helper now gates every URL before it reaches an `href`/`src`: it allows only `http:`/`https:`/`blob:` (and `data:image/...` for inline images) and returns `null` for anything else. Unsafe values render as plain text instead of a link or image, so a hostile or malformed URL degrades gracefully rather than becoming clickable. Safe URLs render exactly as before. Covered by a unit test over the allow/deny list.
1 parent bc01f6e commit b28c6d0

3 files changed

Lines changed: 92 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.

apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ export const MessageBubble = memo(function MessageBubble({
7777
return null;
7878
});
7979

80+
// URLs in `source-url`/`file` parts come from streamed agent/tool data, so an
81+
// unsafe scheme like `javascript:` would become a clickable XSS payload once it
82+
// reaches an href/src. Allow only http(s)/blob (and data: for inline images),
83+
// and return null for anything else so the caller can skip the link/image.
84+
export function toSafeUrl(value: unknown, allowDataImage = false): string | null {
85+
if (typeof value !== "string") return null;
86+
let parsed: URL;
87+
try {
88+
parsed = new URL(value);
89+
} catch {
90+
return null;
91+
}
92+
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:") {
93+
return value;
94+
}
95+
if (allowDataImage && parsed.protocol === "data:" && /^data:image\//i.test(value)) {
96+
return value;
97+
}
98+
return null;
99+
}
100+
80101
export function renderPart(part: UIMessage["parts"][number], i: number) {
81102
const p = part as any;
82103
const type = part.type as string;
@@ -159,15 +180,25 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
159180

160181
// Source URL — clickable citation link
161182
if (type === "source-url") {
183+
const safeUrl = toSafeUrl(p.url);
184+
const label = p.title || p.url;
185+
// Unsafe scheme: render the citation text without a clickable link.
186+
if (!safeUrl) {
187+
return label ? (
188+
<div key={i} className="text-xs text-text-dimmed">
189+
{label}
190+
</div>
191+
) : null;
192+
}
162193
return (
163194
<div key={i} className="text-xs">
164195
<a
165-
href={p.url}
196+
href={safeUrl}
166197
target="_blank"
167198
rel="noopener noreferrer"
168199
className="text-indigo-400 underline hover:text-indigo-300"
169200
>
170-
{p.title || p.url}
201+
{label}
171202
</a>
172203
</div>
173204
);
@@ -187,19 +218,37 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
187218
if (type === "file") {
188219
const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
189220
if (isImage) {
221+
const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images
222+
// Unsafe scheme: fall back to the filename, matching the non-image branch.
223+
if (!safeSrc) {
224+
return p.filename ? (
225+
<div key={i} className="text-xs text-text-dimmed">
226+
{p.filename}
227+
</div>
228+
) : null;
229+
}
190230
return (
191231
<img
192232
key={i}
193-
src={p.url}
233+
src={safeSrc}
194234
alt={p.filename ?? "file"}
195235
className="max-h-64 rounded border border-charcoal-650"
196236
/>
197237
);
198238
}
239+
const safeUrl = toSafeUrl(p.url);
240+
// Unsafe scheme: show the filename without a clickable download link.
241+
if (!safeUrl) {
242+
return p.filename ? (
243+
<div key={i} className="text-xs text-text-dimmed">
244+
{p.filename}
245+
</div>
246+
) : null;
247+
}
199248
return (
200249
<div key={i} className="text-xs">
201250
<a
202-
href={p.url}
251+
href={safeUrl}
203252
target="_blank"
204253
rel="noopener noreferrer"
205254
className="text-indigo-400 underline hover:text-indigo-300"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
3+
4+
describe("toSafeUrl", () => {
5+
it("allows http(s) and blob URLs", () => {
6+
expect(toSafeUrl("https://example.com/x")).toBe("https://example.com/x");
7+
expect(toSafeUrl("http://example.com/x")).toBe("http://example.com/x");
8+
expect(toSafeUrl("blob:https://example.com/uuid")).toBe("blob:https://example.com/uuid");
9+
});
10+
11+
it("rejects javascript: and other dangerous schemes", () => {
12+
expect(toSafeUrl("javascript:alert(1)")).toBeNull();
13+
expect(toSafeUrl("JavaScript:alert(1)")).toBeNull();
14+
expect(toSafeUrl("vbscript:msgbox(1)")).toBeNull();
15+
expect(toSafeUrl("file:///etc/passwd")).toBeNull();
16+
});
17+
18+
it("rejects data: URLs unless inline images are explicitly allowed", () => {
19+
const dataImage = "data:image/png;base64,iVBORw0KGgo=";
20+
expect(toSafeUrl(dataImage)).toBeNull();
21+
expect(toSafeUrl(dataImage, true)).toBe(dataImage);
22+
// Only image data is allowed, even in image context — never data:text/html.
23+
expect(toSafeUrl("data:text/html,<script>alert(1)</script>", true)).toBeNull();
24+
});
25+
26+
it("rejects relative URLs and non-string/malformed input", () => {
27+
expect(toSafeUrl("/relative/path")).toBeNull();
28+
expect(toSafeUrl("not a url")).toBeNull();
29+
expect(toSafeUrl(undefined)).toBeNull();
30+
expect(toSafeUrl(null)).toBeNull();
31+
expect(toSafeUrl(42)).toBeNull();
32+
});
33+
});

0 commit comments

Comments
 (0)