mirror of
https://github.com/molvqingtai/WebChat.git
synced 2025-11-25 19:27:34 +08:00
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>
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
"comctx": "^1.4.3",
|
||||
"danmu": "^0.18.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"framer-motion": "^12.23.22",
|
||||
"hash-it": "^6.0.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -89,6 +89,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
dompurify:
|
||||
specifier: ^3.2.7
|
||||
version: 3.2.7
|
||||
framer-motion:
|
||||
specifier: ^12.23.22
|
||||
version: 12.23.22(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
@@ -1701,6 +1704,9 @@ packages:
|
||||
'@types/react@19.1.16':
|
||||
resolution: {integrity: sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@@ -2459,6 +2465,9 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
@@ -7262,6 +7271,9 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -8064,6 +8076,10 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
|
||||
@@ -22,9 +22,9 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
|
||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||
data={children}
|
||||
customScrollParent={scrollParentRef!}
|
||||
computeItemKey={(index, item) => item.props.data.id}
|
||||
computeItemKey={(_, item) => item.props.data.id}
|
||||
skipAnimationFrameInResizeObserver
|
||||
itemContent={(_: any, item: ReactElement<MessageItemProps | PromptItemProps>) => item}
|
||||
itemContent={(_, item: ReactElement<MessageItemProps | PromptItemProps>) => item}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type FC } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { cn } from '@/utils'
|
||||
import { cn, safeUrl } from '@/utils'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
|
||||
export interface MarkdownProps {
|
||||
@@ -10,35 +10,12 @@ export interface MarkdownProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const safeProtocol = /^(https?|ircs?|mailto|xmpp|data)$/i
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
// Same as:
|
||||
// <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
|
||||
// But without the `encode` part.
|
||||
const colon = value.indexOf(':')
|
||||
const questionMark = value.indexOf('?')
|
||||
const numberSign = value.indexOf('#')
|
||||
const slash = value.indexOf('/')
|
||||
|
||||
if (
|
||||
// If there is no protocol, it’s relative.
|
||||
colon < 0 ||
|
||||
// If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
|
||||
(slash > -1 && colon > slash) ||
|
||||
(questionMark > -1 && colon > questionMark) ||
|
||||
(numberSign > -1 && colon > numberSign) ||
|
||||
// It is a protocol, it should be allowed.
|
||||
safeProtocol.test(value.slice(0, colon))
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
const urlTransform = (value: string) => safeUrl(value)
|
||||
|
||||
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||
return (
|
||||
@@ -62,14 +39,21 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||
<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, ...props }) => (
|
||||
<a
|
||||
className={cn('text-blue-500', className)}
|
||||
target={props.href || '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
{...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} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
import { cn, safeUrl } from "@/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
@@ -21,12 +21,17 @@ function Avatar({
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
src,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
// Sanitize image URL to prevent XSS attacks
|
||||
const safeSrc = src ? safeUrl(src) : undefined
|
||||
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
src={safeSrc}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import type { SiteInfo } from '@/utils/getSiteInfo'
|
||||
import getSiteInfo from '@/utils/getSiteInfo'
|
||||
import {
|
||||
WorldRoomSendType,
|
||||
|
||||
@@ -19,3 +19,4 @@ export { getTextByteSize } from './getTextByteSize'
|
||||
export { default as isEqual } from './isEqual'
|
||||
export { default as setIntervalImmediate } from './setIntervalImmediate'
|
||||
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'
|
||||
export { safeUrl } from './safeUrl'
|
||||
|
||||
16
src/utils/safeUrl.ts
Normal file
16
src/utils/safeUrl.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Sanitize URL to prevent XSS attacks
|
||||
* @param url - The URL to sanitize
|
||||
* @returns Sanitized URL or empty string if invalid
|
||||
*/
|
||||
export const safeUrl = (url: string): string => {
|
||||
if (!url || typeof url !== 'string' || !URL.canParse(url)) return ''
|
||||
|
||||
// Only allow media data URIs (image/video/audio)
|
||||
if (url.startsWith('data:')) return /^data:(image|video|audio)\//i.test(url) ? url : ''
|
||||
|
||||
// Block dangerous protocols
|
||||
if (/^(javascript|vbscript|file|about):/i.test(url)) return ''
|
||||
|
||||
return url
|
||||
}
|
||||
Reference in New Issue
Block a user