文件
hyb-oa/.trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md
T

8.8 KiB
原始文件 Blame 文件历史

采购附件安全设计调研

结论

采购附件的 P0 根因是完整的持久化 active-content 链,而不是 React 常规文本渲染:普通员工可把任意字节、扩展名和 multipart MIME 写入私有存储及数据库;随后授权阅读者取得 OA 同源签名 URL,文件端点又按该 MIME 以 inline 返回。若上传的是 HTML,浏览器会把它作为 OA 同源文档执行。

推荐采用“上传前严格规范化 + 下载时再次验证 + 已认证 Blob 强制下载”的最小方案:只允许 PDF、JPEG、PNG、WEBP;图片由现有 Sharp 解码并重编码;PDF执行严格结构边界检查;文件以随机规范 key 存储;下载不再签发 query bearer URL,也不再打开同源新页,而是由已认证请求取得 application/octet-stream Blob 后触发浏览器下载。

本方案不增加第三方依赖、不搬动或删除历史文件、不把业务 governance_status 当作内容安全凭据,也不需要 Prisma 迁移。所有历史文件在每次下载前都走同一字节验证;验证失败即拒绝返回内容并写安全审计,因此历史 active content 无法进入浏览器同源解释路径。

已确认的代码事实

上传与存储

  • 普通员工默认拥有 procurement.create,本人采购草稿允许上传:server/src/constants/permissions.js:25-29,138-143、server/src/routes/purchaseRequests.js:2200-2228。
  • persistAttachment() 当前只限制空文件和大小,随后直接使用原扩展名与客户端 MIME:server/src/routes/purchaseRequests.js:975-1016。
  • 文件 key 包含清洗后的原文件 basename;S3 ContentType 与数据库 mime_type 都来自 multipart 声明:server/src/routes/purchaseRequests.js:992-1015,2262-2270。
  • request.parts() 循环只保存 filePart,直到循环结束才消费文件流:server/src/routes/purchaseRequests.js:2230-2260。这违背 multipart 迭代时应及时消费文件流的约束,尾随字段或异常 multipart 可能挂起。正确模式可参考 server/src/routes/invoicePool.js:357-408。
  • 当前存储目录不由静态插件暴露;静态插件仅服务前端构建目录:server/src/app.js:181-187。本地存储抽象已有根目录逃逸检查,且同时支持 local 与 s3: key:server/src/services/storage/index.js:21-35,93-119,163-205。

下载与浏览器消费

  • signed URL 在签发时检查采购对象可读性,但最终文件端点只验证 query token、对象 ID、actor ID 与过期时间:server/src/routes/purchaseRequests.js:2417-2536。
  • 文件端点把数据库 MIME 直接传给 reply.type(),并设置 Content-Disposition: inline:server/src/routes/purchaseRequests.js:2538-2609。
  • PC 页面在异步取得同源 URL 后调用 window.open:client/src/pages/PurchaseRequestsPage.tsx:714-726。noopener,noreferrer 不会隔离同源 cookie、storage 或 API 权限。
  • PC 上传 input 没有 accept;移动采购详情目前只展示附件元数据,不提供打开或下载动作:client/src/pages/PurchaseRequestsPage.tsx:1565-1586、client/src/pages/MobilePurchaseRequestDetailPage.tsx:209-220。
  • 前端已有可复用的认证 Blob 下载链:client/src/api/http.ts:183-231、client/src/lib/download.ts:1-11;付款回单已使用该模式。

历史数据与状态

  • PurchaseAttachment 保存路径、原名、客户端 MIME、大小、可空 SHA-256 和业务治理字段,但没有真实内容类型证据:server/prisma/schema.prisma:1192-1221。
  • 历史治理迁移把存量业务治理状态默认成 approved,但未做内容回扫:server/prisma/migrations/20260528162000_business_attachment_governance_v1/migration.sql:1-10。
  • governance_status 不能作为内容安全信任状态:上传 multipart 可提交该业务字段,且服务层会在允许枚举内规范化:server/src/services/businessAttachmentGovernance.js:1-10,59-75,99-110。
  • 现有真实试运行脚本曾上传 .txt / text/plain 采购附件:client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts:1032-1041。因此历史文件不能假设均为安全格式。
  • PurchaseOrderArchive.archived_files 是另一套客户端 JSON 元数据,当前没有对应字节下载端点,不属于本次 P0 链路。

