Files
WebChat/src/components/markdown.tsx
molvqingtai f03a679478 perf: add URL sanitization to prevent XSS attacks
- Create safeUrl utility function to filter malicious URLs
- Allow safe protocols: http/https/mailto/xmpp/data:image/video/audio
- Block dangerous protocols: javascript/vbscript/file/about
- Apply safeUrl to Markdown links and Avatar images
- Auto-render image URLs as <img> in Markdown
- Add skipAnimationFrameInResizeObserver for better performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 02:14:00 +08:00

120 lines
4.6 KiB
TypeScript

import { type FC } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { cn, safeUrl } from '@/utils'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
export interface MarkdownProps {
children?: string
className?: string
}
/**
* Sanitize URL to prevent XSS attacks
* Supports http/https URLs, data URLs (for images), mailto, xmpp, and relative URLs
* https://github.com/remarkjs/react-markdown/blob/baad6c53764e34c4ead41e2eaba176acfc87538a/lib/index.js#L293
*/
const urlTransform = (value: string) => safeUrl(value)
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
return (
<div className={cn(className, 'prose prose-sm prose-slate break-words dark:text-slate-50')}>
<ReactMarkdown
urlTransform={urlTransform}
components={{
h1: ({ className, ...props }) => (
<h1 className={cn('my-2 mt-0 font-semibold text-2xl dark:text-slate-50', className)} {...props} />
),
h2: ({ className, ...props }) => (
<h2 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
),
h3: ({ className, ...props }) => (
<h3 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
),
h4: ({ className, ...props }) => (
<h4 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
),
img: ({ className, alt, ...props }) => (
<img className={cn('my-2 max-w-[100%] rounded', className)} alt={alt} {...props} />
),
strong: ({ className, ...props }) => <strong className={cn('dark:text-slate-50', className)} {...props} />,
a: ({ className, href, ...props }) => {
// Check if link is an image URL
const isImage = href && /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i.test(href)
return isImage ? (
<img src={href} alt="" className={cn('my-2 max-w-[100%] rounded', className)} />
) : (
<a
className={cn('text-blue-500', className)}
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
/>
)
},
ul: ({ className, ...props }) => {
Reflect.deleteProperty(props, 'ordered')
return <ul className={cn('text-sm [&:not([depth="0"])]:my-0 ', className)} {...props} />
},
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
table: ({ className, ...props }) => (
<div className="my-2 w-full">
<ScrollArea scrollLock={false}>
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
),
tr: ({ className, ...props }) => {
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
},
th: ({ className, ...props }) => {
return (
<th
className={cn(
'border px-3 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
)
},
td: ({ className, ...props }) => {
return (
<td
className={cn(
'border px-3 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
)
},
pre: ({ className, ...props }) => <pre className={cn('my-2', className)} {...props} />,
/**
* TODO: Code highlight
* @see https://github.com/remarkjs/react-markdown/issues/680
* @see https://shiki.style/guide/install#usage
*
*/
code: ({ className, ...props }) => (
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
<code className={cn('text-sm', className)} {...props}></code>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{children}
</ReactMarkdown>
</div>
)
}
Markdown.displayName = 'Markdown'
export { Markdown }