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
This commit is contained in:
Katherine
2026-07-03 15:42:36 +08:00
committed by GitHub
parent 0926ea5a38
commit fc78aa71da
7 changed files with 167 additions and 25 deletions
@@ -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;
@@ -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',
@@ -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) {
+1
View File
@@ -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';
@@ -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(/<iframe\b/i);
if (iframeStart === -1) {
result += markdown.slice(cursor);
break;
}
const start = cursor + iframeStart;
result += markdown.slice(cursor, start);
const openingEnd = findTagEnd(markdown, start);
if (openingEnd === -1) {
break;
}
const openingTag = markdown.slice(start, openingEnd + 1);
if (/\/\s*>$/.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;
}
@@ -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]);
@@ -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);
};
}, []);