From d7a1c268d9b75cb5aea9ad9686d7ac52a3053aa4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:50:11 -0600 Subject: [PATCH] fix(app): sanitize markdown -> html --- bun.lock | 6 +++++ package.json | 1 + packages/ui/package.json | 1 + packages/ui/src/components/markdown.tsx | 34 +++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index c8ced856bd..787264e4fa 100644 --- a/bun.lock +++ b/bun.lock @@ -407,6 +407,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "@typescript/native-preview": "catalog:", + "dompurify": "catalog:", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", @@ -507,6 +508,7 @@ "@typescript/native-preview": "7.0.0-dev.20251207.1", "ai": "5.0.119", "diff": "8.0.2", + "dompurify": "3.3.1", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1828,6 +1830,8 @@ "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="], "@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="], @@ -2280,6 +2284,8 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], diff --git a/package.json b/package.json index 031296b26b..d134a187a7 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", + "dompurify": "3.3.1", "ai": "5.0.119", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/ui/package.json b/packages/ui/package.json index bc37a826e8..5b440f515d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -51,6 +51,7 @@ "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", + "dompurify": "catalog:", "marked": "catalog:", "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 2b0b018744..3aefe04da3 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,6 +1,8 @@ import { useMarked } from "../context/marked" +import DOMPurify from "dompurify" import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createResource, splitProps } from "solid-js" +import { isServer } from "solid-js/web" type Entry = { hash: string @@ -10,6 +12,31 @@ type Entry = { const max = 200 const cache = new Map() +if (typeof window !== "undefined" && DOMPurify.isSupported) { + DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { + if (!(node instanceof HTMLAnchorElement)) return + if (node.target !== "_blank") return + + const rel = node.getAttribute("rel") ?? "" + const set = new Set(rel.split(/\s+/).filter(Boolean)) + set.add("noopener") + set.add("noreferrer") + node.setAttribute("rel", Array.from(set).join(" ")) + }) +} + +const config = { + USE_PROFILES: { html: true, mathMl: true }, + SANITIZE_NAMED_PROPS: true, + FORBID_TAGS: ["style"], + FORBID_CONTENTS: ["style", "script"], +} + +function sanitize(html: string) { + if (!DOMPurify.isSupported) return "" + return DOMPurify.sanitize(html, config) +} + function touch(key: string, value: Entry) { cache.delete(key) cache.set(key, value) @@ -34,6 +61,8 @@ export function Markdown( const [html] = createResource( () => local.text, async (markdown) => { + if (isServer) return "" + const hash = checksum(markdown) const key = local.cacheKey ?? hash @@ -46,8 +75,9 @@ export function Markdown( } const next = await marked.parse(markdown) - if (key && hash) touch(key, { hash, html: next }) - return next + const safe = sanitize(next) + if (key && hash) touch(key, { hash, html: safe }) + return safe }, { initialValue: "" }, )