summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock6
-rw-r--r--package.json1
-rw-r--r--packages/ui/package.json1
-rw-r--r--packages/ui/src/components/markdown.tsx34
4 files changed, 40 insertions, 2 deletions
diff --git a/bun.lock b/bun.lock
index c8ced856b..787264e4f 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/[email protected]", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
+ "@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@types/tsscmp": ["@types/[email protected]", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="],
"@types/tunnel": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
@@ -2280,6 +2284,8 @@
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
+ "dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
+
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-case": ["[email protected]", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
diff --git a/package.json b/package.json
index 031296b26..d134a187a 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 bc37a826e..5b440f515 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 2b0b01874..3aefe04da 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<string, Entry>()
+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: "" },
)