Skip to content

Commit cb1683e

Browse files
authored
🤖 fix: add rehype-sanitize to prevent XSS in markdown rendering (#1050)
## Problem The error `The tag <noun> is unrecognized in this browser` indicated that arbitrary HTML tags were being parsed and rendered. This exposed the app to XSS attacks. ### Root Cause The markdown rendering pipeline used: 1. `rehype-raw` - Parses raw HTML embedded in markdown 2. `rehype-harden` - Only sanitizes URLs in `<a>` and `<img>` tags The gap: `rehype-harden` does NOT strip unknown/dangerous HTML elements (`<script>`, `<style>`, `<form>`, `<noun>`, etc.) or remove event handlers (`onclick`, `onerror`, etc.). ## Solution Add `rehype-sanitize` to the plugin chain with a schema that: - Allows safe HTML elements commonly used in markdown - Allows KaTeX MathML elements for math rendering - Allows `<details>`/`<summary>` for collapsible sections - Blocks dangerous elements and strips event handlers ## Testing - [x] `make static-check` passes - [x] `make typecheck` passes - [x] Mermaid tests pass --- _Generated with `mux`_
1 parent 5cc0ba8 commit cb1683e

File tree

3 files changed

+55
-2
lines changed

3 files changed

+55
-2
lines changed

bun.lock

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"lockfileVersion": 1,
3-
"configVersion": 0,
43
"workspaces": {
54
"": {
65
"name": "mux",
@@ -58,6 +57,7 @@
5857
"parse-duration": "^2.1.4",
5958
"posthog-node": "^5.17.0",
6059
"rehype-harden": "^1.1.5",
60+
"rehype-sanitize": "^6.0.0",
6161
"shescape": "^2.1.6",
6262
"source-map-support": "^0.5.21",
6363
"streamdown": "1.6.10",
@@ -2285,6 +2285,8 @@
22852285

22862286
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
22872287

2288+
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
2289+
22882290
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
22892291

22902292
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
@@ -3145,6 +3147,8 @@
31453147

31463148
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
31473149

3150+
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
3151+
31483152
"release-zalgo": ["release-zalgo@1.0.0", "", { "dependencies": { "es6-error": "^4.0.1" } }, "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA=="],
31493153

31503154
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"parse-duration": "^2.1.4",
9999
"posthog-node": "^5.17.0",
100100
"rehype-harden": "^1.1.5",
101+
"rehype-sanitize": "^6.0.0",
101102
"shescape": "^2.1.6",
102103
"source-map-support": "^0.5.21",
103104
"streamdown": "1.6.10",

src/browser/components/Messages/MarkdownCore.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
55
import remarkMath from "remark-math";
66
import rehypeKatex from "rehype-katex";
77
import rehypeRaw from "rehype-raw";
8+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
89
import { harden } from "rehype-harden";
910
import "katex/dist/katex.min.css";
1011
import { normalizeMarkdown } from "./MarkdownStyles";
@@ -30,10 +31,57 @@ const REMARK_PLUGINS: Pluggable[] = [
3031
[remarkMath, { singleDollarTextMath: false }],
3132
];
3233

34+
// Schema for rehype-sanitize that allows safe HTML elements.
35+
// Extends the default schema to support KaTeX math and collapsible sections.
36+
const sanitizeSchema = {
37+
...defaultSchema,
38+
tagNames: [
39+
...(defaultSchema.tagNames ?? []),
40+
// KaTeX MathML elements
41+
"math",
42+
"mrow",
43+
"mi",
44+
"mo",
45+
"mn",
46+
"msup",
47+
"msub",
48+
"mfrac",
49+
"munder",
50+
"mover",
51+
"mtable",
52+
"mtr",
53+
"mtd",
54+
"mspace",
55+
"mtext",
56+
"semantics",
57+
"annotation",
58+
"munderover",
59+
"msqrt",
60+
"mroot",
61+
"mpadded",
62+
"mphantom",
63+
"menclose",
64+
// Collapsible sections (GitHub-style)
65+
"details",
66+
"summary",
67+
],
68+
attributes: {
69+
...defaultSchema.attributes,
70+
// KaTeX uses style for coloring and positioning
71+
span: [...(defaultSchema.attributes?.span ?? []), "style"],
72+
// MathML elements need various attributes
73+
math: ["xmlns", "display"],
74+
annotation: ["encoding"],
75+
// Allow class on all elements for styling
76+
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "class"],
77+
},
78+
};
79+
3380
const REHYPE_PLUGINS: Pluggable[] = [
3481
rehypeRaw, // Parse HTML elements first
82+
[rehypeSanitize, sanitizeSchema], // Sanitize HTML to prevent XSS (strips dangerous elements/attributes)
3583
[
36-
harden, // Sanitize after parsing raw HTML to prevent XSS
84+
harden, // Additional URL filtering for links and images
3785
{
3886
allowedImagePrefixes: ["*"],
3987
allowedLinkPrefixes: ["*"],

0 commit comments

Comments
 (0)