Explorar o código

fix(app): sanitize markdown -> html

Adam hai 3 meses
pai
achega
d7a1c268d9
Modificáronse 4 ficheiros con 40 adicións e 2 borrados
  1. 6 0
      bun.lock
  2. 1 0
      package.json
  3. 1 0
      packages/ui/package.json
  4. 32 2
      packages/ui/src/components/markdown.tsx

+ 6 - 0
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=="],

+ 1 - 0
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",

+ 1 - 0
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:",

+ 32 - 2
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: "" },
   )