add csp and more function for markdown (#4921)

* support html

* html

* add csp

* remove unuse function

---------

Co-authored-by: dreamer6680 <146868355@qq.com>
This commit is contained in:
dreamer6680 2025-05-29 16:19:12 +08:00 committed by archer
parent 0f866fc552
commit d7a722a609
No known key found for this signature in database
GPG Key ID: 4446499B846D4A9E
6 changed files with 560 additions and 44 deletions

68
pnpm-lock.yaml generated
View File

@ -46,7 +46,7 @@ importers:
version: 10.1.4(socks@2.8.4)
next-i18next:
specifier: 15.4.2
version: 15.4.2(i18next@23.16.8)(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 15.4.2(i18next@23.16.8)(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
prettier:
specifier: 3.2.4
version: 3.2.4
@ -343,7 +343,7 @@ importers:
version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@chakra-ui/next-js':
specifier: 2.4.2
version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)
version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)
'@chakra-ui/react':
specifier: 2.10.7
version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -406,7 +406,7 @@ importers:
version: 4.17.21
next-i18next:
specifier: 15.4.2
version: 15.4.2(i18next@23.16.8)(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 15.4.2(i18next@23.16.8)(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
papaparse:
specifier: ^5.4.1
version: 5.4.1
@ -467,7 +467,7 @@ importers:
version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@chakra-ui/next-js':
specifier: 2.4.2
version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)
version: 2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)
'@chakra-ui/react':
specifier: 2.10.7
version: 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -569,7 +569,7 @@ importers:
version: 14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1)
next-i18next:
specifier: 15.4.2
version: 15.4.2(i18next@23.16.8)(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 15.4.2(i18next@23.16.8)(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
nprogress:
specifier: ^0.2.0
version: 0.2.0
@ -612,6 +612,9 @@ importers:
rehype-katex:
specifier: ^7.0.0
version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
@ -5879,9 +5882,15 @@ packages:
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.0:
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
@ -5919,6 +5928,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@ -8026,6 +8038,9 @@ packages:
property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
property-information@7.0.0:
resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==}
@ -8361,6 +8376,9 @@ packages:
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
@ -10969,7 +10987,7 @@ snapshots:
'@chakra-ui/system': 2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)':
'@chakra-ui/next-js@2.4.2(@chakra-ui/react@2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react@18.3.1)':
dependencies:
'@chakra-ui/react': 2.10.7(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@emotion/cache': 11.14.0
@ -16108,6 +16126,22 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
parse5: 7.2.1
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.6
@ -16128,6 +16162,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.0:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 6.5.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
@ -16175,6 +16219,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@ -18238,7 +18284,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
next-i18next@15.4.2(i18next@23.16.8)(next@14.2.28(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
next-i18next@15.4.2(i18next@23.16.8)(next@14.2.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-i18next@14.1.2(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.10
'@types/hoist-non-react-statics': 3.3.6
@ -18873,6 +18919,8 @@ snapshots:
dependencies:
xtend: 4.0.2
property-information@6.5.0: {}
property-information@7.0.0: {}
proto-list@1.2.4: {}
@ -19292,6 +19340,12 @@ snapshots:
unist-util-visit-parents: 6.0.1
vfile: 6.0.3
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4

View File

@ -11,6 +11,53 @@ const nextConfig = {
output: 'standalone',
reactStrictMode: isDev ? false : true,
compress: true,
headers: async () => {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = `'nonce-${nonce}'`;
const scheme_source = 'data: mediastream: blob: filesystem:';
const NECESSARY_DOMAINS = [
'*.sentry.io',
'http://localhost:*',
'http://127.0.0.1:*',
'https://analytics.google.com',
'googletagmanager.com',
'*.googletagmanager.com',
'https://www.google-analytics.com',
'https://api.github.com'
].join(' ');
return [
{
source: '/chat/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'Referrer-Policy', value: 'no-referrer' },
{
key: 'Content-Security-Policy',
value: [
`default-src 'self' ${scheme_source} ${NECESSARY_DOMAINS} ${csp}`,
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${csp} ${NECESSARY_DOMAINS}`,
`style-src 'self' 'unsafe-inline' ${csp} ${NECESSARY_DOMAINS}`,
`media-src 'self' http: ${scheme_source} ${NECESSARY_DOMAINS} ${csp}`,
`worker-src 'self' ${csp} ${NECESSARY_DOMAINS} ${scheme_source}`,
`img-src * data: blob:`,
`font-src 'self'`,
`connect-src 'self' wss: https: ${scheme_source} ${NECESSARY_DOMAINS} ${csp}`,
"object-src 'none'",
"form-action 'self'",
"base-uri 'self'",
"frame-src 'self' 'allow-scripts'",
'sandbox allow-scripts allow-same-origin allow-popups allow-forms',
'upgrade-insecure-requests'
].join('; ')
}
]
}
];
},
webpack(config, { isServer, nextRuntime }) {
Object.assign(config.resolve.alias, {
'@mongodb-js/zstd': false,
@ -85,7 +132,7 @@ const nextConfig = {
'pg',
'bullmq',
'@zilliz/milvus2-sdk-node',
"tiktoken",
'tiktoken'
],
outputFileTracingRoot: path.join(__dirname, '../../'),
instrumentationHook: true

View File

@ -60,6 +60,7 @@
"recharts": "^2.15.0",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.0",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",

View File

@ -0,0 +1,35 @@
import React from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <div>Something went wrong while rendering Markdown.</div>;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import RemarkMath from 'remark-math'; // Math syntax
@ -6,15 +6,15 @@ import RemarkBreaks from 'remark-breaks'; // Line break
import RehypeKatex from 'rehype-katex'; // Math render
import RemarkGfm from 'remark-gfm'; // Special markdown syntax
import RehypeExternalLinks from 'rehype-external-links';
import RehypeRaw from 'rehype-raw';
import styles from './index.module.scss';
import dynamic from 'next/dynamic';
import { Box } from '@chakra-ui/react';
import { CodeClassNameEnum, mdTextFormat } from './utils';
import ErrorBoundary from './errorBoundry';
import SVGRenderer from './markdowSVG';
import { useCreation } from 'ahooks';
import type { AProps } from './A';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
const MdImage = dynamic(() => import('./img/Image'), { ssr: false });
@ -26,7 +26,40 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
const A = dynamic(() => import('./A'), { ssr: false });
function isSafeHref(href: string): boolean {
if (!href) return false;
// allow http(s), mailto, tel, relative paths, #, data:image/audio/video
return /^(https?:|mailto:|tel:|\/|#|data:(?:image|audio|video))/i.test(href.trim());
}
const SafeA = (props: any) => {
const href = props.href || '';
const safeHref = isSafeHref(href) ? href : '#';
const ALLOWED_A_ATTRS = new Set([
'href',
'target',
'rel',
'className',
'children',
'style',
'title'
]);
const safeProps = filterSafeProps(props, ALLOWED_A_ATTRS);
return (
<a
{...safeProps}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer underline !decoration-primary-700 decoration-dashed"
>
{props.children || 'Download'}
</a>
);
};
type Props = {
source?: string;
@ -36,11 +69,9 @@ type Props = {
} & AProps;
const Markdown = (props: Props) => {
const source = props.source || '';
if (source.length < 200000) {
return <MarkdownRender {...props} />;
}
return <Box whiteSpace={'pre-wrap'}>{source}</Box>;
};
const MarkdownRender = ({
@ -48,7 +79,6 @@ const MarkdownRender = ({
showAnimation,
isDisabled,
forbidZhFormat,
chatAuthData,
onOpenCiteModal
}: Props) => {
@ -57,54 +87,116 @@ const MarkdownRender = ({
img: Image,
pre: RewritePre,
code: Code,
svg: SVGRenderer,
script: ScriptBlock,
a: (props: any) => (
<A
<SafeA
{...props}
showAnimation={showAnimation}
chatAuthData={chatAuthData}
onOpenCiteModal={onOpenCiteModal}
showAnimation={showAnimation}
/>
)
};
}, [chatAuthData, onOpenCiteModal, showAnimation]);
const formatSource = useMemo(() => {
if (showAnimation || forbidZhFormat) return source;
return mdTextFormat(source);
}, [forbidZhFormat, showAnimation, source]);
const urlTransform = useCallback((val: string) => {
return val;
}, []);
const urlTransform = useCallback((val: string) => val, []);
return (
<Box position={'relative'}>
<ErrorBoundary>
<ReactMarkdown
className={`markdown ${styles.markdown}
${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
`}
className={`markdown ${styles.markdown} ${
showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''
}`}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]}
rehypePlugins={[
RehypeKatex,
[RehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
RehypeRaw as any,
() => {
return (tree) => {
const iterate = (node: any) => {
if (node.type === 'element') {
// delete ref
if (node.properties?.ref) delete node.properties.ref;
// handle invalid tag name
if (!/^[a-z][a-z0-9]*$/i.test(node.tagName)) {
node.type = 'text';
node.value = `<${node.tagName}`;
}
// handle properties, filter events
if (node.properties) {
Object.keys(node.properties).forEach((key) => {
const keyLower = key.toLowerCase();
// if event property (on开头)
if (keyLower.startsWith('on')) {
const value = node.properties[key];
// if event value is not a function or contains suspicious content, delete the event
if (
typeof value === 'string' || // delete event handler in string format
value === null ||
value === undefined ||
(typeof value === 'string' &&
(value.includes('javascript:') ||
value.includes('alert') ||
value.includes('eval') ||
value.includes('Function') ||
/[\(\)\[\]\{\}]/.test(value))) // flag for executable code containing parentheses, etc.
) {
delete node.properties[key];
}
}
});
}
}
// recursive handle child nodes
if (node.children) node.children.forEach(iterate);
};
tree.children.forEach(iterate);
};
}
]}
disallowedElements={[
'iframe',
'head',
'html',
'meta',
'link',
'style',
'body',
'embed',
'object',
'param',
'applet',
'area',
'map',
'isindex'
]}
components={components}
urlTransform={urlTransform}
>
{formatSource}
</ReactMarkdown>
</ErrorBoundary>
{isDisabled && <Box position={'absolute'} top={0} right={0} left={0} bottom={0} />}
</Box>
);
};
export default React.memo(Markdown);
/* Custom dom */
function Code(e: any) {
const { className, codeBlock, children } = e;
const match = /language-(\w+)/.exec(className || '');
const codeType = match?.[1]?.toLowerCase();
const strChildren = String(children);
const Component = useMemo(() => {
if (codeType === CodeClassNameEnum.mermaid) {
return <MermaidCodeBlock code={strChildren} />;
@ -134,22 +226,61 @@ function Code(e: any) {
if (codeType === CodeClassNameEnum.audio) {
return <AudioBlock code={strChildren} />;
}
return (
<CodeLight className={className} codeBlock={codeBlock} match={match}>
{children}
</CodeLight>
);
}, [codeType, className, codeBlock, match, children, strChildren]);
return Component;
}
function Image({ src }: { src?: string }) {
return <MdImage src={src} />;
function sanitizeImageSrc(src?: string): string | undefined {
if (!src) return undefined;
// remove leading and trailing spaces
const trimmed = src.trim();
// only allow http/https/data/blob protocols
if (/^(https?:|data:|blob:)/i.test(trimmed)) {
return trimmed;
}
// allow relative paths (not starting with javascript: or vbscript:)
if (
!/^(\w+:)/.test(trimmed) &&
!trimmed.startsWith('javascript:') &&
!trimmed.startsWith('vbscript:')
) {
return trimmed;
}
return undefined;
}
function RewritePre({ children }: any) {
const ALLOWED_IMG_ATTRS = new Set([
'alt',
'width',
'height',
'className',
'style',
'title',
'loading',
'decoding',
'crossOrigin',
'referrerPolicy'
]);
function Image({ src, ...rest }: { src?: string; [key: string]: any }) {
const safeSrc = sanitizeImageSrc(src);
if (!safeSrc) {
console.warn(`Blocked unsafe image src: ${src}`);
}
// only allow whitelist attributes, and remove all on* events
const safeProps = filterSafeProps(rest, ALLOWED_IMG_ATTRS);
return <MdImage src={safeSrc} {...safeProps} />;
}
function RewritePre({ children, ...rest }: any) {
// only allow className, style, etc. safe attributes
const ALLOWED_PRE_ATTRS = new Set(['className', 'style']);
const safeProps = filterSafeProps(rest, ALLOWED_PRE_ATTRS);
const modifiedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// @ts-ignore
@ -157,6 +288,146 @@ function RewritePre({ children }: any) {
}
return child;
});
return <>{modifiedChildren}</>;
return <pre {...safeProps}>{modifiedChildren}</pre>;
}
/**
* general safe attribute filter
* @param props input props object
* @param allowedAttrs allowed attribute name Set
* @param eventTypeCheck whether to check event type (e.g. onClick must be a function)
*/
export function filterSafeProps(
props: Record<string, any>,
allowedAttrs: Set<string>,
eventTypeCheck: boolean = true
) {
// dangerous protocols
const DANGEROUS_PROTOCOLS =
/^(?:\s|&nbsp;|&#160;)*(?:javascript|vbscript|data(?!:(?:image|audio|video)))/i;
// dangerous event properties (including various possible ways)
const DANGEROUS_EVENTS =
/^(?:\s|&nbsp;|&#160;)*(?:on|formaction|data-|\[\[|\{\{|xlink:|href|src|action)/i;
// complete decode function
function fullDecode(input: string): string {
if (!input) return '';
let result = input;
let lastResult = '';
// continue decoding until no more decoding can be done
while (result !== lastResult) {
lastResult = result;
try {
// HTML entity decode
result = result.replace(/&(#?[\w\d]+);/g, (_, entity) => {
try {
const txt = document.createElement('textarea');
txt.innerHTML = `&${entity};`;
return txt.value;
} catch {
return '';
}
});
// Unicode decode (\u0061 format)
result = result.replace(/(?:\\|%5C|%5c)u([0-9a-f]{4})/gi, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
// URL encode decode
result = result.replace(/%([0-9a-f]{2})/gi, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
// octal decode
result = result.replace(/\\([0-7]{3})/gi, (_, oct) =>
String.fromCharCode(parseInt(oct, 8))
);
// hexadecimal decode (\x61 format)
result = result.replace(/(?:\\|%5C|%5c)x([0-9a-f]{2})/gi, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
// handle whitespace and comments
result = result.replace(/(?:\s|\/\*.*?\*\/|<!--.*?-->)+/g, '');
} catch {
break;
}
}
return result.toLowerCase();
}
// check if it contains dangerous content
function containsDangerousContent(value: string): boolean {
const decoded = fullDecode(value);
return (
// check dangerous protocol
DANGEROUS_PROTOCOLS.test(decoded) ||
// check dangerous event
DANGEROUS_EVENTS.test(decoded) ||
// check inline event
/on\w+\s*=/.test(decoded) ||
// check javascript: link
/javascript\s*:/.test(decoded) ||
// check other possible injections
/<\w+/i.test(decoded) ||
/\(\s*\)/i.test(decoded) ||
/\[\s*\]/i.test(decoded) ||
/\{\s*\}/i.test(decoded)
);
}
return Object.fromEntries(
Object.entries(props).filter(([key, value]) => {
// 1. decode and check property name
const decodedKey = fullDecode(key);
// 2. intercept all event related properties
if (DANGEROUS_EVENTS.test(decodedKey)) {
return false;
}
// 3. all properties not in the whitelist are rejected
if (!allowedAttrs.has(key)) {
return false;
}
// 4. check property value
if (typeof value === 'string') {
if (containsDangerousContent(value)) {
return false;
}
} else if (typeof value === 'object' && value !== null) {
// only allow simple style objects
if (key !== 'style') {
return false;
}
// check the value of the style object
for (const styleKey in value) {
if (containsDangerousContent(String(value[styleKey]))) {
return false;
}
}
} else if (typeof value === 'function') {
// only allow specific function properties (e.g. onClick)
if (!eventTypeCheck || decodedKey !== 'onclick') {
return false;
}
}
return true;
})
);
}
const ScriptBlock = memo(({ node }: any) => {
const scriptContent = node.children[0]?.value || '';
return `<script>${scriptContent}</script>`;
});
ScriptBlock.displayName = 'ScriptBlock';

View File

@ -0,0 +1,108 @@
import React from 'react';
import ErrorBoundary from './errorBoundry';
import { filterSafeProps } from './index';
interface SVGProps {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
[key: string]: any;
}
const SVG_ALLOWED_ATTRS = new Set([
'width',
'height',
'viewBox',
'fill',
'stroke',
'd',
'x',
'y',
'cx',
'cy',
'r',
'className',
'style'
]);
const SVGRenderer = ({ children, className, style, ...props }: SVGProps) => {
// filter props
const svgProps = { ...props, className, style };
const sanitizedProps = filterSafeProps(svgProps, SVG_ALLOWED_ATTRS, false);
const sanitizeSVGContent = (content: string | React.ReactNode): string => {
if (typeof content !== 'string') {
return '';
}
let cleaned = content;
cleaned = cleaned.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
cleaned = cleaned.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
cleaned = cleaned.replace(
/<foreignObject\b[^<]*(?:(?!<\/foreignObject>)<[^<]*)*<\/foreignObject>/gi,
''
);
cleaned = cleaned.replace(/\son\w+="[^"]*"/gi, '');
cleaned = cleaned.replace(/\son\w+='[^']*'/gi, '');
cleaned = cleaned.replace(/url\s*\(\s*['"]?\s*javascript:[^)]+\)/gi, '');
cleaned = cleaned.replace(/\bhref="javascript:[^"]*"/gi, '');
cleaned = cleaned.replace(/\bhref='javascript:[^']*'/gi, '');
cleaned = cleaned.replace(/\bxlink:href="javascript:[^"]*"/gi, '');
cleaned = cleaned.replace(/\bxlink:href='javascript:[^']*'/gi, '');
cleaned = cleaned.replace(/\bxmlns(:xlink)?=['"]?javascript:[^"']*['"]?/gi, '');
cleaned = cleaned.replace(/style\s*=\s*(['"])(?:(?!\1).)*javascript:.*?\1/gi, '');
cleaned = cleaned.replace(/\bdata:[^,]*?;base64,[^"')]*["')]/gi, (match) => {
return match.toLowerCase().includes('javascript') ? '' : match;
});
const ALLOWED_ATTRS = new Set([
'width',
'height',
'viewBox',
'fill',
'stroke',
'd',
'x',
'y',
'cx',
'cy',
'r',
'class',
'style'
]);
cleaned = cleaned.replace(/\s(\w+)=['"][^'"]*['"]/gi, (match, attr) => {
return ALLOWED_ATTRS.has(attr.toLowerCase()) ? match : '';
});
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '');
return cleaned;
};
const sanitizedContent = React.Children.map(children, (child) => {
if (typeof child === 'string') {
return sanitizeSVGContent(child);
}
return child;
});
return (
<ErrorBoundary fallback={<div>Something went wrong while rendering Markdown.</div>}>
<svg
{...sanitizedProps}
className={className}
style={style}
dangerouslySetInnerHTML={
typeof children === 'string' ? { __html: sanitizeSVGContent(children) } : undefined
}
>
{typeof children !== 'string' && sanitizedContent}
</svg>
</ErrorBoundary>
);
};
export default SVGRenderer;