Skip to content

Commit 6035020

Browse files
committed
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.
1 parent 3bc88c4 commit 6035020

3 files changed

Lines changed: 85 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: 46 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,30 @@ 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+
if (!safeSrc) return null;
190223
return (
191224
<img
192225
key={i}
193-
src={p.url}
226+
src={safeSrc}
194227
alt={p.filename ?? "file"}
195228
className="max-h-64 rounded border border-charcoal-650"
196229
/>
197230
);
198231
}
232+
const safeUrl = toSafeUrl(p.url);
233+
// Unsafe scheme: show the filename without a clickable download link.
234+
if (!safeUrl) {
235+
return p.filename ? (
236+
<div key={i} className="text-xs text-text-dimmed">
237+
{p.filename}
238+
</div>
239+
) : null;
240+
}
199241
return (
200242
<div key={i} className="text-xs">
201243
<a
202-
href={p.url}
244+
href={safeUrl}
203245
target="_blank"
204246
rel="noopener noreferrer"
205247
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)