From f424492751f9b5b6e4a627d6866b231ba421a83d Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 17 Jul 2026 20:32:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=87=87=E8=B4=AD=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E5=90=8C=E6=BA=90=20XSS=EF=BC=8807-17-purchase-attach?= =?UTF-8?q?ment-xss-hardening=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/security-privacy.md | 69 +- .../check.jsonl | 5 + .../design.md | 218 +++++ .../implement.jsonl | 5 + .../prd.md | 155 ++++ .../attachment-security-design-research.md | 89 ++ .../task.json | 37 + client/src/api/errorMessages.ts | 46 + client/src/api/purchaseRequests.ts | 9 +- client/src/pages/PurchaseRequestsPage.tsx | 60 +- .../r7e-browser-business-trialrun.spec.ts | 10 +- .../r103-cross-page-ui-consistency.spec.ts | 142 ++- server/src/routes/purchaseRequests.js | 556 ++++++++---- .../src/services/purchaseAttachmentPolicy.js | 450 ++++++++++ .../unit/routes/purchase.attachments.test.js | 814 ++++++++++++++++++ .../services/purchaseAttachmentPolicy.test.js | 545 ++++++++++++ 16 files changed, 2999 insertions(+), 211 deletions(-) create mode 100644 .trellis/tasks/07-17-purchase-attachment-xss-hardening/check.jsonl create mode 100644 .trellis/tasks/07-17-purchase-attachment-xss-hardening/design.md create mode 100644 .trellis/tasks/07-17-purchase-attachment-xss-hardening/implement.jsonl create mode 100644 .trellis/tasks/07-17-purchase-attachment-xss-hardening/prd.md create mode 100644 .trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md create mode 100644 .trellis/tasks/07-17-purchase-attachment-xss-hardening/task.json create mode 100644 server/src/services/purchaseAttachmentPolicy.js create mode 100644 server/tests/unit/routes/purchase.attachments.test.js create mode 100644 server/tests/unit/services/purchaseAttachmentPolicy.test.js diff --git a/.trellis/spec/hyb-oa/security-privacy.md b/.trellis/spec/hyb-oa/security-privacy.md index 990903d9..e6170313 100644 --- a/.trellis/spec/hyb-oa/security-privacy.md +++ b/.trellis/spec/hyb-oa/security-privacy.md @@ -43,6 +43,12 @@ `archiveNote`. - Download APIs may accept `reason`; sensitive or reason-required attachments must reject downloads without a non-empty reason. +- Upload content-policy inputs are `{ buffer, fileName, declaredMimeType, + maxBytes, normalizeImages }`; accepted output must contain canonical + `buffer`, `extension`, `mimeType`, `fileSize` and `fileSha256`. +- Browser downloads of OA-stored attachments must use a currently + authenticated request, preferably an API-local `POST` with optional JSON + `{ "reason": "..." }`, rather than a query bearer URL. ### 3. Contracts @@ -56,6 +62,24 @@ - Audit logs for downloads must include actor, business target, attachment ID, attachment type, sensitivity level, governance status and sanitized reason when provided. +- New uploads must use an allowlist and jointly verify the final extension, + declared MIME and actual bytes before storage. Original names are display + metadata only; storage keys use a server-generated random identifier and a + canonical extension. +- PDF basic validation requires a strict header and terminal EOF boundary. Safe + image formats must first match fixed signature bytes, then decode with Sharp + using `failOn: "warning"`, a fixed pixel ceiling and a one-page constraint; + new images are re-encoded without metadata before size/hash/MIME persistence. +- A governance state, existing hash, extension or stored MIME never proves + historical content safe. Every download must re-authorize the current actor + and business object, read under a hard byte limit and re-run the actual-byte + policy before any response bytes are sent. +- Successful download audit is part of authorization: write it before setting + attachment headers or sending bytes. Audit failure must return a non-success + response with no file body. +- Successful responses use `application/octet-stream`, `attachment`, + `nosniff`, private no-store caching, no-referrer and a route-level sandbox + CSP. Do not restore same-origin inline preview or query-token download routes. ### 4. Validation & Error Matrix @@ -67,6 +91,14 @@ - User lacks business read permission -> `403 PERM_001`. - Download reason longer than the server limit -> trim before writing audit metadata. +- Unsupported/active bytes, extension-MIME-content mismatch, dangerous file + name, corrupt/truncated content, animation/multiple pages or pixel/byte limit + breach -> reject before object-storage and database writes. +- Explicitly quarantined, missing, oversized, corrupt or unverifiable stored + object -> do not return file bytes; write a fixed blocked-reason audit where + possible. +- Successful-download audit write fails -> return 5xx before download headers + or file bytes. ### 5. Good/Base/Bad Cases @@ -75,37 +107,68 @@ - Base: a reimbursement supporting material upload defaults to `internal`, `supporting_material`, `standard` and remains downloadable by authorized participants. +- Good: an allowed image is signature-checked, strictly decoded, normalized, + stored under a random canonical key, then later revalidated and returned only + as a forced binary download. +- Base: an allowed historical PDF remains byte-for-byte unchanged in storage; + every access still revalidates it and forces attachment download. - Bad: a page hides the download button but the backend route still streams the file without permission or reason checks. - Bad: Enterprise WeChat messages or reports include original attachments instead of OA links and desensitized summaries. +- Bad: trust `multipart.mimetype`, reuse an original basename in the storage + path, or return a stored MIME with `inline` from the OA origin. ### 6. Tests Required - Prisma validate after schema or migration changes. - Backend route tests for default governance fields, custom multipart fields when exposed, sensitive-download reason rejection and audit `extra` metadata. +- Content-policy tests for every allowed type plus active/unknown content, + mismatch, truncation, warning-only JPEG corruption, animation/multiple pages, + pixel/byte limits, metadata removal, random keys and safe disposition names. +- Route tests must prove immediate bounded multipart consumption, rejection + before storage/DB writes, current authentication and object authorization, + historical revalidation, blocked-audit reasons, audit-before-send and the + complete forced-download header contract. - Frontend typecheck/build after API type changes. - Focused UI verification for pages that add attachment governance labels or download-reason interactions. +- Frontend download tests must assert authenticated JSON request + Blob browser + download and absence of popup/inline preview. Use only synthetic in-memory + fixtures; production attachments and historical bulk scanning require a + separate approval. ### 7. Wrong vs Correct #### Wrong ```js -return reply.send(stream); +const storageKey = `${businessId}/${originalFileName}`; +await storage.writeBuffer({ key: storageKey, buffer, contentType: clientMime }); +return reply.type(clientMime).header("content-disposition", "inline").send(buffer); ``` #### Correct ```js -const reason = sanitizeDownloadReason(request.query?.reason); +const validated = await validateActualAttachmentBytes({ + buffer, + fileName, + declaredMimeType, + normalizeImages: false, +}); +const reason = sanitizeDownloadReason(request.body?.reason); if (requiresDownloadReason(attachment) && !reason) { throw buildValidationError({ reason: "required" }); } await prisma.auditLog.create({ data: buildDownloadAudit({ attachment, reason }) }); -return reply.send(stream); +return reply + .type("application/octet-stream") + .header("content-disposition", buildSafeAttachmentDisposition(validated.originalName)) + .header("x-content-type-options", "nosniff") + .header("cache-control", "private, no-store") + .send(validated.buffer); ``` ## Scenario: Company Documents And OA Collaboration diff --git a/.trellis/tasks/07-17-purchase-attachment-xss-hardening/check.jsonl b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/check.jsonl new file mode 100644 index 00000000..87232cd4 --- /dev/null +++ b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"检查批准、范围、只读 reviewer、门禁与提交合同。"} +{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"检查后端实现、Prisma 验证和回归覆盖。"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"检查 Blob 下载、前端构建和聚焦 UI 回归。"} +{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"检查文件上传、下载权限、响应头、审计与敏感信息边界。"} +{"file":".trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md","reason":"按原始证据复核 P0 链路是否被完整切断。"} diff --git a/.trellis/tasks/07-17-purchase-attachment-xss-hardening/design.md b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/design.md new file mode 100644 index 00000000..4ccac11d --- /dev/null +++ b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/design.md @@ -0,0 +1,218 @@ +# 采购附件同源 XSS 安全设计 + +## Security Invariants + +1. 未通过服务端实际字节校验的文件不得写入新的采购附件记录。 +2. 客户端 MIME、原文件名、扩展名、业务治理状态和既有 SHA-256 都不是内容安全凭据。 +3. 任一采购附件在返回浏览器前必须重新检查当前用户、当前采购对象、附件归属、用途要求和实际字节。 +4. 浏览器永远只收到强制下载的二进制响应;采购附件不得作为 OA 同源 HTML、SVG、XML、脚本、图片或 PDF 文档内联解释。 +5. 历史附件不删除、不移动、不改写;不安全或不可验证内容只逻辑阻断并写脱敏审计。 +6. 安全版本出错时允许暂停附件能力,不允许回退到 `inline`、客户端 MIME权威或旧 query bearer URL。 + +## Allowed Content Contract + +| 业务格式 | 合法扩展名 | 规范 MIME | 实际内容判定 | 上传规范化 | +| --- | --- | --- | --- | --- | +| PDF | `.pdf` | `application/pdf` | 首字节为 `%PDF-`;版本字符合法;最后非空白内容以 `%%EOF` 结束;无前置/尾随 active 内容 | 保留原字节,不做内容消毒 | +| JPEG | `.jpg` / `.jpeg` | `image/jpeg` | Sharp 探测为 jpeg、单页、像素数不超限,输入以 JPEG EOI 结束 | Sharp 按 JPEG 重编码并移除元数据 | +| PNG | `.png` | `image/png` | Sharp 探测为 png、单页、像素数不超限,IEND 为最终 chunk | Sharp 按 PNG 重编码并移除元数据 | +| WEBP | `.webp` | `image/webp` | Sharp 探测为 webp、单页、像素数不超限,RIFF 声明长度等于实际长度 | Sharp 按 WEBP 重编码并移除元数据 | + +固定策略:输入最大值沿用 `MAX_UPLOAD_SIZE_BYTES`(当前默认 10 MiB);图片最大解码像素常量固定为 `40_000_000`;动画、APNG、animated WEBP 和其他多页输入拒绝。规范化输出再次执行最大字节检查。 + +图片进入 Sharp 之前必须先匹配 JPEG SOI、PNG signature 或 RIFF/WEBP 固定字节;SVG、GIF、XML、HTML 和未知内容不得交给通用图片解码器。Sharp 使用明确的 `limitInputPixels: 40_000_000`、严格损坏处理和动画元数据读取,要求 `pages` 缺失或等于 `1`,再核对 `metadata.format` 与前置签名、扩展名和声明 MIME 一致。 + +声明 MIME处理: + +- `application/pdf`、`image/jpeg`、`image/jpg`、`image/png`、`image/webp` 必须与扩展名和探测结果匹配。 +- 空值或 `application/octet-stream` 只表示客户端未知,仍必须由扩展名和字节结果唯一确认。 +- 任何 active、文本、Office、GIF 或其他 MIME直接拒绝。 + +危险文件名处理: + +- 先移除路径语义并拒绝 `/`、`\\`、NUL、CR/LF 和其他控制字符。 +- 限制显示名长度,同时保留最终规范扩展名。 +- 允许普通版本点号(如 `报价.v2.pdf`),但任一中间扩展名若为 `html/htm/xhtml/svg/xml/js/mjs/cjs/css` 等 active 类型则拒绝。 +- 存储 key 不使用显示名,只使用业务 UUID、随机 UUID 和规范扩展名。 + +## Upload Flow + +1. 检查 multipart、权限、采购对象存在、本人草稿和可编辑状态;失败时尚未读取/写入存储。 +2. 迭代 `request.parts()`: + - 允许的治理字段继续规范化; + - 遇到第一个 `file` part 时立即在硬上限内读取,不把未消费 stream 留到循环结束;即使元数据已可判定非法,也先受限排空或显式销毁该 stream; + - 字段名必须为 `file`;出现第二文件或插件 `FST_FILES_LIMIT` 时稳定返回 `VALID_001`; + - 统一把 `FST_REQ_FILE_TOO_LARGE` 与 `FST_FILES_LIMIT` 映射为 `400 VALID_001`; + - 在内存中完成文件名、扩展名、声明 MIME、字节探测和图片规范化。 +3. 循环结束后确认恰好一份已规范化文件,规范化输出非空且不超限。 +4. 生成 `purchase-requests//.`,用规范 MIME 调用 `writeBuffer()`。 +5. 对最终存储 buffer 计算大小和 SHA-256;数据库只保存规范 MIME、规范显示名和最终摘要。 +6. 在现有事务内创建附件与上传审计;审计不记录原始 payload 或存储绝对路径。 + +路由不得先写原始文件再验证。数据库失败后的存储孤儿清理不是本 P0 根因;若实现中发现可安全复用的删除抽象,可另记后续,不在本轮扩大存储层。 + +## Download API + +### 新接口 + +```text +POST /api/purchase-requests/:id/attachments/:attachmentId/download +Authorization: Bearer +Content-Type: application/json + +{ "reason": "可选;敏感附件必填" } +``` + +请求 body 使用严格 schema,只允许可选且限长的 `reason`。响应是二进制 Blob,不返回 JSON signed URL。 + +前端采购 API helper 必须显式传入 `headers: { "content-type": "application/json" }` 和 `JSON.stringify(...)`;不修改共享 `apiRequestBlob` 的默认行为,避免扩大其他 Blob 调用的回归面。 + +### 退役接口 + +```text +POST /api/purchase-requests/:id/attachments/:attachmentId/signed-url +GET /api/purchase-requests/:id/attachments/:attachmentId/file?token=... +``` + +二者从应用路由和前端 API中移除。已经签发的旧 URL 在新版本上不能取得文件;这属于安全失效,不做兼容回放。 + +### 下载处理顺序 + +1. `fastify.authenticate` 验证当前 access token。 +2. 执行采购读取权限和 `loadReadablePurchaseRequest()` 对象级范围检查。 +3. 查询附件并要求 `purchase_request_id` 匹配;读取业务治理字段、存储路径、文件名、MIME 和记录大小。 +4. 规范化 JSON body 中的用途;按既有 `requiresDownloadReason()` 执行。 +5. 若业务治理状态为 `quarantined`,立即写阻断审计并返回 `409 VALID_001`。 +6. 记录大小必须在硬上限内;使用现有 storage stream 逐块读取并再次执行实际上限,不能只信数据库大小。实际对象一旦超限,立即销毁 local/S3 stream 并拒绝,不能调用现有无上限 `readFileBuffer()`。 +7. 使用与上传相同的格式策略验证实际字节。历史 active/错配/损坏内容不进入响应;存储暂时错误保持 fail closed,不把瞬时错误误报为安全通过。 +8. 成功写 `PURCHASE_ATTACHMENT_DOWNLOADED` 审计,随后设置固定响应头并发送 buffer;审计写入失败必须 fail closed,不得发送未留痕文件。 + +## Response Header Contract + +所有成功下载固定为: + +```text +Content-Type: application/octet-stream +Content-Disposition: attachment; filename=""; filename*=UTF-8'' +X-Content-Type-Options: nosniff +Cache-Control: private, no-store, max-age=0 +Pragma: no-cache +Referrer-Policy: no-referrer +Content-Security-Policy: sandbox; default-src 'none'; base-uri 'none'; form-action 'none' +Cross-Origin-Resource-Policy: same-origin +``` + +`Content-Disposition` 由集中 helper 生成:ASCII fallback 只含安全字符;UTF-8 名称按 RFC 5987 编码。不得直接插值数据库原名。 + +即使代理或浏览器行为异常,`octet-stream + attachment + nosniff + sandbox` 仍提供多层同源 active-content 防线。 + +## Historical Attachment Strategy + +- 不新增 schema 字段,不批量读取或回填真实记录。 +- 每个历史下载请求都验证实际字节,因此不存在“旧记录因 `approved` 或旧 SHA-256 而自动可信”的路径。 +- 合法历史 PDF/JPEG/PNG/WEBP:验证通过后按安全响应头下载,存储原件保持字节不变。 +- 明确 active MIME/扩展名:可在读取前快速阻断;最终决策仍以实际内容策略为准。 +- 错配、损坏、超限或缺失:不发送文件;返回自然中文隔离/缺失提示并写固定原因码审计。 +- 显式业务 `quarantined`:前端禁用,服务端强制拒绝;`approved` 仅表示业务治理通过,不表示内容验证通过。 +- 后续若附件量导致每次复验成本不可接受,再以独立任务设计持久化 `content_validation_version/status` 和受控重扫;该优化不能弱化下载响应头。 + +## Error And Audit Matrix + +| 场景 | HTTP / Error | 存储/DB | 审计 | +| --- | --- | --- | --- | +| 非 multipart / 无文件 / 错字段 / 第二文件 | `400 VALID_001` | 不写 | 普通请求错误日志,不含 payload | +| 空文件 / 输入或规范化输出超限 | `400 VALID_001` | 不写 | 不记录内容 | +| 不允许扩展名/MIME/危险双扩展名 | `400 VALID_001` | 不写 | 可记录固定拒绝类别 | +| 字节类型错配、损坏、像素/页数超限 | `400 VALID_001` | 不写 | 可记录固定拒绝类别 | +| 无上传/读取权限 | `403 PERM_001` | 不写 | 沿用权限审计策略 | +| 非本人草稿或不可编辑状态 | `403` / `409 VALID_001` | 不写 | 沿用业务审计 | +| 下载缺用途 | `400 VALID_001` | 不读文件 | 沿用用途策略 | +| 显式隔离或历史内容校验失败 | `409 VALID_001` | 不改/不删 | `PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED` + 固定 reason code | +| 存储对象不存在 | `404` | 不改/不删 | 可记录 `missing`,不写路径 | +| 存储暂时故障 | `5xx` fail closed | 不改/不删 | 安全标量错误,不序列化 provider 原始对象 | +| 成功下载审计写入失败 | `5xx` fail closed | 不改/不删 | 不发送文件;日志只含安全标量,不序列化原始 DB/provider error | +| 成功下载 | `200` 二进制 | 不改 | 现有 actor、业务对象、附件 ID、类型、敏感级别、用途 | + +固定内容拒绝码建议:`active_type`、`unsupported_type`、`type_mismatch`、`dangerous_filename`、`corrupt_content`、`pixel_limit`、`multiple_pages`、`size_limit`、`missing_object`。响应只映射成用户可理解文案,不暴露探测细节。 + +## Frontend Flow + +1. 文件 input 增加: + +```text +accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp" +``` + +2. 选择后仍可立即上传,但旁边固定显示允许格式;服务端拒绝时保留当前采购草稿和其他表单内容。 +3. 下载先走现有用途弹窗;随后 `apiRequestBlob(POST .../download)`。 +4. 对详情 DTO 中的原名再次移除控制字符/路径分隔符并限长,再调用 `downloadBlob(response.blob, safeAttachmentName)` 触发 ``;不调用 `window.open`。HTTP `Content-Disposition` 仍保留安全 ASCII fallback 与 UTF-8 `filename*`,供非前端调用者使用。 +5. `governanceStatus === "quarantined"` 时显示“已隔离”并禁用下载;服务器仍是最终边界。 +6. 移动详情维持只读元数据,不增加下载/预览;本任务不做移动页面重构。 + +## Rollout + +1. 本地实现、聚焦测试、完整门禁和只读安全复核完成后形成一个采购附件安全提交。 +2. 部署必须另行批准;部署前核对数据库/存储备份、实际 storage driver 和对象存储私有性。 +3. 前后端同批切换;上线前必须另行批准、配置并验证代理对采购附件上传、旧 signed URL 和旧文件端点的精确拒绝规则。没有该外部门禁时禁止部署。 +4. 发布后用合成 PDF/图片和无害 active-content fixture 做授权隔离 smoke;不得使用真实附件或把测试 payload 留在生产。 +5. 只有部署来源 commit、公网响应头、角色权限和下载事件都通过后,才能告知用户线上可测。 + +## Rollback + +- 没有 schema 或历史数据写入需要回滚;新文件 key 与旧存储抽象兼容。 +- 校验误判时只回滚/调整格式策略和前端提示,保留认证下载、强制 attachment、安全 MIME 与响应头。 +- 若新版本不可用,优先向前修复;如确需恢复旧应用,只有在已经验证的代理拒绝规则持续关闭上传、signed URL 和旧 file 端点时才可操作。旧版不能重新提供 `text/html + inline`。 +- 不通过删除历史文件、重置数据库、改写 migration 或公开 S3对象来恢复使用。 + +## Test Matrix + +### 策略单测 + +- 合法最小 PDF、JPEG、PNG、WEBP。 +- HTML/SVG/XML/JS/文本/未知二进制。 +- `.html.pdf`、`.pdf.html`、普通 `.v2.pdf`。 +- HTML 伪装 PDF;PNG 字节配 JPEG/PDF MIME或扩展名。 +- 截断 PDF/PNG/JPEG、PDF 前置/尾随 active 内容。 +- 图片尾随标记在规范化结果中消失;元数据不保留。 +- 超像素、多页/动画、输入/输出超字节上限。 +- APNG、animated WEBP、伪造巨大尺寸头,以及 Sharp 可解析但 allowlist 禁止的 SVG。 +- 中文、长名、CRLF、引号、路径分隔符和 Content-Disposition。 + +### 路由测试 + +- multipart 文件在治理字段之前与之后均完成,不挂起。 +- 第二文件、错误字段、拒绝 MIME、空文件、非 multipart、插件大小/文件数错误;用超时断言证明流被消费/销毁且请求正常结束。 +- 无权限、他人草稿、非草稿均不调用存储或附件 create。 +- 新 key 不含 basename;DB/S3 MIME、大小、hash 对应规范化 buffer。 +- 新认证下载重新检查对象权限与用途;旧 signed/file 路由不可用。 +- 前端采购 API helper 的真实请求带 JSON Content-Type/body,敏感附件用途能被 Fastify 解析。 +- 历史合法、active、伪装、损坏、显式 quarantined、缺失、超限。 +- 成功响应每个安全头与 `Content-Disposition`;任何路径均无 `inline`。 +- 阻断/成功审计不包含原始 payload、完整路径或 token。 +- 审计写入异常时不返回成功或文件内容;数据库/存储原始错误对象不进入日志。 +- 数据库记录大小小于实际对象大小时,实际流上限仍阻断并销毁 stream。 + +### 前端/E2E + +- `accept` 与格式说明可见。 +- 直接构造恶意 multipart 被拒绝且附件列表不新增。 +- 点击下载产生 Playwright download event,不产生新 page/popup。 +- `reason_required` 弹窗取消、缺失与成功路径。 +- 显式隔离附件禁用下载。 +- 移动详情继续只显示名称、大小、上传人和治理标签。 +- R103 和 R7E 使用合成有效 PDF/图片 fixture。 + +## Expected File Scope + +- `server/src/services/purchaseAttachmentPolicy.js`(新增) +- `server/src/routes/purchaseRequests.js` +- `server/tests/unit/services/purchaseAttachmentPolicy.test.js`(新增) +- `server/tests/unit/routes/purchase.attachments.test.js`(新增) +- `client/src/api/purchaseRequests.ts` +- `client/src/pages/PurchaseRequestsPage.tsx` +- `client/src/api/errorMessages.ts` +- `client/tests/e2e/r103-cross-page-ui-consistency.spec.ts` +- `client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts` +- 当前 Trellis task 文件 + +若实现证明无需修改某文件,则不为满足清单而制造改动。不得修改或提交 `.trellis/tasks/07-16-system-multidimensional-audit/`。 diff --git a/.trellis/tasks/07-17-purchase-attachment-xss-hardening/implement.jsonl b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/implement.jsonl new file mode 100644 index 00000000..21ae202a --- /dev/null +++ b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"遵循 Trellis 实施、批准、范围、回滚和提交边界。"} +{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"约束 Fastify 路由、Prisma 无漂移验证和后端测试。"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"保持现有 React/Vite 下载交互与前端验证合同。"} +{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"执行业务附件权限、下载审计、私有存储和脱敏边界。"} +{"file":".trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md","reason":"提供已确认 XSS 链路、方案比较、历史兼容和复用证据。"} diff --git a/.trellis/tasks/07-17-purchase-attachment-xss-hardening/prd.md b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/prd.md new file mode 100644 index 00000000..acb83f21 --- /dev/null +++ b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/prd.md @@ -0,0 +1,155 @@ +# 采购附件同源 XSS 安全收口 + +## Goal + +关闭采购附件从普通员工上传到审批角色打开之间的同源存储型 XSS 链,并把采购附件固定为“严格格式入库、服务端授权、浏览器强制下载”的安全模式;兼容现有 local/S3 存储和历史记录,不删除、不搬动、不改写真实附件。 + +## Scope + +- 采购附件 multipart 读取、大小限制、文件名、扩展名、声明 MIME、字节内容校验、图片规范化、存储 key、哈希与数据库 MIME。 +- 采购附件下载权限、下载用途、历史字节复验、响应头和审计。 +- 退役采购附件 query bearer signed URL,改为当前会话已认证的 Blob 下载。 +- PC 采购详情的文件选择提示、格式 accept、下载行为和安全错误文案。 +- 采购附件策略/路由单测、前端聚焦 E2E、受影响的 R7E 采购附件 fixture 和项目门禁。 + +## Requirements + +### 1. 允许格式与真实内容校验 + +- 采购附件只允许 PDF、JPEG/JPG、PNG、WEBP;不允许 HTML、XHTML、SVG、XML、JavaScript、CSS、纯文本、Office、GIF 或未知二进制。 +- 服务端同时检查最终扩展名、客户端声明 MIME 与实际字节内容;客户端 `accept` 仅用于文件选择体验,不能作为安全边界。 +- MIME 为空或 `application/octet-stream` 时可继续按扩展名和字节探测;其他不在 allowlist 的声明 MIME直接拒绝。 +- 拒绝类型错配、损坏/截断内容、危险双扩展名、路径型文件名、控制字符,以及 PDF/图片前置或尾随的明显 active-content 伪装。 +- PDF 必须从首字节开始具有合法 PDF 头,且最后非空白内容以 EOF 标记结束;不把该检查描述为杀毒或 PDF 内容消毒。 +- 图片必须先通过 JPEG/PNG/WEBP 固定签名字节分类,active/未知格式不得进入 Sharp;随后由 Sharp 以 `40_000_000` 像素硬上限、严格损坏策略和单页约束解码并按真实格式重新编码;规范化后再次执行大小上限,去除元数据和尾随内容。 +- multipart 文件流必须在 `request.parts()` 迭代到该 part 时立即受限消费;即使字段名、文件名或声明 MIME 已知非法,也必须先安全排空或显式销毁当前流再返回。第二个文件、错误字段、插件文件数上限和文件过大都返回稳定的 `VALID_001`,不得挂起或退化为 500。 + +### 2. 安全命名与私有存储 + +- 原文件名只作为经规范化、限长的显示名,不得参与存储路径拼接。 +- 新存储 key 固定为 `purchase-requests//.`;数据库与对象存储记录服务端探测后的规范 MIME。 +- SHA-256、`file_size`、S3 ContentType 和数据库 MIME均基于最终实际存储的规范化字节。 +- 继续使用现有私有 local/S3 抽象和根目录逃逸校验,不新增静态目录、不公开对象 ACL。 + +### 3. 下载必须重新授权并强制 attachment + +- 删除采购附件 `/signed-url` 与 `GET .../file?token=...` 链路;新下载使用当前 access token 的已认证 POST Blob 请求,并在下载时重新执行采购对象读取权限和附件归属校验。 +- 下载用途放在 JSON body,不再进入 query string;采购 API helper 必须显式发送 `Content-Type: application/json`,因为现有 `apiRequestBlob` 不会自动为字符串 body 补该 header。`reason_required`、保密或受限附件继续要求非空用途并写审计。 +- 所有成功响应固定使用 `Content-Type: application/octet-stream`、`Content-Disposition: attachment`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, no-store`、`Referrer-Policy: no-referrer`,并增加无脚本能力的 route-level sandbox CSP 作为纵深防护。 +- 服务端生成 RFC 安全的下载文件名;不得把控制字符、路径分隔符或未转义原名写入响应头。 +- PC 端通过现有 `apiRequestBlob` 与 `downloadBlob` 触发浏览器下载,不使用 `window.open`,不创建 OA 同源预览页。 +- 成功下载审计必须在发送文件前写入;审计失败时返回 5xx 且不得发送任何文件字节。 + +### 4. 历史附件兼容与逻辑隔离 + +- 现有附件记录、业务治理状态、存储路径和文件字节全部保留;本 task 不做物理删除、移动、批量重写或生产扫描。 +- 每次下载都在实际返回前,以硬大小上限读取并复用同一内容策略验证历史字节;`governance_status=approved`、安全扩展名或已有 SHA-256 均不得跳过验证。 +- 显式 `governance_status=quarantined` 的附件直接拒绝;其他历史附件若为 HTML/SVG/XML/文本、类型错配、损坏、缺失或超限,均不得返回文件内容。 +- 内容校验失败写固定原因码安全审计并返回可理解的隔离提示;不得记录原文件内容、完整存储路径或原始 payload。 +- 合法历史 PDF/JPEG/PNG/WEBP 通过字节验证后仍只能作为二进制附件下载,绝不恢复同源内联预览。 +- 若后续需要批量扫描、持久化内容状态或物理隔离目录,另建任务并在读取/写入真实数据前取得明确批准。 + +### 5. 前端体验 + +- PC 材料区明确说明“支持 PDF、JPG、PNG、WEBP;下载会留痕,OA 内不直接预览”。 +- 文件 input 同时声明允许扩展名和 MIME;用户绕过 `accept` 直接构造 multipart 时仍由服务端拒绝。 +- 保留现有用途填写弹窗和“下载”动作;显式隔离附件禁用下载,历史内容校验失败显示“附件未通过安全校验,请重新上传允许格式或联系管理员”。 +- 移动采购详情继续只展示安全元数据,不新增 iframe/object/embed、Blob 预览或在线 active-content 能力。 + +## Acceptance Criteria + +- [x] 合法 PDF、JPEG/JPG、PNG、WEBP 上传成功;保存的 key 随机且不含原 basename,MIME/大小/SHA-256 对应规范化字节。 +- [x] HTML、SVG、XML、JS、文本、未知格式、危险双扩展名、伪造 MIME、扩展名错配、损坏图片和截断 PDF均在写存储/建 DB 行前拒绝。 +- [x] 图片像素炸弹、多页/动画输入、规范化后超限和 multipart 第二文件均 fail closed,不挂起、不返回 500。 +- [x] 普通员工只能给本人草稿上传;无权限、非本人或非草稿请求不写存储、不建附件行。 +- [x] 采购附件不再签发 query bearer URL;旧签名 URL 无法取得文件,新下载必须携带当前认证并重新校验读取权限。 +- [x] 下载用途不出现在 URL;敏感附件缺用途仍返回 `400 VALID_001`,成功下载保留既有审计语义。 +- [x] 前端 Blob helper 实际发送 JSON header/body;敏感附件携用途时可下载,审计写入失败时响应无文件内容且非成功状态。 +- [x] 所有成功下载均为 `octet-stream + attachment + nosniff + no-store + no-referrer + sandbox`,响应中不存在 `inline`。 +- [x] 历史 HTML/SVG/文本、伪装类型、损坏或缺失文件均不返回内容;合法历史 PDF/图片只强制下载;任何历史文件都不被删除、移动或改写。 +- [x] CRLF、引号、路径分隔符、中文和超长原名不能注入响应头或逃逸存储根。 +- [x] PC 端显示允许格式,点击下载触发 browser download 而非新页面;移动详情不新增预览能力。 +- [x] R7E text/plain 失真 fixture 已替换为有效安全样本;新增服务端策略/路由测试和前端聚焦测试通过。 +- [x] Prisma validate(确认无 schema 漂移)、服务端聚焦测试、完整服务端测试、前端 typecheck/build、Trellis governance 和 `git diff --check` 通过。 +- [x] 独立只读 reviewer 复核上传、下载、历史兼容、权限、响应头和回滚,不修改代码。 + +## Definition of Done + +- 普通员工上传的 active content 无法入库;已有 active content 无法作为 OA 同源响应返回。 +- 采购附件只存在“授权后二进制下载”路径,不存在在线预览、query bearer URL 或客户端 MIME 直达响应头的路径。 +- 历史数据保持原样,异常文件以逻辑阻断和审计处理;无生产连接、真实附件扫描、部署或公网配置修改。 +- 自动化覆盖直接 multipart 绕过、历史恶意内容和浏览器下载行为,提交使用中文并引用 `07-17-purchase-attachment-xss-hardening`。 + +## Technical Approach + +- 新增采购附件安全策略服务,使用 Node 内置字节检查与现有 Sharp;不增加 `file-type`、PDF 解析器或杀毒依赖。 +- 路由在 multipart 迭代中立即读入并规范化单个文件;写存储前完成全部校验。 +- 历史下载使用受限缓冲读取和同一策略复验;成功后返回安全二进制,失败时只写脱敏审计并拒绝。 +- 前后端共同移除 signed URL/window.open,复用现有认证 Blob 下载组件。 +- 不改 Prisma schema;业务治理状态仅用于人工隔离,不作为内容已安全的证明。 + +## Decision (ADR-lite) + +**Context**:只把 `inline` 改成 `attachment` 可以立即切断同源执行,但仍会允许任意 active content 入库;只加 MIME allowlist 又会被伪造客户端 MIME绕过。新增持久化内容状态和批量历史扫描会引入生产数据迁移,却不能取代实际下载响应边界。 + +**Decision**:采用“双重实际字节校验 + 认证强制下载”。上传前严格规范化,下载前对新旧文件都复验;签名 URL/query token 退役;历史文件不迁移、不删除。显式业务隔离状态可提前阻断,但任何业务状态都不能绕过内容校验。 + +**Consequences**:文本、Office、GIF 和旧 `.txt` 附件不再可下载,需要转为 PDF 或安全图片重新上传;每次下载最多增加一次受限字节读取和图片解码。换来无需生产数据迁移、无状态陈旧、对历史伪装文件同样 fail closed 的边界。 + +## Implementation Plan + +1. 后端策略:新增文件名/扩展名/MIME/字节校验、PDF 边界检查、Sharp 规范化和安全 Content-Disposition 工具及单测。 +2. 上传路由:在 multipart 迭代中立即消费,先校验再写入;改用随机规范 key和服务端探测 MIME,补上传路由测试。 +3. 下载路由:以认证 POST Blob 替代签名 URL,重新校验权限、用途和历史内容,固定安全响应头与审计,补历史/权限/响应头测试。 +4. 前端与验收:增加 `accept`/说明,改用 Blob 下载,更新错误映射、R103 与 R7E fixture;不改移动预览能力。 +5. 收口:执行全量相关门禁,启动不改代码的独立安全 reviewer,按结论修正后提交;部署与真实数据操作另行申请。 + +## Test Plan + +- 策略单测:四类合法格式;HTML/SVG/XML/JS/文本;类型错配;危险双扩展名;截断/损坏;图片尾随标记清除;PDF 前后 active content;APNG/animated WEBP;伪造大尺寸头;像素/页数/大小;文件名和 Content-Disposition。 +- 后端路由:multipart 文件在字段前/后、第二文件、错误字段、拒绝 MIME、空/超限和请求超时守卫;权限/草稿边界;随机 key、规范 MIME/hash;认证下载、JSON用途、历史恶意/合法/缺失对象、实际对象大于记录大小、显式隔离、审计失败和全部响应头。 +- 前端聚焦:input `accept` 与中文说明;服务端拒绝提示;download event 且无 popup;用途弹窗;隔离按钮;移动端只显示元数据。 +- 受影响 fixture:R103 与手工 R7E 采购附件改用内存有效 PDF/图片,不读取真实附件。 +- 静态/项目门禁:`cd server && npm run prisma:validate`;相关 `node --check`;服务端聚焦 Vitest 和完整 `npm test`;`cd client && npm run typecheck && npm run build`;聚焦 Playwright;`bash scripts/check-minimal.sh`;`node scripts/qa/check-trellis-governance.mjs`;`git diff --check`。 +- 不运行生产数据库、真实附件、企业微信、真实付款、部署或公网写入。 + +## Verification Evidence + +- `node --check`:采购策略、采购路由及对应两份单测均通过。 +- 聚焦 Vitest:策略 39/39、采购附件路由 26/26,合计 65/65 通过。 +- `bash scripts/check-minimal.sh`:受控本地权限下 7 段连续通过;服务端 157 个测试文件、896/896,通过 Prisma validate、前端 typecheck/build、治理、敏感内容与特性矩阵。 +- 聚焦 Playwright:R103 采购附件认证 JSON POST、Blob download、无 popup、隔离禁用和自然中文错误共 3/3 通过。 +- Trellis task validate、process baseline、Trellis governance 与 `git diff --check` 均通过。 +- 首轮沙箱门禁仅有临时 Git 状态与回环端口两项环境失败;受控权限隔离复跑 12/12 后,完整统一门禁连续 PASS。 +- 独立只读安全 reviewer 收口无剩余 P0/P1;Trellis 质量检查补齐超长用途截断和路径型 multipart 拒绝两项 P2,未改 schema、生产数据或部署配置。 +- 未连接生产/本地业务数据库,未读取真实附件,未运行 R7E live 写入,未部署、push 或修改公网代理。 + +## Rollout And Rollback + +- 当前任务只实现和本地验证,不部署。上线前必须单独批准部署、数据库/存储备份和公网检查。 +- 前后端需同批发布,因为旧前端会调用被退役的 signed URL;切换窗口可暂时阻断采购附件上传与下载。 +- 部署前必须另行批准并验证边缘对采购附件上传、旧 signed URL 和旧 file 端点的精确拒绝规则;该门禁未满足时禁止部署。安全版本失败时优先向前修复,不能把脆弱旧二进制直接恢复为对外服务。 +- 无 schema/data migration,历史路径和字节不变;local/S3 均继续使用现有存储抽象。 +- 若格式策略出现兼容误判,可只调整 allowlist/校验实现;`attachment + octet-stream + nosniff`、重新授权和禁止同源预览不得回退。 +- 若必须回滚到旧应用版本,代理层必须继续阻断采购附件上传、signed URL 和 file 端点,直到安全版本恢复;不得让旧 `text/html + inline` 路径重新对外。 +- 历史批量扫描、物理隔离或生产状态写入均不在本 task 自动执行,需另行授权。 + +## Out of Scope + +- 全站 CSP enforce、Trusted Types、独立附件预览域、在线 PDF/图片预览。 +- 杀毒、恶意 PDF CDR、Office 转码、对象存储 ACL/MinIO 运维重构。 +- 报销附件、付款回单、员工合同、发票 signed URL 或其他模块的附件统一平台化。 +- 采购材料类型选择器、治理字段交互或附件信息架构重构;本 P0 只保留现有材料区并增加允许格式/下载边界。 +- 生产数据库/存储扫描、物理删除、移动、批量治理状态回填、真实附件修复。 +- 公网部署、反向代理修改、真实账号/附件验证、企业微信写入或付款动作。 + +## Research References + +- [`research/attachment-security-design-research.md`](research/attachment-security-design-research.md) — 当前代码链路、历史兼容、方案比较和复用点。 +- `.trellis/tasks/07-16-system-multidimensional-audit/research/business-security-review.md` — `BSR-SEC-002` 前序只读审查证据。 + +## Technical Notes + +- 当前复杂度为中高:后端上传/下载、历史兼容、前端下载和自动化跨层,但不涉及权限模型重构或生产迁移。 +- task priority 已从生成时的 `P2` 修正为已确认的 `P0`。 +- 本地实现、独立只读复核和统一门禁已完成;未读取真实附件、未连接任何业务数据库、未部署或修改公网配置。 diff --git a/.trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md new file mode 100644 index 00000000..9df5c892 --- /dev/null +++ b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md @@ -0,0 +1,89 @@ +# 采购附件安全设计调研 + +## 结论 + +采购附件的 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 上传/预览安全边界。 diff --git a/.trellis/tasks/07-17-purchase-attachment-xss-hardening/task.json b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/task.json new file mode 100644 index 00000000..1ee088f7 --- /dev/null +++ b/.trellis/tasks/07-17-purchase-attachment-xss-hardening/task.json @@ -0,0 +1,37 @@ +{ + "id": "purchase-attachment-xss-hardening", + "name": "purchase-attachment-xss-hardening", + "title": "采购附件同源 XSS 安全收口", + "description": "关闭采购附件任意格式上传、同源 signed URL 与 inline 响应形成的存储型 XSS,改为真实内容校验、私有随机存储和已认证强制下载。", + "status": "in_progress", + "dev_type": null, + "scope": "security/purchase-attachment-xss", + "package": null, + "priority": "P0", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-07-17", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + ".trellis/tasks/07-16-system-multidimensional-audit/research/business-security-review.md", + ".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", + "server/src/routes/purchaseRequests.js", + "server/src/services/storage/index.js", + "client/src/api/purchaseRequests.ts", + "client/src/pages/PurchaseRequestsPage.tsx" + ], + "notes": "P0 本地实施与质量门禁已完成:采购附件改为严格实际字节校验、随机私有存储、认证强制下载和历史复验;独立只读 reviewer 无剩余 P0/P1,统一门禁 PASS。待按 Phase 3.4 提交计划确认后提交;未连接业务数据库、读取真实附件、部署、push 或修改公网配置。", + "meta": {} +} diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 0ee4d34e..9dda200d 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -333,6 +333,52 @@ export function mapApiErrorMessage( return "请假冻结账本状态异常,请联系管理员处理。"; } + if (details?.attachment === "security_validation_failed") { + if (details?.reason === "quarantined") { + return "附件已隔离,无法下载,请联系管理员。"; + } + if (details?.reason === "missing_object") { + return "附件文件不存在或已缺失,请联系管理员。"; + } + return "附件未通过安全校验,请重新上传允许格式或联系管理员。"; + } + + if (details?.file === "empty_file") { + return "附件内容为空,请重新选择文件。"; + } + + if (details?.file === "size_limit" || details?.file === "file_too_large") { + return "附件超过大小限制,请压缩后重新上传。"; + } + + if (details?.file === "dangerous_filename") { + return "附件名称不安全,请重命名后重新上传。"; + } + + if (details?.file === "unsupported_type") { + return "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。"; + } + + if (details?.file === "type_mismatch") { + return "附件扩展名、声明类型与实际内容不一致,请重新选择原始文件或转换为支持格式。"; + } + + if (details?.file === "corrupt_content") { + return "附件已损坏或内容不完整,请重新导出后上传。"; + } + + if (details?.file === "pixel_limit") { + return "图片尺寸过大,请压缩后重新上传。"; + } + + if (details?.file === "multiple_pages") { + return "暂不支持多页或动画图片,请转换为 PDF 或单页安全图片。"; + } + + if (details?.file === "only_single_file_supported") { + return "一次只能上传一个附件,请重新选择。"; + } + if (details?.file === "required") { return "当前假种要求上传附件后才能提交。"; } diff --git a/client/src/api/purchaseRequests.ts b/client/src/api/purchaseRequests.ts index 5a6496a3..86a1ae75 100644 --- a/client/src/api/purchaseRequests.ts +++ b/client/src/api/purchaseRequests.ts @@ -1,4 +1,4 @@ -import { apiRequestData } from "./http"; +import { apiRequestBlob, apiRequestData } from "./http"; import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import type { PrecheckResponse, RiskFlag } from "./feeControl"; @@ -386,11 +386,12 @@ export async function deletePurchaseAttachment(purchaseRequestId: string, attach ); } -export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string, reason?: string) { - return apiRequestData<{ signedUrl: string; expiresAt: string }>( - `/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`, +export async function downloadPurchaseAttachment(purchaseRequestId: string, attachmentId: string, reason?: string) { + return apiRequestBlob( + `/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/download`, { method: "POST", + headers: { "content-type": "application/json" }, body: JSON.stringify(reason ? { reason } : {}), }, ); diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index 170e4b26..9f3d8e98 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -8,10 +8,10 @@ import { archivePurchaseOrder, createPurchaseRequest, deletePurchaseAttachment, + downloadPurchaseAttachment, fetchPurchaseOrderArchive, fetchPurchaseRequestDetail, fetchPurchaseRequests, - issuePurchaseAttachmentSignedUrl, precheckPurchaseRequest, submitPurchaseRequest, updatePurchaseRequest, @@ -54,6 +54,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat"; +import { downloadBlob } from "../lib/download"; import { buildPrecheckDialogMessage } from "../lib/precheck"; import { formatApprovalHistoryTime, @@ -65,6 +66,7 @@ import { } from "../lib/approvalDisplay"; const PAGE_SIZE = 20; +const MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH = 180; type DraftItem = { id: string; @@ -77,7 +79,7 @@ type DraftItem = { function resolveError(error: unknown) { if (isApiError(error)) { - return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + return mapApiErrorMessage(error.code, error.details, error.message); } if (error instanceof Error) { return error.message; @@ -85,6 +87,26 @@ function resolveError(error: unknown) { return "请求失败,请稍后重试。"; } +function sanitizePurchaseAttachmentDownloadName(value?: string) { + const cleaned = (value ?? "") + .normalize("NFC") + .replace(/[\u0000-\u001f\u007f-\u009f/\\]+/g, "_") + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]+/g, "_") + .replace(/\s+/g, " ") + .replace(/^[\s._]+|[\s._]+$/g, "") + .trim(); + const fallback = cleaned || "采购附件"; + const characters = Array.from(fallback); + if (characters.length <= MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH) { + return fallback; + } + + const extension = fallback.match(/\.[a-z0-9]{1,10}$/i)?.[0] ?? ""; + const stem = extension ? fallback.slice(0, -extension.length) : fallback; + const stemLength = Math.max(1, MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH - Array.from(extension).length); + return `${Array.from(stem).slice(0, stemLength).join("")}${extension}`; +} + function moneyPreview(quantity: string, unitPrice: string) { const quantityNumber = Number(quantity); const unitPriceNumber = Number(unitPrice); @@ -717,13 +739,17 @@ export function PurchaseRequestsPage() { if (!selectedId) { return; } + if (attachment.governanceStatus === "quarantined") { + toast.error("附件已隔离,无法下载,请联系管理员。"); + return; + } const reason = await requestDownloadReason(attachment); if (reason === null) { return; } try { - const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason); - window.open(response.signedUrl, "_blank", "noopener,noreferrer"); + const response = await downloadPurchaseAttachment(selectedId, attachment.id, reason); + downloadBlob(response.blob, sanitizePurchaseAttachmentDownloadName(attachment.originalName)); } catch (error) { const message = resolveError(error); setFormError(message); @@ -1566,7 +1592,9 @@ export function PurchaseRequestsPage() {

材料证据

-

用于报价、订单、验收或情况说明;草稿状态可增删,下载会留痕。

+

+ 用于报价、订单、验收或情况说明;支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。 +

{ const file = event.target.files?.[0]; @@ -1624,7 +1654,15 @@ export function PurchaseRequestsPage() {
-