diff --git a/web/src/components/markdown-content/index.tsx b/web/src/components/markdown-content/index.tsx index 9789987491..57df99c2a8 100644 --- a/web/src/components/markdown-content/index.tsx +++ b/web/src/components/markdown-content/index.tsx @@ -21,11 +21,13 @@ import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request'; import { useLoadingPause } from '@/hooks/use-loading-pause'; import { currentReg, + escapeUnmatchedAngleBrackets, parseCitationIndex, preprocessLaTeX, replaceRetrievingToSection, replaceTextByOldReg, replaceThinkToSection, + unescapeAngleBrackets, } from '@/utils/chat'; import classNames from 'classnames'; import { omit } from 'lodash'; @@ -65,7 +67,11 @@ const MarkdownContent = ({ const { setDocumentIds, data: fileThumbnails } = useFetchDocumentThumbnailsByIds(); const contentWithCursor = useMemo(() => { - let text = DOMPurify.sanitize(content, { + // Escape standalone < and > outside matched <...> tags + // so DOMPurify doesn't strip them as HTML. + const safeContent = escapeUnmatchedAngleBrackets(content); + + let text = DOMPurify.sanitize(safeContent, { ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'], ADD_ATTR: ['class'], }); @@ -78,11 +84,13 @@ const MarkdownContent = ({ const thinkSummary = loading ? `${t('chat.thinking')}...` : t('chat.thought'); - return pipe( - (value: string) => replaceThinkToSection(value, thinkSummary), - replaceRetrievingToSection, - preprocessLaTeX, - )(nextText); + return unescapeAngleBrackets( + pipe( + (value: string) => replaceThinkToSection(value, thinkSummary), + replaceRetrievingToSection, + preprocessLaTeX, + )(nextText), + ); }, [content, loading, t]); useEffect(() => { diff --git a/web/src/components/next-markdown-content/index.tsx b/web/src/components/next-markdown-content/index.tsx index 62d3ff80a9..5358fcf6fd 100644 --- a/web/src/components/next-markdown-content/index.tsx +++ b/web/src/components/next-markdown-content/index.tsx @@ -19,11 +19,13 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for import { currentReg, + escapeUnmatchedAngleBrackets, parseCitationIndex, preprocessLaTeX, replaceRetrievingToSection, replaceTextByOldReg, replaceThinkToSection, + unescapeAngleBrackets, } from '@/utils/chat'; import { citationMarkerReg } from '@/utils/citation-utils'; import { getDirAttribute } from '@/utils/text-direction'; @@ -172,7 +174,11 @@ function MarkdownContent({ const { setDocumentIds, data: fileThumbnails } = useFetchDocumentThumbnailsByIds(); const contentWithCursor = useMemo(() => { - let text = DOMPurify.sanitize(content, { + // Escape standalone < and > outside matched <...> tags + // so DOMPurify doesn't strip them as HTML. + const safeContent = escapeUnmatchedAngleBrackets(content); + + let text = DOMPurify.sanitize(safeContent, { ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'], ADD_ATTR: ['class'], }); @@ -184,11 +190,13 @@ function MarkdownContent({ const thinkSummary = loading ? `${t('chat.thinking')}...` : t('chat.thought'); - return pipe( - (value: string) => replaceThinkToSection(value, thinkSummary), - replaceRetrievingToSection, - preprocessLaTeX, - )(nextText); + return unescapeAngleBrackets( + pipe( + (value: string) => replaceThinkToSection(value, thinkSummary), + replaceRetrievingToSection, + preprocessLaTeX, + )(nextText), + ); }, [content, loading, t]); useEffect(() => { diff --git a/web/src/pages/next-search/markdown-content/index.tsx b/web/src/pages/next-search/markdown-content/index.tsx index 8b05ecd508..bd5eb63c95 100644 --- a/web/src/pages/next-search/markdown-content/index.tsx +++ b/web/src/pages/next-search/markdown-content/index.tsx @@ -17,11 +17,13 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for import { currentReg, + escapeUnmatchedAngleBrackets, parseCitationIndex, preprocessLaTeX, replaceRetrievingToSection, replaceTextByOldReg, replaceThinkToSection, + unescapeAngleBrackets, } from '@/utils/chat'; import { citationMarkerReg } from '@/utils/citation-utils'; import { getDirAttribute } from '@/utils/text-direction'; @@ -66,7 +68,11 @@ const MarkdownContent = ({ const { setDocumentIds, data: fileThumbnails } = useFetchDocumentThumbnailsByIds(); const contentWithCursor = useMemo(() => { - let text = DOMPurify.sanitize(content, { + // Escape standalone < and > outside matched <...> tags + // so DOMPurify doesn't strip them as HTML. + const safeContent = escapeUnmatchedAngleBrackets(content); + + let text = DOMPurify.sanitize(safeContent, { ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'], ADD_ATTR: ['class'], }); @@ -75,11 +81,13 @@ const MarkdownContent = ({ text = t('chat.searching'); } const nextText = replaceTextByOldReg(text); - return pipe( - replaceThinkToSection, - replaceRetrievingToSection, - preprocessLaTeX, - )(nextText); + return unescapeAngleBrackets( + pipe( + replaceThinkToSection, + replaceRetrievingToSection, + preprocessLaTeX, + )(nextText), + ); }, [content, t]); useEffect(() => { diff --git a/web/src/utils/chat.ts b/web/src/utils/chat.ts index 5b5bfd2cae..54a88d4c5c 100644 --- a/web/src/utils/chat.ts +++ b/web/src/utils/chat.ts @@ -103,6 +103,56 @@ export function replaceRetrievingToSection(text: string = '') { return result; } +// Placeholder markers used internally to protect standalone < and > from +// DOMPurify stripping. These Unicode symbols (U+27E8/U+27E9) are extremely +// unlikely to appear in normal user input. +const LT_MARKER = '\u27E8LT\u27E9'; +const GT_MARKER = '\u27E8GT\u27E9'; + +/** + * Escape standalone < and > that are NOT part of a matched <...> pair, + * so that DOMPurify won't strip them as HTML tags. + * Only brackets inside text segments (outside complete tags) are escaped; + * matched <...> tags are left intact for DOMPurify to handle. + */ +export function escapeUnmatchedAngleBrackets(content: string): string { + if (!content) return content; + + const segments: string[] = []; + const tags: string[] = []; + let lastIndex = 0; + + const regex = /<[^>]*>/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + segments.push(content.slice(lastIndex, match.index)); + tags.push(match[0]); + lastIndex = regex.lastIndex; + } + segments.push(content.slice(lastIndex)); + + const escapedSegments = segments.map((seg) => + seg.replace(//g, GT_MARKER), + ); + + return escapedSegments + .map((seg, i) => (i < tags.length ? seg + tags[i] : seg)) + .join(''); +} + +/** + * Restore escaped angle bracket markers back to HTML entities (</>). + * Must be called *after* preprocessLaTeX (which would otherwise convert + * </> back to raw <, >). + */ +export function unescapeAngleBrackets(content: string): string { + if (!content) return content; + return content + .replace(new RegExp(LT_MARKER, 'g'), '<') + .replace(new RegExp(GT_MARKER, 'g'), '>'); +} + export function setInitialChatVariableEnabledFieldValue( field: ChatVariableEnabledField, ) {