diff --git a/web/package.json b/web/package.json index 0b2f6d5f8..b72f4911a 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,6 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@github/relative-time-element": "^4.5.0", - "@matejmazur/react-katex": "^3.1.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -33,11 +32,9 @@ "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.19", - "dompurify": "^3.3.0", "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", "i18next": "^25.6.3", - "katex": "^0.16.25", "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "lucide-react": "^0.544.0", @@ -57,6 +54,7 @@ "react-use": "^17.6.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0bb2872f2..d6418dcfd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@github/relative-time-element': specifier: ^4.5.0 version: 4.5.0 - '@matejmazur/react-katex': - specifier: ^3.1.3 - version: 3.1.3(katex@0.16.25)(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -77,9 +74,6 @@ importers: dayjs: specifier: ^1.11.19 version: 1.11.19 - dompurify: - specifier: ^3.3.0 - version: 3.3.0 fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -89,9 +83,6 @@ importers: i18next: specifier: ^25.6.3 version: 25.6.3(typescript@5.9.3) - katex: - specifier: ^0.16.25 - version: 0.16.25 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -149,6 +140,9 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -682,13 +676,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@matejmazur/react-katex@3.1.3': - resolution: {integrity: sha512-rBp7mJ9An7ktNoU653BWOYdO4FoR4YNwofHZi+vaytX/nWbIlmHVIF+X8VFOn6c3WYmrLT5FFBjKqCZ1sjR5uQ==} - engines: {node: '>=12', yarn: '>=1.1'} - peerDependencies: - katex: '>=0.9' - react: '>=16' - '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} @@ -1988,6 +1975,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -2647,6 +2637,9 @@ packages: rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} @@ -3417,11 +3410,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@matejmazur/react-katex@3.1.3(katex@0.16.25)(react@18.3.1)': - dependencies: - katex: 0.16.25 - react: 18.3.1 - '@mermaid-js/parser@0.6.3': dependencies: langium: 3.3.1 @@ -4743,6 +4731,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -5666,6 +5660,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-breaks@4.0.0: dependencies: '@types/mdast': 4.0.4 diff --git a/web/src/components/MemoContent/CodeBlock.tsx b/web/src/components/MemoContent/CodeBlock.tsx index b6a6dcce2..cafa682a0 100644 --- a/web/src/components/MemoContent/CodeBlock.tsx +++ b/web/src/components/MemoContent/CodeBlock.tsx @@ -1,4 +1,3 @@ -import DOMPurify from "dompurify"; import hljs from "highlight.js"; import { CheckIcon, CopyIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; @@ -7,23 +6,20 @@ import { cn } from "@/lib/utils"; import { userStore } from "@/store"; import { getThemeWithFallback, resolveTheme } from "@/utils/theme"; import { MermaidBlock } from "./MermaidBlock"; +import { extractCodeContent, extractLanguage } from "./utils"; -interface PreProps { +interface CodeBlockProps { children?: React.ReactNode; className?: string; } -export const CodeBlock = observer(({ children, className, ...props }: PreProps) => { +export const CodeBlock = observer(({ children, className, ...props }: CodeBlockProps) => { const [copied, setCopied] = useState(false); - // Extract the code element and its props const codeElement = children as React.ReactElement; const codeClassName = codeElement?.props?.className || ""; - const codeContent = String(codeElement?.props?.children || "").replace(/\n$/, ""); - - // Extract language from className (format: language-xxx) - const match = /language-(\w+)/.exec(codeClassName); - const language = match ? match[1] : ""; + const codeContent = extractCodeContent(children); + const language = extractLanguage(codeClassName); // If it's a mermaid block, render with MermaidBlock component if (language === "mermaid") { @@ -34,66 +30,6 @@ export const CodeBlock = observer(({ children, className, ...props }: PreProps) ); } - // If it's __html special language, render sanitized HTML - if (language === "__html") { - const sanitizedHTML = DOMPurify.sanitize(codeContent, { - ALLOWED_TAGS: [ - "div", - "span", - "p", - "br", - "strong", - "b", - "em", - "i", - "u", - "s", - "strike", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "blockquote", - "code", - "pre", - "ul", - "ol", - "li", - "dl", - "dt", - "dd", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - "a", - "img", - "figure", - "figcaption", - "hr", - "small", - "sup", - "sub", - ], - ALLOWED_ATTR: "href title alt src width height class id style target rel colspan rowspan".split(" "), - FORBID_ATTR: "onerror onload onclick onmouseover onfocus onblur onchange".split(" "), - FORBID_TAGS: "script iframe object embed form input button".split(" "), - }); - - return ( -
- ); - } - const theme = getThemeWithFallback(userStore.state.userGeneralSetting?.theme); const resolvedTheme = resolveTheme(theme); const isDarkTheme = resolvedTheme.includes("dark"); diff --git a/web/src/components/MemoContent/MermaidBlock.tsx b/web/src/components/MemoContent/MermaidBlock.tsx index 951ba08ad..83946dd13 100644 --- a/web/src/components/MemoContent/MermaidBlock.tsx +++ b/web/src/components/MemoContent/MermaidBlock.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { userStore } from "@/store"; import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme"; +import { extractCodeContent } from "./utils"; interface MermaidBlockProps { children?: React.ReactNode; @@ -20,9 +21,7 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps const [error, setError] = useState(""); const [systemThemeChange, setSystemThemeChange] = useState(0); - // Extract Mermaid code content from children - const codeElement = children as React.ReactElement; - const codeContent = String(codeElement?.props?.children || "").replace(/\n$/, ""); + const codeContent = extractCodeContent(children); // Get theme preference (reactive via MobX observer) // Falls back to localStorage or system preference if no user setting diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 7b52eab2c..3902bfbc3 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -1,6 +1,59 @@ +import { defaultSchema } from "rehype-sanitize"; + export const MAX_DISPLAY_HEIGHT = 256; export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = { ALL: { textKey: "memo.show-more", next: "SNIPPET" }, SNIPPET: { textKey: "memo.show-less", next: "ALL" }, }; + +/** + * Sanitization schema for markdown HTML content. + * Extends the default schema to allow: + * - KaTeX math rendering elements (MathML tags) + * - KaTeX-specific attributes (className, style, aria-*, data-*) + * - Safe HTML elements for rich content + * + * This prevents XSS attacks while preserving math rendering functionality. + */ +export const SANITIZE_SCHEMA = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + div: [...(defaultSchema.attributes?.div || []), "className"], + span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]], + // MathML attributes for KaTeX rendering + annotation: ["encoding"], + math: ["xmlns"], + mi: [], + mn: [], + mo: [], + mrow: [], + mspace: [], + mstyle: [], + msup: [], + msub: [], + msubsup: [], + mfrac: [], + mtext: [], + semantics: [], + }, + tagNames: [ + ...(defaultSchema.tagNames || []), + // MathML elements for KaTeX math rendering + "math", + "annotation", + "semantics", + "mi", + "mn", + "mo", + "mrow", + "mspace", + "mstyle", + "msup", + "msub", + "msubsup", + "mfrac", + "mtext", + ], +}; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 03c7b9003..601da30c0 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; @@ -15,12 +16,12 @@ import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { isSuperUser } from "@/utils/user"; import { CodeBlock } from "./CodeBlock"; import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; +import { SANITIZE_SCHEMA } from "./constants"; import { useCompactLabel, useCompactMode } from "./hooks"; import { MemoContentContext } from "./MemoContentContext"; import { Tag } from "./Tag"; import { TaskListItem } from "./TaskListItem"; import type { MemoContentProps } from "./types"; -import "katex/dist/katex.min.css"; const MemoContent = observer((props: MemoContentProps) => { const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props; @@ -34,7 +35,6 @@ const MemoContent = observer((props: MemoContentProps) => { const memo = memoName ? memoStore.getMemoByName(memoName) : null; const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser)); - // Context for custom components const contextValue = { memoName, readonly: !allowEdit, @@ -60,7 +60,7 @@ const MemoContent = observer((props: MemoContentProps) => { > { + const codeElement = children as React.ReactElement; + return String(codeElement?.props?.children || "").replace(/\n$/, ""); +}; + +/** + * Extracts the language identifier from a code block's className. + * react-markdown uses the format "language-xxx" for code blocks. + * + * @param className - The className string from a code element + * @returns The language identifier, or empty string if none found + */ +export const extractLanguage = (className: string): string => { + const match = /language-(\w+)/.exec(className); + return match ? match[1] : ""; +}; diff --git a/web/vite.config.mts b/web/vite.config.mts index b515b3cef..5b63cb382 100644 --- a/web/vite.config.mts +++ b/web/vite.config.mts @@ -40,7 +40,6 @@ export default defineConfig({ output: { manualChunks: { "utils-vendor": ["dayjs", "lodash-es"], - "katex-vendor": ["katex"], "mermaid-vendor": ["mermaid"], "leaflet-vendor": ["leaflet", "react-leaflet"], },