refactor(web): improve MemoContent security and maintainability

Security improvements:
- Add rehype-sanitize for XSS protection in markdown content
- Remove DOMPurify and deprecated __html code block feature
- Extract sanitize schema to constants with comprehensive documentation

Maintainability improvements:
- Extract SANITIZE_SCHEMA to constants.ts for better organization
- Create utils.ts with shared code extraction utilities
- Refactor CodeBlock and MermaidBlock to use shared utilities
- Rename PreProps to CodeBlockProps for clarity
- Reduce code duplication across components

Dependency cleanup:
- Remove explicit katex dependency (now transitive via rehype-katex)
- Remove @matejmazur/react-katex (unused)
- Remove dompurify (replaced by rehype-sanitize)
- Update vite config to remove katex-vendor chunk

Changes: 7 files changed, 84 insertions(+), 100 deletions(-)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steven 2025-12-02 22:45:22 +08:00
parent d9f8bc80f0
commit 4668c4714b
8 changed files with 109 additions and 100 deletions

View file

@ -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",

41
web/pnpm-lock.yaml generated
View file

@ -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

View file

@ -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 (
<div
className="w-full overflow-auto my-2!"
dangerouslySetInnerHTML={{
__html: sanitizedHTML,
}}
/>
);
}
const theme = getThemeWithFallback(userStore.state.userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark");

View file

@ -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<string>("");
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

View file

@ -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",
],
};

View file

@ -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) => {
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath, remarkTag, remarkPreserveType]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
rehypePlugins={[rehypeRaw, rehypeKatex, [rehypeSanitize, SANITIZE_SCHEMA]]}
components={{
// Conditionally render custom components based on AST node type
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),

View file

@ -0,0 +1,25 @@
import type React from "react";
/**
* Extracts code content from a react-markdown code element.
* Handles the nested structure where code is passed as children.
*
* @param children - The children prop from react-markdown (typically a code element)
* @returns The extracted code content as a string with trailing newline removed
*/
export const extractCodeContent = (children: React.ReactNode): string => {
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] : "";
};

View file

@ -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"],
},