8.8 KiB
8.8 KiB
采购附件安全设计调研
结论
采购附件的 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 上传/预览安全边界。