安全与项目约束

  • security-best-practices 的文件上传/静态内容规则要求:服务端 allowlist、内容校验、服务端随机文件名、非静态目录存储,以及不把用户上传的 active content 作为主站同源内容返回。
  • React 文件规则要求客户端 accept 只作为体验提示,安全边界必须在服务端;不应内联渲染 HTML、SVG 或其他 active content。
  • 项目安全规约要求附件权限、下载用途和审计由服务端执行;存储路径不得进入普通响应。
  • 当前任务不得读取真实附件、连接生产数据库、迁移真实数据、部署或修改公网代理;这些动作必须单独批准。

方案比较

方案 A:严格上传 + 每次下载复验 + 认证 Blob 下载(采用)

  • 上传只接受 PDF/JPEG/PNG/WEBP;扩展名、声明 MIME 与字节探测一致。
  • 图片先做 JPEG/PNG/WEBP 固定签名分类,再使用现有 Sharp 按 40_000_000 像素硬上限、严格损坏和单页边界解码/重编码;PDF 使用严格头尾边界检查。
  • 下载前对包括历史文件在内的实际字节再次执行限制与校验,失败时不返回任何文件内容。
  • 删除采购附件 signed URL/query token 流,改为授权 POST 下载 Blob;响应始终 attachment + octet-stream + nosniff。
  • 优点:不迁移历史数据、不增加依赖、不产生安全状态陈旧问题;任何下载都以实际字节为准;变更集中且容易回滚。
  • 代价:每次下载最多读取并校验 10 MiB,图片需解码一次。对于小团队 OA 的附件频率和现有限额可接受。

方案 B:新增内容校验状态并批量扫描历史附件

  • 新增 unverified/validated/quarantined、探测 MIME、版本和原因字段;部署后运行历史扫描。
  • 优点:列表可提前显示持久化安全状态,重复下载可跳过部分校验。
  • 缺点:需要 schema/migration、生产真实附件批量读取和状态写入;状态可能因存储替换或校验器升级而陈旧,仍不能取代下载时安全响应头。
  • 结论:作为附件治理后续能力保留,不作为关闭当前 P0 的前置。

方案 C:保留在线预览并迁移到独立隔离域

  • 在无 OA cookie/localStorage 的专用域提供预览,配合 sandbox、严格 CSP 和私有对象访问。
  • 优点:保留在线预览体验。
  • 缺点:引入新域名、对象授权、代理、CSP、跨域和部署运维面;当前 PC 文案本来就是“下载”,移动端也没有预览承诺。
  • 结论:当前不需要;以后若有明确在线预览业务再独立立项。

采用方案的关键边界

  • 客户端 MIME 永远不是权威值;缺失或 application/octet-stream 可作为未知提示,但最终必须由扩展名和字节内容确认。
  • HTML、XHTML、SVG、XML、JS、CSS、文本、Office、未知二进制、损坏文件、危险双扩展名、类型不一致,以及 PDF/图片前置或尾随的明显 active-content 伪装全部拒绝。
  • 图片上传后的哈希、大小、数据库 MIME 和 S3 ContentType基于规范化后的真实存储字节。
  • PDF 校验只证明基本格式边界,不宣称完成杀毒或内容消毒;它仍只能下载,不允许 OA 内联预览。
  • 业务 governance_status=approved、已有 SHA-256 或安全扩展名均不得绕过下载字节复验;显式 quarantined 记录直接阻断。
  • 历史文件不删除、不改写、不移动;校验失败只逻辑阻断并记录固定原因码,不记录原始内容或完整存储路径。

直接影响的验收脚本

  • client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts 的 text/plain fixture 必须换成内存构造的有效 PDF 或安全图片。
  • client/tests/e2e/r103-cross-page-ui-consistency.spec.ts 应补 accept、安全提示和 browser download 事件;现有伪 PDF 字节 fixture应改为有效最小样本。
  • 服务端当前没有采购附件上传/下载专测,应新增策略单测和路由测试,不复用只覆盖提交、预算或归档的采购测试。

参考规则

  • 前序审查:.trellis/tasks/07-16-system-multidimensional-audit/research/business-security-review.md 的 BSR-SEC-002。
  • 项目规约:.trellis/spec/hyb-oa/security-privacy.md、.trellis/spec/hyb-oa/backend-fastify-prisma.md、.trellis/spec/hyb-oa/frontend-react-vite-design.md。
  • 业务事实:docs/需求-采购费用审批规约.md、docs/产品设计规则.md、docs/测试规约与回归门禁.md。
  • 安全技能规则:文件上传、非静态存储、强制下载、nosniff、React 上传/预览安全边界。