mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+20
-3
@@ -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]);
|
||||
|
||||
|
||||
+23
-11
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user