From a9a48d7d1e13676ee00f332f21ec468d752465c5 Mon Sep 17 00:00:00 2001 From: lovely90133 Date: Sat, 2 May 2026 09:36:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(=E5=AE=89=E5=85=A8):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E9=BB=98=E8=AE=A4=E7=9A=84XSS=E9=98=B2=E6=8A=A4?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加rehype-sanitize作为默认的markdown预览安全插件 实现自动合并用户自定义rehype插件与默认安全插件 添加全面的XSS安全测试用例 --- core/package.json | 3 +- core/src/Editor.factory.tsx | 45 +++- test/xss-security.test.tsx | 452 ++++++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 test/xss-security.test.tsx diff --git a/core/package.json b/core/package.json index fc3647fb0..e261804fb 100644 --- a/core/package.json +++ b/core/package.json @@ -76,7 +76,8 @@ "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.2.0", "rehype": "~13.0.0", - "rehype-prism-plus": "~2.0.0" + "rehype-prism-plus": "~2.0.0", + "rehype-sanitize": "^6.0.0" }, "keywords": [ "react", diff --git a/core/src/Editor.factory.tsx b/core/src/Editor.factory.tsx index c9c9ee13f..e02d8e349 100644 --- a/core/src/Editor.factory.tsx +++ b/core/src/Editor.factory.tsx @@ -1,10 +1,47 @@ import React, { useEffect, useReducer, useMemo, useRef, useImperativeHandle } from 'react'; +import rehypeSanitize from 'rehype-sanitize'; import { ToolbarVisibility } from './components/Toolbar/'; import DragBar from './components/DragBar/'; import { getCommands, getExtraCommands, type ICommand, type TextState, TextAreaCommandOrchestrator } from './commands/'; import { reducer, EditorContext, type ContextStore } from './Context'; import type { MDEditorProps } from './Types'; +type RehypePlugin = any; +type RehypePluginTuple = [RehypePlugin, any]; +type RehypePluginItem = RehypePlugin | RehypePluginTuple; + +function containsRehypeSanitize(plugins: RehypePluginItem[]): boolean { + return plugins.some((plugin) => { + if (Array.isArray(plugin)) { + return plugin[0] === rehypeSanitize; + } + return plugin === rehypeSanitize; + }); +} + +function mergePreviewOptionsWithSanitize( + previewOptions: MDEditorProps['previewOptions'], +): MDEditorProps['previewOptions'] { + if (!previewOptions) { + return { rehypePlugins: [rehypeSanitize] }; + } + + const { rehypePlugins, ...restOptions } = previewOptions; + + if (rehypePlugins === undefined) { + return { ...restOptions, rehypePlugins: [rehypeSanitize] }; + } + + if (Array.isArray(rehypePlugins)) { + if (containsRehypeSanitize(rehypePlugins)) { + return previewOptions; + } + return { ...restOptions, rehypePlugins: [rehypeSanitize, ...rehypePlugins] }; + } + + return { ...restOptions, rehypePlugins: [rehypeSanitize, rehypePlugins] }; +} + function setGroupPopFalse(data: Record = {}) { Object.keys(data).forEach((keyname) => { data[keyname] = false; @@ -190,15 +227,17 @@ export function createMDEditor< } }; - const previewClassName = `${prefixCls}-preview ${previewOptions.className || ''}`; + const mergedPreviewOptions = useMemo(() => mergePreviewOptionsWithSanitize(previewOptions), [previewOptions]); + + const previewClassName = `${prefixCls}-preview ${mergedPreviewOptions.className || ''}`; const handlePreviewScroll = (e: React.UIEvent) => handleScroll(e, 'preview'); let mdPreview = useMemo( () => (
- +
), - [previewClassName, previewOptions, state.markdown], + [previewClassName, mergedPreviewOptions, state.markdown], ); const preview = components?.preview && components?.preview(state.markdown || '', state, dispatch); if (preview && React.isValidElement(preview)) { diff --git a/test/xss-security.test.tsx b/test/xss-security.test.tsx new file mode 100644 index 000000000..67675d5f3 --- /dev/null +++ b/test/xss-security.test.tsx @@ -0,0 +1,452 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import MDEditor from '../core/src'; +import MDEditorCommon from '../core/src/index.common'; +import MDEditorNoHighlight from '../core/src/index.nohighlight'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; + +const modeEditors = [ + ['default', MDEditor], + ['common', MDEditorCommon], + ['nohighlight', MDEditorNoHighlight], +] as const; + +describe('XSS Security Regression Tests', () => { + describe('Default XSS Protection - MDEditor Component', () => { + describe.each(modeEditors)('%s mode: default sanitization', (modeName, EditorComponent) => { + it('should sanitize script tags by default', () => { + const xssContent = 'Hello World'; + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(container).toHaveTextContent('Hello'); + expect(container).toHaveTextContent('World'); + }); + + it('should sanitize iframe with javascript: protocol by default', () => { + const xssContent = ''; + const { container } = render( + , + ); + + const iframes = container.querySelectorAll('iframe'); + iframes.forEach((iframe) => { + const src = iframe.getAttribute('src'); + if (src) { + expect(src.toLowerCase()).not.toMatch(/^javascript:/i); + } + }); + }); + + it('should sanitize img onerror events by default', () => { + const xssContent = ''; + const { container } = render( + , + ); + + const imgs = container.querySelectorAll('img'); + imgs.forEach((img) => { + expect(img.hasAttribute('onerror')).toBe(false); + expect(img.hasAttribute('onload')).toBe(false); + }); + }); + + it('should sanitize svg onload events by default', () => { + const xssContent = ''; + const { container } = render( + , + ); + + const svgs = container.querySelectorAll('svg'); + svgs.forEach((svg) => { + expect(svg.hasAttribute('onload')).toBe(false); + }); + }); + + it('should sanitize anchor tags with javascript: href by default', () => { + const xssContent = '[Click me](javascript:alert(1))'; + const { container } = render( + , + ); + + const links = container.querySelectorAll('a'); + links.forEach((link) => { + const href = link.getAttribute('href'); + if (href) { + expect(href.toLowerCase()).not.toMatch(/^javascript:/i); + } + }); + }); + + it('should sanitize form action with javascript: by default', () => { + const xssContent = '
'; + const { container } = render( + , + ); + + const forms = container.querySelectorAll('form'); + forms.forEach((form) => { + const action = form.getAttribute('action'); + if (action) { + expect(action.toLowerCase()).not.toMatch(/^javascript:/i); + } + }); + }); + + it('should preserve safe HTML tags by default', () => { + const safeContent = '

Title

Paragraph with bold and italic text

'; + const { container } = render( + , + ); + + expect(container.querySelector('h1')).toBeInTheDocument(); + expect(container.querySelector('p')).toBeInTheDocument(); + expect(container.querySelector('strong')).toBeInTheDocument(); + expect(container.querySelector('em')).toBeInTheDocument(); + }); + }); + }); + + describe('User Configuration Compatibility', () => { + describe('Custom rehype-sanitize schema', () => { + it('should respect user-defined rehype-sanitize schema', () => { + const content = 'Custom styled text'; + const { container } = render( + , + ); + + const span = container.querySelector('span.custom-class'); + expect(span).toBeInTheDocument(); + expect(span).toHaveTextContent('Custom styled text'); + }); + + it('should use user-configured rehype-sanitize instead of default when explicitly provided', () => { + const xssContent = 'Hello World'; + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(container).toHaveTextContent('Hello'); + expect(container).toHaveTextContent('World'); + }); + }); + + describe('Custom rehype plugins', () => { + it('should merge default sanitize with user rehype plugins', () => { + const mockRehypePlugin = jest.fn(() => (tree: any) => tree); + + render( + , + ); + + expect(screen.getByTitle('merge-plugins')).toBeInTheDocument(); + }); + + it('should allow multiple rehype plugins including sanitize', () => { + const mockPlugin1 = jest.fn(() => (tree: any) => tree); + const mockPlugin2 = jest.fn(() => (tree: any) => tree); + + render( + , + ); + + expect(screen.getByTitle('multiple-plugins')).toBeInTheDocument(); + }); + }); + + describe('Custom preview component', () => { + it('should allow complete override via components.preview', () => { + const customPreviewText = 'Custom Preview Rendered'; + const mockPreview = jest.fn(() =>
{customPreviewText}
); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-preview')).toBeInTheDocument(); + expect(getByTestId('custom-preview')).toHaveTextContent(customPreviewText); + expect(mockPreview).toHaveBeenCalled(); + }); + + it('should pass correct source to custom preview component', () => { + const testMarkdown = '# Test Content\n\nSome text'; + let capturedSource: string | undefined; + + const mockPreview = jest.fn((source: string) => { + capturedSource = source; + return
{source}
; + }); + + render( + , + ); + + expect(mockPreview).toHaveBeenCalled(); + expect(capturedSource).toBe(testMarkdown); + }); + + it('should not apply default sanitize when using custom preview component', () => { + const xssContent = ''; + const mockPreview = jest.fn((source: string) => ( +
+ )); + + const { container, getByTestId } = render( + , + ); + + expect(getByTestId('custom-preview')).toBeInTheDocument(); + expect(mockPreview).toHaveBeenCalledWith(xssContent, expect.anything(), expect.anything()); + }); + }); + + describe('Other previewOptions', () => { + it('should pass className from previewOptions', () => { + const customClassName = 'custom-preview-class'; + const { container } = render( + , + ); + + const previewElement = container.querySelector('.w-md-editor-preview'); + expect(previewElement).toHaveClass(customClassName); + }); + }); + }); + + describe('MDEditor.Markdown Static Component', () => { + it('requires manual rehype-sanitize configuration for XSS protection', () => { + const xssContent = ''; + + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script'); + if (scripts.length > 0) { + console.warn('Note: MDEditor.Markdown static component does not have default XSS protection. ' + + 'Users must manually configure rehype-sanitize when using this component directly.'); + } + }); + + it('should respect rehype-sanitize when explicitly configured', () => { + const xssContent = 'Hello World'; + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(container).toHaveTextContent('Hello'); + expect(container).toHaveTextContent('World'); + }); + + it('should work with custom rehype-sanitize schema', () => { + const content = 'Styled text'; + const { container } = render( + , + ); + + const span = container.querySelector('span.my-class'); + expect(span).toBeInTheDocument(); + expect(span).toHaveTextContent('Styled text'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty previewOptions gracefully', () => { + const { container } = render( + , + ); + + expect(screen.getByTitle('empty-options')).toBeInTheDocument(); + expect(container.querySelector('h1')).toBeInTheDocument(); + }); + + it('should handle markdown without HTML', () => { + const markdown = '# Title\n\nThis is **bold** and *italic* text.\n\n- List item 1\n- List item 2'; + const { container } = render( + , + ); + + expect(container.querySelector('h1')).toHaveTextContent('Title'); + expect(container.querySelector('strong')).toHaveTextContent('bold'); + expect(container.querySelector('em')).toHaveTextContent('italic'); + }); + + it('should sanitize nested dangerous HTML', () => { + const nestedXss = '

Safe text

'; + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(container).toHaveTextContent('Safe text'); + }); + + it('should sanitize data URI schemes', () => { + const xssContent = ''; + const { container } = render( + , + ); + + const imgs = container.querySelectorAll('img'); + imgs.forEach((img) => { + const src = img.getAttribute('src'); + if (src && src.startsWith('data:')) { + expect(src).not.toMatch(/text\/html/i); + } + }); + }); + }); +}); From afccc63dcb55498dce89a2b395cdf9d50a08fc63 Mon Sep 17 00:00:00 2001 From: lovely90133 Date: Sat, 2 May 2026 10:03:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(xss-security):=20=E5=A2=9E=E5=BC=BAXSS?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=E7=9A=84?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E8=8C=83=E5=9B=B4=E5=92=8C=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加更多XSS测试用例并完善断言,验证默认sanitize行为与自定义rehype插件的交互 确保测试覆盖安全风险场景并明确标记潜在风险 --- test/xss-security.test.tsx | 76 +++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/test/xss-security.test.tsx b/test/xss-security.test.tsx index 67675d5f3..5c1e0a7f5 100644 --- a/test/xss-security.test.tsx +++ b/test/xss-security.test.tsx @@ -200,10 +200,11 @@ describe('XSS Security Regression Tests', () => { describe('Custom rehype plugins', () => { it('should merge default sanitize with user rehype plugins', () => { const mockRehypePlugin = jest.fn(() => (tree: any) => tree); + const xssContent = ' Safe text'; render( { />, ); - expect(screen.getByTitle('merge-plugins')).toBeInTheDocument(); + const scripts = document.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(document.body).toHaveTextContent('Safe text'); + expect(mockRehypePlugin).toHaveBeenCalled(); }); it('should allow multiple rehype plugins including sanitize', () => { const mockPlugin1 = jest.fn(() => (tree: any) => tree); const mockPlugin2 = jest.fn(() => (tree: any) => tree); + const xssContent = ' Safe'; render( { />, ); - expect(screen.getByTitle('multiple-plugins')).toBeInTheDocument(); + const scripts = document.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(document.body).toHaveTextContent('Safe'); + expect(mockPlugin1).toHaveBeenCalled(); + expect(mockPlugin2).toHaveBeenCalled(); + }); + + it('should not duplicate sanitize when user already includes it', () => { + const xssContent = ' Safe'; + + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script'); + expect(scripts.length).toBe(0); + expect(container).toHaveTextContent('Safe'); }); }); @@ -283,13 +311,16 @@ describe('XSS Security Regression Tests', () => { expect(capturedSource).toBe(testMarkdown); }); - it('should not apply default sanitize when using custom preview component', () => { - const xssContent = ''; - const mockPreview = jest.fn((source: string) => ( -
- )); + it('should not apply default sanitize when using custom preview component - SECURITY RISK', () => { + const xssContent = ''; + let capturedSource: string | undefined; + + const mockPreview = jest.fn((source: string) => { + capturedSource = source; + return
{source}
; + }); - const { container, getByTestId } = render( + render( { />, ); - expect(getByTestId('custom-preview')).toBeInTheDocument(); - expect(mockPreview).toHaveBeenCalledWith(xssContent, expect.anything(), expect.anything()); + expect(mockPreview).toHaveBeenCalled(); + expect(capturedSource).toBe(xssContent); + expect(capturedSource).toContain(' { }); describe('MDEditor.Markdown Static Component', () => { - it('requires manual rehype-sanitize configuration for XSS protection', () => { - const xssContent = ''; + it('does NOT have default XSS protection - IMPORTANT SECURITY NOTE', () => { + const xssContent = ''; const { container } = render( , ); - const scripts = container.querySelectorAll('script'); - if (scripts.length > 0) { - console.warn('Note: MDEditor.Markdown static component does not have default XSS protection. ' + - 'Users must manually configure rehype-sanitize when using this component directly.'); - } + const scripts = container.querySelectorAll('script.xss-test'); + expect(scripts.length).toBeGreaterThan(0); }); it('should respect rehype-sanitize when explicitly configured', () => { @@ -386,18 +415,21 @@ describe('XSS Security Regression Tests', () => { }); describe('Edge Cases', () => { - it('should handle empty previewOptions gracefully', () => { + it('should handle empty previewOptions gracefully AND apply default sanitize', () => { + const xssContent = '

Title

'; const { container } = render( , ); - expect(screen.getByTitle('empty-options')).toBeInTheDocument(); + const scripts = container.querySelectorAll('script'); + expect(scripts.length).toBe(0); expect(container.querySelector('h1')).toBeInTheDocument(); + expect(container.querySelector('h1')).toHaveTextContent('Title'); }); it('should handle markdown without HTML', () => {