From db01b9634b4bf05a0dcf181cafd3a20cdde37de0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 21 Apr 2026 09:12:18 -0400 Subject: [PATCH] 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. --- apps/fabro-web/app/routes/run-stages.test.ts | 17 ++++++++++ apps/fabro-web/app/routes/run-stages.tsx | 35 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 apps/fabro-web/app/routes/run-stages.test.ts diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts new file mode 100644 index 000000000..d10b5b2d2 --- /dev/null +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -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); + }); +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 9b205d1df..5ceb146cf 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -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 (