From a14e1e9013ebfe0a9f87313593177a6e169a6f3a Mon Sep 17 00:00:00 2001 From: leocagli Date: Tue, 23 Jun 2026 13:32:50 -0300 Subject: [PATCH] fix(security): sanitize markdown in RenderMarkdownContent to prevent XSS IssuesTab renders external GitHub discussion content through react-markdown with no sanitization, creating an XSS vector. Adds rehype-sanitize with a strict protocol allowlist (http, https, mailto only) that blocks javascript: and data: URLs in links and image sources while preserving safe markup. Closes #190 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 30 ++++++++++++++++++++++++++++++ package.json | 1 + src/app/utils/renderMarkdown.tsx | 17 +++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/package-lock.json b/package-lock.json index eded157..7f07504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "react-slick": "0.31.0", "react-theme-switch-animation": "^1.0.0", "recharts": "2.15.2", + "rehype-sanitize": "^6.0.0", "simple-icons": "^16.2.0", "sonner": "2.0.3", "tailwind-merge": "3.2.0", @@ -7141,6 +7142,21 @@ "node": ">= 0.4" } }, + "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": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -10115,6 +10131,20 @@ "@babel/runtime": "^7.9.2" } }, + "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/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", diff --git a/package.json b/package.json index 68289c9..f428731 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "react-slick": "0.31.0", "react-theme-switch-animation": "^1.0.0", "recharts": "2.15.2", + "rehype-sanitize": "^6.0.0", "simple-icons": "^16.2.0", "sonner": "2.0.3", "tailwind-merge": "3.2.0", diff --git a/src/app/utils/renderMarkdown.tsx b/src/app/utils/renderMarkdown.tsx index 8c5bebb..7f4c449 100644 --- a/src/app/utils/renderMarkdown.tsx +++ b/src/app/utils/renderMarkdown.tsx @@ -1,4 +1,20 @@ import ReactMarkdown from "react-markdown" +import rehypeSanitize, { defaultSchema } from "rehype-sanitize" + +const sanitizeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + a: [ + ...(defaultSchema.attributes?.a ?? []), + ], + }, + protocols: { + ...defaultSchema.protocols, + href: ["http", "https", "mailto"], + src: ["http", "https"], + }, +} export default function RenderMarkdownContent({ content, @@ -7,6 +23,7 @@ export default function RenderMarkdownContent({ }) { return (

{children}

, p: ({ children }) =>

{children}

,