项目:react-ui | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
组件库不是业务系统,「安全」在这里不表现为认证鉴权与越权控制,而收敛为四个可审计的切面:XSS 防护面(哪些代码会把字符串变成 HTML、信任边界画在哪)、依赖供应链(第三方依赖与补丁的治理)、发布完整性(发出去的产物如何被证明与源码一致、可被消费),以及工程门禁的准入控制(谁能把什么带进代码库——即这个仓库意义上的「权限」)。本篇逐一走读这四个切面,最后汇总当前已知的覆盖缺口。已确立的发布流程与门禁清单见《06-部署运行》,本篇只谈其中的安全属性。
对整个 packages/ui/src 与 packages/hooks/src 检索 dangerouslySetInnerHTML,命中恰好四处,这就是本库完整的「字符串 → HTML」攻击面。逐个核对注入内容与信任边界:
| 注入点 | 文件 | 注入内容 | 内容来源 | 信任边界 |
|---|---|---|---|---|
| CSS 变量注入 | packages/ui/src/core/UIProvider/UICssVariables/UICssVariables.tsx(26 行) | cssVariablesResolver(theme) 产出的 CSS 文本,写入 <style data-ui-styles="css-variables"> | 主题对象 | 开发者传入的 theme 键值 |
| 响应式样式注入 | packages/ui/src/core/InlineStyles/InlineStyles.tsx(21 行) | stylesToString(props) 序列化的样式与媒体查询,写入 <style data-ui-styles="inline"> | Box 的 style props | 开发者传入的样式对象 |
| 色彩方案脚本 | packages/ui/src/core/UIProvider/ColorSchemeScript/ColorSchemeScript.tsx(37 行) | getScript({ defaultColorScheme, localStorageKey }) 生成的静态内联脚本 | 仅两个 props 拼接 | 组件自身,无用户输入 |
| 代码高亮 | packages/ui/src/components/CodeHighlight/CodeHighlight/CodeHighlight.tsx | highlightedCode.highlightedCode HTML 字符串 | 用户传入的 code 经 adapter 高亮 | 见下文单独分析 |
前三处的共同点是:内容全部来自开发者侧配置(主题对象、样式对象、组件常量),不经过任何终端用户输入,序列化器(core/InlineStyles/styles-to-string、css-object-to-string)把 JS 对象映射为 CSS 属性值而非标签结构,不存在把「值」升格为「标签」的通道。唯一的注意项是这些 <style>/<script> 均携带 nonce={nonce?.()}(UIProvider 的 getStyleNonce 下发,InlineStyles 的 JSDoc 注释明确写了「自动添加 nonce 属性用于 CSP 安全策略」),在启用 CSP 的站点上是必需的配套——消费者若配置了严格 CSP,必须记得给 UIProvider 传 getStyleNonce,否则动态样式会被浏览器静默拦截(表现为主题不生效而非报错,见《12-FAQ与故障排查》)。
第四处 CodeHighlight 是唯一真正处理「任意文本」的组件,值得单独走读。其机制(packages/ui/src/components/CodeHighlight/CodeHighlightProvider/CodeHighlightProvider.tsx)是:CodeHighlightAdapterProvider 接收一个 CodeHighlightAdapter,契约为 loadContext?(): Promise<any> + getHighlighter(ctx): Highlighter;Highlighter 的返回类型把责任写得非常直白——highlightedCode: string(HTML 标记)+ isHighlighted: boolean(该字符串是否为 HTML)。组件侧(CodeHighlight.tsx)据此分流:高亮成功时以 { dangerouslySetInnerHTML: { __html: highlightedCode.highlightedCode } } 渲染;未高亮时回退为 { children: code.trim() },即普通 React 文本子节点,天然转义。默认 adapter 是 plain-text-adapter(同目录 adapters/plain-text-adapter.ts),isHighlighted 恒为 false——所以默认路径零 innerHTML,用户代码永远走文本通道。风险被精确限定在「消费者主动接入第三方高亮 adapter(如 shiki)」的场景:此时 HTML 由高亮器产出,信任边界在「adapter 的实现者保证高亮输出已转义」。这是文档化责任而非代码可强制的约束,CodeHighlightAdapter 类型注释把 highlightedCode 标注为「Highlighted code (html markup)」即是这道契约的落点。建议(见第 6 节):文档站接入示例 adapter 时补一句「不得对不可信输入启用高亮 adapter」的红字说明。
还有一个相邻面值得记录:packages/ui/src/emotion/ssr.tsx 引入了 html-react-parser(packages/ui/package.json dependencies 中的 ^5.2.17)用于服务端关键样式抽取。该库把 HTML 字符串解析为 React 元素,输入同样是 emotion 自产样式而非用户内容,风险评级为低,但它是全仓唯一引入「HTML 解析」能力的依赖,升级时应在 CHANGELOG 里关注其安全通告。
权限语境的映射:如果消费方把组件用于「用户生成内容」(UGC)场景,本库的立场是清晰的——文本类组件(Text、Title、Markdown 式排版族 TypographyStylesProvider)一律按 React 子节点渲染、自动转义;没有任何组件接受原始 HTML prop(检索不到 html 类 prop);CodeHighlight 的 HTML 通道只在 adapter 层打开且默认关闭。UGC 站点若必须渲染富文本,需要业务侧自行引入 sanitize 层,这与 antd/Mantine 的边界一致。
pnpm-lock.yaml 锁定全量依赖图,CI 安装一律 pnpm install --frozen-lockfile(.github/workflows/ci.yml 的「安装依赖」步骤),本地与 CI 的依赖树因此逐字节一致。根 package.json 的 pnpm.overrides 把 react/react-dom/@types/react/@types/react-dom 钉死在 19.2.7/19.2.3——即使某个传递依赖声明了更宽的 React 范围,解析结果也唯一,这同时消除了「多 React 实例」这一经典运行时故障源(双实例会让 Context 断裂,useUIContext 直接抛错)。
patches/dumi@2.4.47.patch 经根 package.json 的 pnpm.patchedDependencies 自动应用,是全仓唯一的第三方补丁。它把文档站引擎 dumi 2.4.47 的上游行为修正收敛为一份可审计、可版本化的 diff:补丁内容进 lockfile 哈希,任何人 pnpm install 后得到的是同一个修过的 dumi,而不是「记得手动 patch」的口头约定。治理纪律上可核对的事实有两条:补丁数量保持最少(1 个),且与构建产物的发布无关——files: ["es"] 意味着补丁只影响文档站,不进入 npm 用户的依赖树。
@xiaoye-react/ui 的 20 个 dependencies 全部是纯函数库或 React 生态库(floating-ui、tanstack-virtual、dayjs、rrule、emotion 五件套、react-number-format、react-remove-scroll、react-dropzone、clsx、type-fest、chroma-js、html-react-parser、klona、fast-deep-equal、csstype、@standard-schema/spec),没有引入任何带 Node 侧文件系统/网络能力的包;hooks 包则零依赖。构建侧的关键决策是外部化:packages/ui/vite.config.ts 的 isExternal 按「包名段」匹配 dependencies + peer + react,注释给出了安全与体积双重理由——依赖打进产物等于下游装两份实例,而 dayjs 的 extend/locale 与 @emotion/cache 的插入点都是全局状态,双实例即行为错乱。外部化同时把供应链责任一分为二:库为「自己声明的依赖版本区间」负责,消费方打包器为「最终进包的实际版本」负责,安全通告的响应路径(升级依赖、重发补丁版)也因此清晰。
「发出去的东西 = 源码里验证过的东西」由四道自动闸与一条消费者侧验证环构成,全部可定位到脚本级:
| 闸 | 实现文件 | 校验内容 | 安全属性 |
|---|---|---|---|
| dts 发射失败即构建失败 | packages/ui/vite.config.ts 的 afterDiagnostic | 类型错误时声明产物不完整(对应文件被静默跳过、下游 import 变 TS2307),直接抛异常 | 防止「半份类型」带病发布 |
check:published-types | packages/ui/package.json 的 build 后置脚本,tsc --noEmit --skipLibCheck false ... es/index.d.ts | 对产物 d.ts 以最严格配置独立再编译 | 发布面类型可被陌生消费方编译 |
check:exports | scripts/validate-package-exports.mjs(112 行) | exports/main/module/types 指向真实文件;通配符导出展开核对「只有 .d.ts 没有 .js」的孤儿文件;导出 CSS 却声明 sideEffects: false 的矛盾 | 防「类型解析通过、运行时 404」的供应链投毒式缺陷 |
| 消费者侧 ESM 复验 | scripts/release.mjs 的 consumerVerify() | 在 /tmp/verify 全新目录 npm 安装发布版本,纯 Node ESM 具名导入 ui/hooks/pro 三包 | 唯一能暴露 CJS 互操作回归的盲区补光 |
release.mjs 自身也有两处安全设计值得点名:其一,--otp 参数正则限定为 6–8 位数字才允许拼进 shell 命令(validateArgs() 注释明言「OTP 会原样拼进 shell 命令,只接受数字」),阻断了参数注入;其二,注册表核对用 npm view --prefer-online 强制回源并重试 12 次——npm view 会吃本地缓存,不复位就永远读到旧 packument,「发布成功」的判定必须基于权威源。预检(preflight)则校验工作树干净、在 main、与 origin/main 对齐、以及 ui 的 peer 下限能被本次 hooks 版本满足(satisfiesCaret 只认 ^x.y.z、不跨 major,无法判断的形态显式返回 null「交给人」)——这防的是「peer 指向注册表上不存在的版本」导致的下游解析污染,属于对下游供应链的主动保护。回滚窗口(npm 72 小时内 unpublish、之后只能 deprecate)与版本策略见《06-部署运行》第 4 节。
除了 HTML 注入,组件库语境还有三个小切面,核对结果如下。URL/链接类 props(Anchor、NavLink、Breadcrumbs 的 href):一律落到 React 的 href 属性,javascript: URL 的拦截责任在业务侧(React 本身会告警,无库级额外过滤 ⚠️ 待确认是否有计划加拦截)。localStorage:hooks 包的 use-local-storage/use-session-storage 与 UIProvider 的色彩方案持久化(键 ui-color-scheme-value)读写的是纯字符串状态,不存敏感信息;ColorSchemeScript 生成的内联脚本读取该键时只做字符串比较,注入路径为属性赋值而非 HTML 拼接。受控属性透传:filterProps(core/utils/filter-props)与 ElementProps 类型约定保证库自定义 props(mod、__vars 等)不会泄漏进真实 DOM,反之 DOM 属性透传给到开发者手上——不拦截也不放大。
这个仓库把「谁能改什么」做成了机器强制的门禁,语义上就是一套权限系统:提交者权限受 commitlint(commitlint.config.cjs,config-conventional + cz-git 交互生成)约束提交格式;拼写受 cspell(cspell.json 自定义词典 .cspell/custom-words.txt)约束——新术语必须显式入典,防止 typo 顺手混入标识符;lint 警告总量受 --max-warnings=57 棘轮约束,豁免必须留注释(CONTRIBUTING「债务政策」:不留注释的豁免会被清掉);发布权限受 npm scope 2FA(npm login --scope=@xiaoye-react --auth-type=interactive)与 release.mjs 三档命令约束。单人仓的背景下这套门禁的真实对象是「未来的自己与 AI 协作者」——CONTRIBUTING 开篇原话——它的安全价值在于把每一次入库都变成可审计事件(conventional 提交 + 与 CI 完全一致的检查序列)。
按「位置 / 证据 / 影响」汇总当前可核对的安全相关缺口,逐条建议并入第 13 篇台账:
packages/ui/src/components/CodeHighlight/CodeHighlightProvider/CodeHighlightProvider.tsx。证据:CodeHighlightAdapter 的 ctx 类型为 any,HTML 责任仅由注释承担,无 sanitize 钩子。影响:接入劣质 adapter 时 XSS 责任完全落在消费方。建议:文档给出「adapter 输出视为可信 HTML」红字契约,或在 adapter 接口上提供可选的 transformHtml 后处理钩子;eslint.config.mjs rules 段。证据:@typescript-eslint/no-explicit-any、no-unsafe-function-type、no-empty-object-type 等显式 off。影响:any 类型的入库无门禁(如 adapter ctx、若干 options 形态),类型层漏洞不会被 lint 提前暴露。建议:至少对 packages/*/src 开启 no-explicit-any 的 warn 并纳入棘轮;eslint.config.mjs ignores 数组(packages/ui/src/components/Menubar/**/*、Tree/**/*)。影响:这两个目录的 hooks 规则与未使用变量完全失察。建议:补齐修复后移出忽略名单(与 hooks 规则修复的既往路径一致——eslint 注释记录了 145 → 78 → 57 的治理史);packages/*/package.json 的 prepublishOnly(仅 build;ui 额外跑 check:exports)。影响:绕过 release.mjs 直接 pnpm publish 时测试不拦。缓解:release.mjs 在排练与正式发布两个模式都显式执行包级测试(runPackageTests),根脚本 publish:* 链也含 test——缺口只在「手改流程」场景;把第 1~5 节压缩成一个可带走的威胁模型:库信任开发者、不信任终端用户输入——所有接受「任意文本」的通道(子节点渲染、props 透传)都走 React 的默认转义,所有「字符串变结构化 HTML」的通道(innerHTML 四点、html-react-parser)要么内容自产(主题、样式、脚本常量)、要么把责任显式移交给 adapter 实现者并默认关闭。因此库层面没有「数据被注入」的一等风险,消费侧的真实风险集中在两处:把用户输入喂给高亮 adapter,以及在 CSP 严格的站点忘配 nonce。后者的配置速记:UIProvider 传 getStyleNonce={() => nonce},库内全部动态样式(InlineStyles、UICssVariables、emotion 注入)都会自动携带;SSR 场景的 ColorSchemeScript 生成的是静态脚本,内容与 nonce 无关,但 <script> 标签本身也要进 CSP 的 script-src 允许集(内联脚本需配 hash 或 nonce,随站点 CSP 策略形态而定 ⚠️ 待确认各框架的具体装配方式)。
小结:本库的安全模型是「默认转义、显式开洞、开洞处类型注释即契约」——innerHTML 面收敛到 4 处且 3 处为开发者侧输入,供应链靠锁定 + 最少补丁 + 外部化三分治理,发布完整性有四道闸一验证环。剩下的工作是把「注释承担的契约」逐步升级为「门禁强制的约束」,这也是第 13 篇演进建议的安全主线。