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:
@@ -10,6 +10,7 @@ import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
|
||||
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
|
||||
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
|
||||
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
|
||||
import { parseMarkdownContent } from "../../utils/safe-json";
|
||||
|
||||
const MarkdownContent = () => {
|
||||
const { strId } = useParams();
|
||||
@@ -68,7 +69,7 @@ const MarkdownContent = () => {
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
<MarkdownView content={JSON.parse(markdown.content)} template={template}/>
|
||||
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
|
||||
<MarkdownSettingModal
|
||||
isOpen={isSettingModalOpen}
|
||||
markdown={markdown}
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
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 {data: links, isLoading} = useLinks();
|
||||
|
||||
@@ -74,7 +90,7 @@ const MarkdownView = ({ content, template, height="auto" }) => {
|
||||
variables: content
|
||||
}) + "\n" + linkDefinitions}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queri
|
||||
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
|
||||
import { useTree } from "../../utils/queries/tree-queries";
|
||||
import { getMarkdownIdByPath } from "../../utils/pathUtils";
|
||||
import { parseMarkdownContent } from "../../utils/safe-json";
|
||||
|
||||
const StandaloneMarkdownPage = () => {
|
||||
const location = useLocation();
|
||||
@@ -91,7 +92,7 @@ const StandaloneMarkdownPage = () => {
|
||||
<h1 className="title">{markdown?.title === "index" ? indexTitle : markdown?.title}</h1>
|
||||
</div>
|
||||
{markdown && (
|
||||
<MarkdownView content={JSON.parse(markdown.content)} template={template} />
|
||||
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
11
src/utils/safe-json.js
Normal file
11
src/utils/safe-json.js
Normal 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." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user