fix(frontend): sanitize user markdown to prevent stored XSS (#9386)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-30 10:40:56 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent fddbe4a51c
commit def01b8ff6
5 changed files with 66 additions and 19 deletions
+30
View File
@@ -66,6 +66,7 @@
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rfc4648": "^1.5.3",
"runed": "^0.36.0",
"svelte-carousel": "^1.0.25",
@@ -6352,6 +6353,21 @@
"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-parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
@@ -10888,6 +10904,20 @@
"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/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+2 -1
View File
@@ -126,7 +126,6 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"unist-util-visit": "^5.0.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
@@ -141,12 +140,14 @@
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rfc4648": "^1.5.3",
"runed": "^0.36.0",
"svelte-carousel": "^1.0.25",
"svelte-exmarkdown": "^5.0.0",
"svelte-infinite-loading": "^1.4.0",
"tailwind-merge": "^1.13.2",
"unist-util-visit": "^5.0.0",
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
@@ -1,19 +1,12 @@
<script lang="ts">
import { Markdown, type Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
import { Markdown } from 'svelte-exmarkdown'
import { markdownPlugins as plugins } from './markdownPlugins'
interface Props {
md: string
noPadding?: boolean
}
let { md, noPadding }: Props = $props()
const plugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeGithubAlerts] }
]
</script>
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
@@ -5,10 +5,8 @@
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import { initCss } from '../../utils'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
import { Markdown, type Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
import { Markdown } from 'svelte-exmarkdown'
import { markdownPlugins as plugins } from '$lib/components/markdownPlugins'
import { classNames } from '$lib/utils'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
@@ -31,11 +29,6 @@
configuration
}: Props = $props()
const plugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeGithubAlerts] }
]
const { app, worldStore, mode } = getContext<AppViewerContext>('AppViewerContext')
const resolvedConfig = $state(
@@ -0,0 +1,30 @@
import type { Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
/**
* Shared plugin chain for rendering user-supplied Markdown (script/flow/resource
* descriptions, flow-graph notes, the App "Markdown" component, ...).
*
* Order matters and is security-sensitive:
* 1. `gfmPlugin` — GitHub-flavored Markdown.
* 2. `rehypeRaw` — re-parses embedded raw HTML into live hast nodes.
* 3. `rehypeSanitize` — strips dangerous nodes (`<script>`, `<iframe>`,
* `<svg><script>`, `on*` handlers, `javascript:` URLs)
* from the raw-parsed tree. Without this stage the raw
* HTML reaches the DOM and executes (stored XSS).
* 4. `rehypeGithubAlerts` — runs AFTER sanitize so the trusted alert markup it
* injects (including inline SVG icons) is preserved
* without having to allowlist SVG for user input.
*
* Any Markdown sink that renders user input MUST use this chain rather than
* assembling its own `rehypeRaw` pipeline.
*/
export const markdownPlugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeSanitize] },
{ rehypePlugin: [rehypeGithubAlerts] }
]