fix(web): sanitize markdown links in run stages

Reject unsafe markdown hrefs and strip raw HTML so stage output cannot
smuggle protocol-relative or scripted links into the run detail UI.
This commit is contained in:
Bryan Helmkamp 2026-04-21 09:12:18 -04:00
parent e92a2ba9db
commit db01b9634b
No known key found for this signature in database
2 changed files with 50 additions and 2 deletions

View file

@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { isSafeMarkdownHref } from "./run-stages";
describe("isSafeMarkdownHref", () => {
test("rejects protocol-relative URLs", () => {
expect(isSafeMarkdownHref("//attacker.example/pixel.png")).toBe(false);
});
test("accepts root-relative, hash, http, https, and mailto URLs", () => {
expect(isSafeMarkdownHref("/runs/run-1")).toBe(true);
expect(isSafeMarkdownHref("#section-1")).toBe(true);
expect(isSafeMarkdownHref("https://fabro.sh")).toBe(true);
expect(isSafeMarkdownHref("http://localhost:3000")).toBe(true);
expect(isSafeMarkdownHref("mailto:test@example.com")).toBe(true);
});
});

View file

@ -1,6 +1,37 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { marked } from "marked";
import { Marked } from "marked";
const SAFE_HTTP_URL_RE = /^https?:\/\//i;
const SAFE_MAILTO_URL_RE = /^mailto:/i;
export function isSafeMarkdownHref(href: string): boolean {
return (
SAFE_HTTP_URL_RE.test(href) ||
SAFE_MAILTO_URL_RE.test(href) ||
href.startsWith("#") ||
(href.startsWith("/") && !href.startsWith("//"))
);
}
const markedSafe = new Marked();
markedSafe.use({
async: false,
walkTokens(token) {
if (
(token.type === "link" || token.type === "image") &&
typeof token.href === "string" &&
!isSafeMarkdownHref(token.href)
) {
token.href = "";
}
},
renderer: {
html() {
return "";
},
},
});
import { CommandLineIcon, ChatBubbleLeftIcon, PlayIcon } from "@heroicons/react/24/outline";
import { ToolBlock } from "../components/tool-use";
import type { ToolUse } from "../components/tool-use";
@ -167,7 +198,7 @@ export async function loader({ request, params }: any) {
}
function Markdown({ content }: { content: string }) {
const html = useMemo(() => marked.parse(content, { async: false }) as string, [content]);
const html = useMemo(() => markedSafe.parse(content, { async: false }) as string, [content]);
return (
<div
className="prose prose-sm max-w-none text-fg-3 prose-headings:text-fg-2 prose-strong:text-fg-2 prose-code:rounded prose-code:bg-overlay-strong prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.8em] prose-code:font-mono prose-code:text-fg-3 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-overlay-strong prose-pre:text-fg-3 prose-a:text-teal-500"