mirror of
https://github.com/usememos/memos.git
synced 2025-12-11 06:36:02 +08:00
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:
parent
d9f8bc80f0
commit
4668c4714b
8 changed files with 109 additions and 100 deletions
|
|
@ -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
41
web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
25
web/src/components/MemoContent/utils.ts
Normal file
25
web/src/components/MemoContent/utils.ts
Normal 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] : "";
|
||||
};
|
||||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue