diff --git a/docSite/content/zh-cn/docs/development/openapi/dataset.md b/docSite/content/zh-cn/docs/development/openapi/dataset.md index d9d5f0d181d1..934617ebf951 100644 --- a/docSite/content/zh-cn/docs/development/openapi/dataset.md +++ b/docSite/content/zh-cn/docs/development/openapi/dataset.md @@ -645,7 +645,7 @@ data 为集合的 ID。 {{< /tab >}} {{< /tabs >}} -### 创建一个外部文件库集合(商业版) +### 创建一个外部文件库集合(弃用) {{< tabs tabTotal="3" >}} {{< tab tabName="请求示例" >}} diff --git a/projects/app/next.config.js b/projects/app/next.config.js index a82cead0f06f..5875e1708169 100644 --- a/projects/app/next.config.js +++ b/projects/app/next.config.js @@ -1,6 +1,7 @@ const { i18n } = require('./next-i18next.config.js'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const isDev = process.env.NODE_ENV === 'development'; @@ -14,51 +15,50 @@ const nextConfig = { headers: async () => { const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); - const csp = `'nonce-${nonce}'`; + const csp_nonce = `'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(' '); + + const SENTRY_DOMAINS = '*.sentry.io'; + const GOOGLE_DOMAINS = 'https://www.googletagmanager.com https://www.google-analytics.com'; + const LOCALHOST = 'http://localhost:* http://127.0.0.1:*'; + const OTHER_DOMAINS = 'https://api.example.com'; + + const csp = [ + `default-src 'self' ${scheme_source} ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST}`, + `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${csp_nonce} ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST}`, + `style-src 'self' 'unsafe-inline' ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST}`, + `img-src data: blob: ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST} *`, + `connect-src 'self' wss: https: ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST}`, + `font-src 'self'`, + `media-src 'self' ${scheme_source} ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST}`, + `worker-src 'self' ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS} ${LOCALHOST} ${scheme_source}`, + `object-src 'none'`, + `form-action 'self'`, + `base-uri 'self'`, + `frame-src 'self' ${SENTRY_DOMAINS} ${GOOGLE_DOMAINS} ${OTHER_DOMAINS}`, + `sandbox allow-scripts allow-same-origin allow-popups allow-forms`, + `upgrade-insecure-requests` + ].join('; '); return [ { - source: '/chat/(.*)', + source: '/(.*)', 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('; ') - } + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload' + }, + { key: 'Content-Security-Policy', value: csp } ] } ]; }, - webpack(config, { isServer, nextRuntime }) { + + webpack: (config, { isServer, nextRuntime }) => { Object.assign(config.resolve.alias, { '@mongodb-js/zstd': false, '@aws-sdk/credential-providers': false, diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx index 418f64a0bdb2..84e1b248b848 100644 --- a/projects/app/src/components/Markdown/index.tsx +++ b/projects/app/src/components/Markdown/index.tsx @@ -10,7 +10,7 @@ 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 { CodeClassNameEnum, mdTextFormat, filterSafeProps } from './utils'; import ErrorBoundary from './errorBoundry'; import SVGRenderer from './markdowSVG'; import { useCreation } from 'ahooks'; @@ -37,15 +37,7 @@ 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 ALLOWED_A_ATTRS = new Set(['href']); const safeProps = filterSafeProps(props, ALLOWED_A_ATTRS); return ( @@ -130,30 +122,27 @@ const MarkdownRender = ({ node.type = 'text'; node.value = `<${node.tagName}`; } - - // handle properties, filter events + // use filterSafeProps to filter component properties 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]; - } - } - }); + const ALLOWED_ATTRS = new Set([ + 'title', + 'alt', + 'src', + 'href', + 'target', + 'rel', + 'width', + 'height', + 'align', + 'valign', + 'type', + 'lang', + 'value', + 'name' + ]); + + // use filterSafeProps to filter properties + node.properties = filterSafeProps(node.properties, ALLOWED_ATTRS); } } @@ -254,6 +243,11 @@ function sanitizeImageSrc(src?: string): string | undefined { return undefined; } +function Image({ src }: { src?: string }) { + const safeSrc = sanitizeImageSrc(src); + return ; +} + const ALLOWED_IMG_ATTRS = new Set([ 'alt', 'width', diff --git a/projects/app/src/components/Markdown/markdowSVG.tsx b/projects/app/src/components/Markdown/markdowSVG.tsx index a220fe10a106..1335b7357347 100644 --- a/projects/app/src/components/Markdown/markdowSVG.tsx +++ b/projects/app/src/components/Markdown/markdowSVG.tsx @@ -1,6 +1,6 @@ import React from 'react'; import ErrorBoundary from './errorBoundry'; -import { filterSafeProps } from './index'; +import { filterSafeProps } from './utils'; interface SVGProps { children?: React.ReactNode; @@ -26,80 +26,30 @@ const SVG_ALLOWED_ATTRS = new Set([ ]); const SVGRenderer = ({ children, className, style, ...props }: SVGProps) => { - // filter props - const svgProps = { ...props, className, style }; - const sanitizedProps = filterSafeProps(svgProps, SVG_ALLOWED_ATTRS, false); + const sanitizedProps = filterSafeProps({ ...props, className, style }, SVG_ALLOWED_ATTRS); const sanitizeSVGContent = (content: string | React.ReactNode): string => { - if (typeof content !== 'string') { - return ''; - } + 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; + return content + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + .replace(/)<[^<]*)*<\/foreignObject>/gi, '') + .replace(//g, ''); }; - const sanitizedContent = React.Children.map(children, (child) => { - if (typeof child === 'string') { - return sanitizeSVGContent(child); - } - return child; - }); - return ( - Something went wrong while rendering Markdown.}> + SVG rendering error}> - {typeof children !== 'string' && sanitizedContent} + {typeof children !== 'string' && + React.Children.map(children, (child) => + typeof child === 'string' ? sanitizeSVGContent(child) : child + )} ); diff --git a/projects/app/src/components/Markdown/utils.ts b/projects/app/src/components/Markdown/utils.ts index d274be4ac5a6..c06c8bef5ba8 100644 --- a/projects/app/src/components/Markdown/utils.ts +++ b/projects/app/src/components/Markdown/utils.ts @@ -41,3 +41,176 @@ export const mdTextFormat = (text: string) => { return text; }; + +/** + * general safe attribute filter + * @param props input props object + * @param allowedAttrs allowed attribute name Set + */ +export function filterSafeProps(props: Record, allowedAttrs: Set) { + // 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; + + // 可疑内容模式 + const SUSPICIOUS_CONTENT = { + javascript: /javascript:/i, + alert: /alert\s*\(/i, + eval: /eval\s*\(/i, + function: /Function\s*\(/i, + executable: /[\(\)\[\]\{\}]/ + }; + + // complete decode function + function fullDecode(input: string): string { + if (!input) return ''; + + let result = input; + let lastResult = ''; + let iterations = 0; + const MAX_ITERATIONS = 5; // 防止无限循环 + + // continue decoding until no more decoding can be done or max iterations reached + while (result !== lastResult && iterations < MAX_ITERATIONS) { + lastResult = result; + iterations++; + 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 { + if (!value) return false; + + 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 + SUSPICIOUS_CONTENT.javascript.test(decoded) || + // check alert + SUSPICIOUS_CONTENT.alert.test(decoded) || + // check eval + SUSPICIOUS_CONTENT.eval.test(decoded) || + // check Function constructor + SUSPICIOUS_CONTENT.function.test(decoded) || + // check other possible injections + /<\w+/i.test(decoded) || + /\(\s*\)/i.test(decoded) || + /\[\s*\]/i.test(decoded) || + /\{\s*\}/i.test(decoded) + ); + } + + // filter props + const filteredProps = { ...props }; + + // 1. filter out all properties not in the whitelist + Object.keys(filteredProps).forEach((key) => { + // properties not in the whitelist are deleted directly + if (!allowedAttrs.has(key)) { + delete filteredProps[key]; + return; + } + + // check if the property name has danger + const keyLower = key.toLowerCase(); + const decodedKey = fullDecode(key); + + // 过滤所有事件处理属性 (on开头),不再保留onClick + if (keyLower.startsWith('on')) { + delete filteredProps[key]; + return; + } + + // 危险的事件属性 + if (DANGEROUS_EVENTS.test(decodedKey)) { + delete filteredProps[key]; + return; + } + + // 检查属性值 + const value = filteredProps[key]; + + // 字符串类型值检查 + if (typeof value === 'string') { + if (containsDangerousContent(value)) { + delete filteredProps[key]; + return; + } + } + // 对象类型值检查 + else if (typeof value === 'object' && value !== null) { + // 只允许style对象 + if (key !== 'style') { + delete filteredProps[key]; + return; + } + + // 检查style对象的所有值 + const styleProps = { ...value }; + let hasDangerousStyle = false; + + Object.keys(styleProps).forEach((styleKey) => { + const styleValue = String(styleProps[styleKey]); + if (containsDangerousContent(styleValue)) { + hasDangerousStyle = true; + } + }); + + if (hasDangerousStyle) { + delete filteredProps[key]; + return; + } + } + }); + + return filteredProps; +}