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..5c1e0a7f5 --- /dev/null +++ b/test/xss-security.test.tsx @@ -0,0 +1,484 @@ +/** + * @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); + const xssContent = ' Safe text'; + + render( + , + ); + + 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( + , + ); + + 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'); + }); + }); + + 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 - SECURITY RISK', () => { + const xssContent = ''; + let capturedSource: string | undefined; + + const mockPreview = jest.fn((source: string) => { + capturedSource = source; + return
{source}
; + }); + + render( + , + ); + + expect(mockPreview).toHaveBeenCalled(); + expect(capturedSource).toBe(xssContent); + expect(capturedSource).toContain(' { + 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('does NOT have default XSS protection - IMPORTANT SECURITY NOTE', () => { + const xssContent = ''; + + const { container } = render( + , + ); + + const scripts = container.querySelectorAll('script.xss-test'); + expect(scripts.length).toBeGreaterThan(0); + }); + + 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 AND apply default sanitize', () => { + const xssContent = '

Title

'; + const { container } = render( + , + ); + + 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', () => { + 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); + } + }); + }); + }); +});