From fc78aa71da1399dfaedd60de8ff78719a497720d Mon Sep 17 00:00:00 2001 From: Katherine Date: Fri, 3 Jul 2026 15:42:36 +0800 Subject: [PATCH] fix(markdown): sanitize vditor iframe rendering (#9986) * fix(markdown): sanitize vditor iframe rendering * fix(markdown): strip iframes before vditor edit * fix(markdown): handle iframe srcdoc before vditor edit * refactor(markdown): share iframe sanitizer --- .../src/flow/common/Markdown/Display.tsx | 17 +++- .../src/flow/common/Markdown/Edit.tsx | 26 ++++-- .../flow/internal/components/Markdown/util.ts | 3 +- packages/core/client-v2/src/index.ts | 1 + .../client-v2/src/utils/markdownSanitize.ts | 88 +++++++++++++++++++ .../src/client-v2/components/Display.tsx | 23 ++++- .../src/client-v2/components/Edit.tsx | 34 ++++--- 7 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 packages/core/client-v2/src/utils/markdownSanitize.ts diff --git a/packages/core/client-v2/src/flow/common/Markdown/Display.tsx b/packages/core/client-v2/src/flow/common/Markdown/Display.tsx index 1cf2a73bba2..a4fdeadc5b8 100644 --- a/packages/core/client-v2/src/flow/common/Markdown/Display.tsx +++ b/packages/core/client-v2/src/flow/common/Markdown/Display.tsx @@ -12,6 +12,7 @@ import { css } from '@emotion/css'; import { createRoot } from 'react-dom/client'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import Vditor from 'vditor'; +import { removeMarkdownIframes, stripMarkdownIframes } from '../../../utils/markdownSanitize'; import { useCDN } from './useCDN'; import useStyle from './style'; @@ -51,8 +52,15 @@ function DisplayInner(props: { value: string; style?: CSSProperties; loadImages? Vditor.preview(containerRef.current, props.value ?? '', { mode: 'light', cdn, - }); + markdown: { + sanitize: true, + }, + transform: stripMarkdownIframes, + }) + .then(() => removeMarkdownIframes(containerRef.current)) + .catch(() => removeMarkdownIframes(containerRef.current)); setTimeout(() => { + removeMarkdownIframes(containerRef.current); containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => { img.style.cursor = 'zoom-in'; img.addEventListener('click', () => { @@ -129,13 +137,16 @@ export const Display = (props) => { Vditor.md2html(props.value, { mode: 'light', cdn, + markdown: { + sanitize: true, + }, }) .then((html) => { - setText(convertToText(html)); + setText(convertToText(stripMarkdownIframes(html))); }) .catch(() => setText('')); } - }, [props.value, textOnly]); + }, [props.value, textOnly, cdn]); const isOverflowTooltip = useCallback(() => { if (!elRef.current) return false; diff --git a/packages/core/client-v2/src/flow/common/Markdown/Edit.tsx b/packages/core/client-v2/src/flow/common/Markdown/Edit.tsx index 9ce8803bd96..38b7b78be75 100644 --- a/packages/core/client-v2/src/flow/common/Markdown/Edit.tsx +++ b/packages/core/client-v2/src/flow/common/Markdown/Edit.tsx @@ -17,6 +17,7 @@ import React, { useEffect, useCallback, useLayoutEffect, useMemo, useRef, useSta import { useTranslation } from 'react-i18next'; import Vditor from 'vditor'; import 'vditor/dist/index.css'; +import { stripMarkdownIframeTags, stripMarkdownIframes } from '../../../utils/markdownSanitize'; import { useCDN } from './useCDN'; import useStyle from './style'; @@ -102,13 +103,20 @@ const Edit = (props) => { if (!containerRef.current) return; const toolbarConfig = placeToolbarTooltipsBelow(toolbar ?? defaultToolbar); + const safeValue = stripMarkdownIframeTags(value ?? ''); const vditor = new Vditor(containerRef.current, { - value: value ?? '', + value: safeValue, lang, cache: { enable: false }, undoDelay: 0, mode: props.mode || 'ir', - preview: { math: { engine: 'KaTeX' } }, + preview: { + markdown: { + sanitize: true, + }, + math: { engine: 'KaTeX' }, + transform: stripMarkdownIframes, + }, toolbar: toolbarConfig, fullscreen: { index: 1200 }, cdn, @@ -117,7 +125,6 @@ const Edit = (props) => { after: () => { vdRef.current = vditor; setEditorReady(true); // Notify that the editor is ready - vditor.setValue(value ?? ''); if (disabled) { vditor.disabled(); } else { @@ -131,8 +138,12 @@ const Edit = (props) => { }); } }, - input(value) { - onChange(value); + input(nextValue) { + const safeNextValue = stripMarkdownIframeTags(nextValue); + if (safeNextValue !== nextValue) { + vditor.setValue(safeNextValue); + } + onChange(safeNextValue); }, upload: { multiple: false, @@ -229,8 +240,9 @@ const Edit = (props) => { useEffect(() => { if (editorReady && vdRef.current) { const editor = vdRef.current; - if (value !== editor.getValue()) { - editor.setValue(value ?? ''); + const safeValue = stripMarkdownIframeTags(value ?? ''); + if (safeValue !== editor.getValue()) { + editor.setValue(safeValue); const preArea = containerRef.current?.querySelector( 'div.vditor-content > div.vditor-ir > pre', diff --git a/packages/core/client-v2/src/flow/internal/components/Markdown/util.ts b/packages/core/client-v2/src/flow/internal/components/Markdown/util.ts index 1155adee7d3..8420639d79a 100644 --- a/packages/core/client-v2/src/flow/internal/components/Markdown/util.ts +++ b/packages/core/client-v2/src/flow/internal/components/Markdown/util.ts @@ -9,13 +9,14 @@ import _ from 'lodash'; import { useEffect, useState } from 'react'; +import { stripMarkdownIframes } from '../../../../utils/markdownSanitize'; export const parseMarkdown = _.memoize(async (text: string) => { if (!text) { return text; } const m = await import('./md'); - return m.default.render(text); + return stripMarkdownIframes(m.default.render(text)); }); export function useParseMarkdown(text: string) { diff --git a/packages/core/client-v2/src/index.ts b/packages/core/client-v2/src/index.ts index 5daa10c2d48..faa2297cfe4 100644 --- a/packages/core/client-v2/src/index.ts +++ b/packages/core/client-v2/src/index.ts @@ -39,5 +39,6 @@ export * from './collection-manager/interfaces'; export * from './collection-manager/template-fields'; export * from './data-source'; export * from './flow'; +export * from './utils/markdownSanitize'; export { DEFAULT_DATA_SOURCE_KEY, isTitleField, isTitleFieldInterface } from './flow-compat'; export { default as AntdAppProvider } from './theme/AntdAppProvider'; diff --git a/packages/core/client-v2/src/utils/markdownSanitize.ts b/packages/core/client-v2/src/utils/markdownSanitize.ts new file mode 100644 index 00000000000..2f840a20206 --- /dev/null +++ b/packages/core/client-v2/src/utils/markdownSanitize.ts @@ -0,0 +1,88 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export function stripMarkdownIframeTags(markdown: string) { + if (!markdown) { + return markdown; + } + + let result = ''; + let cursor = 0; + + while (cursor < markdown.length) { + const iframeStart = markdown.slice(cursor).search(/$/.test(openingTag)) { + cursor = openingEnd + 1; + continue; + } + + const closingStart = markdown.slice(openingEnd + 1).search(/<\/iframe\s*>/i); + if (closingStart === -1) { + break; + } + + cursor = + openingEnd + 1 + closingStart + markdown.slice(openingEnd + 1 + closingStart).match(/^<\/iframe\s*>/i)[0].length; + } + + return result; +} + +export function stripMarkdownIframes(html: string) { + if (!html || typeof DOMParser === 'undefined') { + return html; + } + + const doc = new DOMParser().parseFromString(html, 'text/html'); + doc.querySelectorAll('iframe').forEach((iframe) => iframe.remove()); + return doc.body.innerHTML; +} + +export function removeMarkdownIframes(container?: ParentNode | null) { + container?.querySelectorAll('iframe').forEach((iframe) => iframe.remove()); +} + +function findTagEnd(html: string, start: number) { + let quote: string | undefined; + + for (let index = start; index < html.length; index += 1) { + const char = html[index]; + if (quote) { + if (char === quote) { + quote = undefined; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '>') { + return index; + } + } + + return -1; +} diff --git a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Display.tsx b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Display.tsx index 4668ba5e598..a3e590be968 100644 --- a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Display.tsx +++ b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Display.tsx @@ -7,6 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { removeMarkdownIframes, stripMarkdownIframes } from '@nocobase/client-v2'; import { Popover } from 'antd'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import Vditor from 'vditor'; @@ -74,8 +75,15 @@ function DisplayInner(props: { value: string; style?: CSSProperties }) { Vditor.preview(containerRef.current, props.value ?? '', { mode: 'light', cdn, - }); + markdown: { + sanitize: true, + }, + transform: stripMarkdownIframes, + }) + .then(() => removeMarkdownIframes(containerRef.current)) + .catch(() => removeMarkdownIframes(containerRef.current)); setTimeout(() => { + removeMarkdownIframes(containerRef.current); containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => { img.style.cursor = 'zoom-in'; img.addEventListener('click', () => { @@ -107,16 +115,25 @@ export const Display = (props) => { Vditor.md2html(value, { mode: 'light', cdn, + markdown: { + sanitize: true, + }, }) .then((html) => { - setText(convertToText(html)); + setText(convertToText(stripMarkdownIframes(html))); }) .catch(() => setText('')); } else { Vditor.preview(containerRef.current, value, { mode: 'light', cdn, - }); + markdown: { + sanitize: true, + }, + transform: stripMarkdownIframes, + }) + .then(() => removeMarkdownIframes(containerRef.current)) + .catch(() => removeMarkdownIframes(containerRef.current)); } }, [value, props.ellipsis, cdn]); diff --git a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx index fef56005da3..03583c20bb9 100644 --- a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx +++ b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client-v2/components/Edit.tsx @@ -7,6 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { stripMarkdownIframeTags, stripMarkdownIframes } from '@nocobase/client-v2'; import { useFlowContext } from '@nocobase/flow-engine'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Vditor from 'vditor'; @@ -47,13 +48,20 @@ export const Edit = (props) => { if (!containerRef.current) return; const toolbarConfig = toolbar ?? defaultToolbar; + const safeValue = stripMarkdownIframeTags(value ?? ''); const vditor = new Vditor(containerRef.current, { - value: value ?? '', + value: safeValue, lang, cache: { enable: false }, undoDelay: 0, - preview: { math: { engine: 'KaTeX' } }, + preview: { + markdown: { + sanitize: true, + }, + math: { engine: 'KaTeX' }, + transform: stripMarkdownIframes, + }, toolbar: toolbarConfig, fullscreen: { index: 1200, @@ -68,8 +76,6 @@ export const Edit = (props) => { const savedScrollX = window.scrollX || window.pageXOffset; const savedScrollY = window.scrollY || window.pageYOffset; - vditor.setValue(value ?? ''); - requestAnimationFrame(() => { window.scrollTo(savedScrollX, savedScrollY); }); @@ -80,8 +86,12 @@ export const Edit = (props) => { vditor.enable(); } }, - input(value) { - onChange(value); + input(nextValue) { + const safeNextValue = stripMarkdownIframeTags(nextValue); + if (safeNextValue !== nextValue) { + vditor.setValue(safeNextValue); + } + onChange(safeNextValue); }, upload: { multiple: false, @@ -167,11 +177,12 @@ export const Edit = (props) => { useEffect(() => { if (editorReady && vdRef.current) { const editor = vdRef.current; - if (value !== editor.getValue()) { + const safeValue = stripMarkdownIframeTags(value ?? ''); + if (safeValue !== editor.getValue()) { const savedScrollX = window.scrollX || window.pageXOffset; const savedScrollY = window.scrollY || window.pageYOffset; - editor.setValue(value ?? ''); + editor.setValue(safeValue); const preArea = containerRef.current?.querySelector( 'div.vditor-content > div.vditor-ir > pre', ) as HTMLPreElement; @@ -234,7 +245,8 @@ export const Edit = (props) => { }, [zIndex]); useLayoutEffect(() => { - if (!containerRef.current) return; + const container = containerRef.current; + if (!container) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { @@ -249,10 +261,10 @@ export const Edit = (props) => { } }); - observer.observe(containerRef.current); + observer.observe(container); return () => { - observer.unobserve(containerRef.current); + observer.unobserve(container); }; }, []);