diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba66e7e4c..84c99298f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/projects/app/next.config.js b/projects/app/next.config.js index 558cfff7f..a82cead0f 100644 --- a/projects/app/next.config.js +++ b/projects/app/next.config.js @@ -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 diff --git a/projects/app/package.json b/projects/app/package.json index d0bb7b2d5..8f70c2e20 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -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", diff --git a/projects/app/src/components/Markdown/errorBoundry.tsx b/projects/app/src/components/Markdown/errorBoundry.tsx new file mode 100644 index 000000000..15005aa12 --- /dev/null +++ b/projects/app/src/components/Markdown/errorBoundry.tsx @@ -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 { + 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 ||
Something went wrong while rendering Markdown.
; + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx index 5445a8168..418f64a0b 100644 --- a/projects/app/src/components/Markdown/index.tsx +++ b/projects/app/src/components/Markdown/index.tsx @@ -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 ( + + {props.children || 'Download'} + + ); +}; type Props = { source?: string; @@ -36,11 +69,9 @@ type Props = { } & AProps; const Markdown = (props: Props) => { const source = props.source || ''; - if (source.length < 200000) { return ; } - return {source}; }; 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) => ( - ) }; }, [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 ( - - {formatSource} - + + { + 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} + + {isDisabled && } ); }; - 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 ; @@ -134,22 +226,61 @@ function Code(e: any) { if (codeType === CodeClassNameEnum.audio) { return ; } - return ( {children} ); }, [codeType, className, codeBlock, match, children, strChildren]); - return Component; } -function Image({ src }: { src?: string }) { - return ; +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 ; +} + +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
{modifiedChildren}
; } + +/** + * 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, + allowedAttrs: Set, + eventTypeCheck: boolean = true +) { + // dangerous protocols + const DANGEROUS_PROTOCOLS = + /^(?:\s| | )*(?:javascript|vbscript|data(?!:(?:image|audio|video)))/i; + + // dangerous event properties (including various possible ways) + const DANGEROUS_EVENTS = + /^(?:\s| | )*(?: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 ``; +}); +ScriptBlock.displayName = 'ScriptBlock'; diff --git a/projects/app/src/components/Markdown/markdowSVG.tsx b/projects/app/src/components/Markdown/markdowSVG.tsx new file mode 100644 index 000000000..a220fe10a --- /dev/null +++ b/projects/app/src/components/Markdown/markdowSVG.tsx @@ -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>/gi, ''); + cleaned = cleaned.replace(/)<[^<]*)*<\/style>/gi, ''); + cleaned = cleaned.replace( + /)<[^<]*)*<\/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(//g, ''); + + return cleaned; + }; + + const sanitizedContent = React.Children.map(children, (child) => { + if (typeof child === 'string') { + return sanitizeSVGContent(child); + } + return child; + }); + + return ( + Something went wrong while rendering Markdown.}> + + {typeof children !== 'string' && sanitizedContent} + + + ); +}; + +export default SVGRenderer;