Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/sanitize-agent-view-urls.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 53 additions & 4 deletions apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ? (
<div key={i} className="text-xs text-text-dimmed">
{label}
</div>
) : null;
}
return (
<div key={i} className="text-xs">
<a
href={p.url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
>
{p.title || p.url}
{label}
</a>
</div>
);
Expand All @@ -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 ? (
<div key={i} className="text-xs text-text-dimmed">
{p.filename}
</div>
) : null;
}
return (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<img
key={i}
src={p.url}
src={safeSrc}
alt={p.filename ?? "file"}
className="max-h-64 rounded border border-charcoal-650"
/>
);
}
const safeUrl = toSafeUrl(p.url);
// Unsafe scheme: show the filename without a clickable download link.
if (!safeUrl) {
return p.filename ? (
<div key={i} className="text-xs text-text-dimmed">
{p.filename}
</div>
) : null;
}
return (
<div key={i} className="text-xs">
<a
href={p.url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
Expand Down
33 changes: 33 additions & 0 deletions apps/webapp/test/components/runs/v3/agent/AgentMessageView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";

describe("toSafeUrl", () => {
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,<script>alert(1)</script>", 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();
});
});