Security hardening: prevent stored XSS and render crashes

- MarkdownView: add rehype-sanitize between rehype-raw and rehype-katex
  to strip scripts/event-handlers/javascript: URLs from user-authored
  markdown (was stored XSS, also affected the public /pg/* route);
  keep className on code/span/div so KaTeX and syntax highlighting
  still work. Add rehype-sanitize ^6.0.0 to deps and lockfile.
- MarkdownContent / StandaloneMarkdownPage: parse markdown content via
  parseMarkdownContent() instead of an unguarded JSON.parse, so a single
  corrupt/legacy record no longer white-screens the whole page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 16:12:56 +01:00
parent c9310250e4
commit 045c7c51d6
6 changed files with 63 additions and 3 deletions

30
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"redux": "^5.0.1", "redux": "^5.0.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"util": "^0.12.5" "util": "^0.12.5"
@@ -6263,6 +6264,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": { "node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz",
@@ -10868,6 +10884,20 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/relateurl": { "node_modules/relateurl": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",

View File

@@ -32,6 +32,7 @@
"redux": "^5.0.1", "redux": "^5.0.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"util": "^0.12.5" "util": "^0.12.5"

View File

@@ -10,6 +10,7 @@ import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries"; import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries"; import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal"; import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
import { parseMarkdownContent } from "../../utils/safe-json";
const MarkdownContent = () => { const MarkdownContent = () => {
const { strId } = useParams(); const { strId } = useParams();
@@ -68,7 +69,7 @@ const MarkdownContent = () => {
</div> </div>
</PermissionGuard> </PermissionGuard>
</div> </div>
<MarkdownView content={JSON.parse(markdown.content)} template={template}/> <MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
<MarkdownSettingModal <MarkdownSettingModal
isOpen={isSettingModalOpen} isOpen={isSettingModalOpen}
markdown={markdown} markdown={markdown}

View File

@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism"; import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
@@ -46,6 +47,21 @@ const ParseTemplate = ({template, variables}) => {
}; };
// Markdown content is authored by users and rendered for everyone
// (including the unauthenticated /pg/* route), so raw HTML must be
// sanitized to prevent stored XSS. className is kept on code/span/div so
// syntax highlighting and KaTeX (which runs after sanitize) still work;
// scripts, event handlers and javascript: URLs are stripped.
const sanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
code: [...(defaultSchema.attributes?.code || []), ["className"]],
span: [...(defaultSchema.attributes?.span || []), ["className"]],
div: [...(defaultSchema.attributes?.div || []), ["className"]],
},
};
const MarkdownView = ({ content, template, height="auto" }) => { const MarkdownView = ({ content, template, height="auto" }) => {
const {data: links, isLoading} = useLinks(); const {data: links, isLoading} = useLinks();
@@ -74,7 +90,7 @@ const MarkdownView = ({ content, template, height="auto" }) => {
variables: content variables: content
}) + "\n" + linkDefinitions} }) + "\n" + linkDefinitions}
remarkPlugins={[remarkMath, remarkGfm]} remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
components={{ components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || "");

View File

@@ -9,6 +9,7 @@ import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queri
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries"; import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
import { useTree } from "../../utils/queries/tree-queries"; import { useTree } from "../../utils/queries/tree-queries";
import { getMarkdownIdByPath } from "../../utils/pathUtils"; import { getMarkdownIdByPath } from "../../utils/pathUtils";
import { parseMarkdownContent } from "../../utils/safe-json";
const StandaloneMarkdownPage = () => { const StandaloneMarkdownPage = () => {
const location = useLocation(); const location = useLocation();
@@ -91,7 +92,7 @@ const StandaloneMarkdownPage = () => {
<h1 className="title">{markdown?.title === "index" ? indexTitle : markdown?.title}</h1> <h1 className="title">{markdown?.title === "index" ? indexTitle : markdown?.title}</h1>
</div> </div>
{markdown && ( {markdown && (
<MarkdownView content={JSON.parse(markdown.content)} template={template} /> <MarkdownView content={parseMarkdownContent(markdown.content)} template={template} />
)} )}
</div> </div>
); );

11
src/utils/safe-json.js Normal file
View File

@@ -0,0 +1,11 @@
// Markdown `content` is a JSON string. Records created via the API (or
// legacy/corrupt data) may not be valid JSON; an unguarded JSON.parse in a
// render path throws and white-screens the whole page (including the public
// /pg/* route). Parse defensively and degrade to a readable fallback.
export function parseMarkdownContent(raw) {
try {
return JSON.parse(raw);
} catch (e) {
return { markdown: "> ⚠️ This document could not be displayed: its stored content is not valid." };
}
}