修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)

这个提交包含在:
2026-07-17 20:32:05 +08:00
父节点 4c5eb7e22e
当前提交 f424492751
共修改 16 个文件,包含 2999 行新增和 211 行删除
+66 -3
查看文件
@@ -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
@@ -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 链路是否被完整切断。"}
@@ -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/<purchase-id>/<uuid>.<ext>`,用规范 MIME 调用 `writeBuffer()`。
5. 对最终存储 buffer 计算大小和 SHA-256;数据库只保存规范 MIME、规范显示名和最终摘要。
6. 在现有事务内创建附件与上传审计;审计不记录原始 payload 或存储绝对路径。
路由不得先写原始文件再验证。数据库失败后的存储孤儿清理不是本 P0 根因;若实现中发现可安全复用的删除抽象,可另记后续,不在本轮扩大存储层。
## Download API
### 新接口
```text
POST /api/purchase-requests/:id/attachments/:attachmentId/download
Authorization: Bearer <current access token>
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="<ascii-fallback>"; filename*=UTF-8''<encoded-name>
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)` 触发 `<a download>`;不调用 `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/`。
@@ -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 链路、方案比较、历史兼容和复用证据。"}
@@ -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/<purchase-id>/<random-uuid>.<canonical-extension>`;数据库与对象存储记录服务端探测后的规范 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`。
- 本地实现、独立只读复核和统一门禁已完成;未读取真实附件、未连接任何业务数据库、未部署或修改公网配置。
@@ -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 上传/预览安全边界。
@@ -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": {}
}
+46
查看文件
@@ -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 "当前假种要求上传附件后才能提交。";
}
+5 -4
查看文件
@@ -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 } : {}),
},
);
+53 -7
查看文件
@@ -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() {
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">材料证据</h4>
<p className="text-sm text-zinc-500">用于报价、订单、验收或情况说明;草稿状态可增删,下载会留痕。</p>
<p id="purchase-attachment-format-hint" className="text-sm text-zinc-500">
用于报价、订单、验收或情况说明;支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。
</p>
</div>
<div className="flex min-w-0 items-center gap-3">
<input
@@ -1574,6 +1602,8 @@ export function PurchaseRequestsPage() {
hidden
ref={attachmentInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp"
aria-describedby="purchase-attachment-format-hint"
disabled={!canEditDraft || uploadMutation.isPending}
onChange={(event) => {
const file = event.target.files?.[0];
@@ -1624,7 +1654,15 @@ export function PurchaseRequestsPage() {
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
<Button
data-testid={`purchase-attachment-download-mobile-${attachment.id}`}
type="button"
size="sm"
variant="secondary"
disabled={attachment.governanceStatus === "quarantined"}
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
onClick={() => void handleDownloadAttachment(attachment)}
>
下载
</Button>
<Button
@@ -1666,7 +1704,15 @@ export function PurchaseRequestsPage() {
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
<Button
data-testid={`purchase-attachment-download-desktop-${attachment.id}`}
type="button"
size="sm"
variant="secondary"
disabled={attachment.governanceStatus === "quarantined"}
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
onClick={() => void handleDownloadAttachment(attachment)}
>
下载
</Button>
<Button
@@ -134,6 +134,10 @@ const defaultCloseoutReimbursementId = "40876ef3-0652-4972-aa14-1aad1a855ae0";
const defaultCloseoutReimbursementCode = "BX-20260514-0016";
const wecomUa =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const safePurchaseAttachmentPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
"base64",
);
function nowStamp() {
return `${trialrunMarkerPrefix}-${Date.now()}`;
@@ -1029,15 +1033,15 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("1");
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("188");
const purchaseAttachmentName = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.txt`;
const purchaseAttachmentName = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.png`;
const uploadAttachmentResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`;
});
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
name: purchaseAttachmentName,
mimeType: "text/plain",
buffer: Buffer.from(`${trialrunMarkerPrefix} purchase attachment ${marker}\n`, "utf-8"),
mimeType: "image/png",
buffer: safePurchaseAttachmentPng,
});
const uploadAttachmentResponse = await uploadAttachmentResponsePromise;
if (!uploadAttachmentResponse.ok()) {
@@ -45,6 +45,12 @@ const me = {
const purchaseId = "purchase-r103";
const reimbursementId = "11111111-1111-4111-8111-111111111111";
const invoiceId = "invoice-r103";
const safePurchaseAttachmentId = "attachment-safe-r103";
const quarantinedPurchaseAttachmentId = "attachment-quarantined-r103";
const safePngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
"base64",
);
function navigationItem(key: string, href: string, label: string, icon: string) {
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
@@ -97,7 +103,48 @@ function purchaseDetail() {
updatedAt: "2026-05-06T09:10:00.000Z",
},
],
attachments: [],
attachments: [
{
id: safePurchaseAttachmentId,
originalName: "采购报价.png",
mimeType: "image/png",
fileSize: safePngBuffer.length,
fileSha256: null,
governanceStatus: "approved",
sensitivityLevel: "internal",
attachmentType: "quote",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: me.id,
createdAt: "2026-05-06T09:20:00.000Z",
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
},
{
id: quarantinedPurchaseAttachmentId,
originalName: "历史隔离附件.pdf",
mimeType: "application/pdf",
fileSize: 128,
fileSha256: null,
governanceStatus: "quarantined",
sensitivityLevel: "internal",
attachmentType: "quote",
downloadPolicy: "standard",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: me.id,
createdAt: "2026-05-06T09:15:00.000Z",
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
},
],
pendingWorkflow: null,
archiveOrder: null,
permissions: {
@@ -300,12 +347,38 @@ async function mockApis(page: Page) {
}
if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") {
if (request.postDataBuffer()?.includes(Buffer.from('filename="unsupported.txt"'))) {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-r103-unsupported-attachment",
error: {
code: "VALID_001",
message: "参数校验失败",
details: { file: "unsupported_type" },
},
}),
});
return;
}
await fulfillJson(route, {
attachment: {
id: "attachment-r103",
originalName: "采购报价.pdf",
mimeType: "application/pdf",
fileSize: 4,
originalName: "采购报价.png",
mimeType: "image/png",
fileSize: safePngBuffer.length,
fileSha256: null,
governanceStatus: "approved",
sensitivityLevel: "internal",
attachmentType: "quote",
downloadPolicy: "standard",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: me.id,
createdAt: "2026-05-06T09:20:00.000Z",
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
@@ -314,6 +387,25 @@ async function mockApis(page: Page) {
return;
}
if (
pathName === `/api/purchase-requests/${purchaseId}/attachments/${safePurchaseAttachmentId}/download` &&
request.method() === "POST"
) {
expect(request.headers().authorization).toBe("Bearer mock-r103-token");
expect(request.headers()["content-type"]).toContain("application/json");
expect(request.postDataJSON()).toEqual({ reason: "采购复核" });
await route.fulfill({
status: 200,
headers: {
"content-type": "application/octet-stream",
"content-disposition": 'attachment; filename="purchase-attachment.png"',
"x-content-type-options": "nosniff",
},
body: safePngBuffer,
});
return;
}
if (pathName === "/api/invoice-pool") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] });
return;
@@ -342,7 +434,7 @@ test.describe("R103 cross page UI consistency", () => {
await login(page);
});
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/purchase-requests");
@@ -361,14 +453,44 @@ test.describe("R103 cross page UI consistency", () => {
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
await expect(page.getByText("支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。")).toBeVisible();
await expect(page.getByText("Choose File")).toHaveCount(0);
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
name: "采购报价.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("r103"),
const attachmentInput = page.getByTestId("purchase-attachment-file-input");
await expect(attachmentInput).toHaveAttribute(
"accept",
".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp",
);
await attachmentInput.setInputFiles({
name: "采购报价.png",
mimeType: "image/png",
buffer: safePngBuffer,
});
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.pdf");
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.png");
await attachmentInput.setInputFiles({
name: "unsupported.txt",
mimeType: "text/plain",
buffer: Buffer.from("synthetic unsupported attachment", "utf8"),
});
await expect(page.getByRole("main").getByText("仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。")).toBeVisible();
let popupOpened = false;
page.on("popup", () => {
popupOpened = true;
});
await page.getByTestId(`purchase-attachment-download-desktop-${safePurchaseAttachmentId}`).click();
await expect(page.getByRole("heading", { name: "填写下载用途" })).toBeVisible();
await page.getByRole("textbox", { name: "下载用途" }).fill("采购复核");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "继续下载" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("采购报价.png");
expect(popupOpened).toBe(false);
await expect(
page.getByTestId(`purchase-attachment-download-desktop-${quarantinedPurchaseAttachmentId}`),
).toBeDisabled();
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
});
+372 -184
查看文件
@@ -1,13 +1,9 @@
const crypto = require("node:crypto");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { compactDateString, parseDateOnlyString } = require("../utils/date");
const { parseDateOnlyString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence");
@@ -32,7 +28,13 @@ const {
buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const defaultStorage = require("../services/storage");
const {
PurchaseAttachmentPolicyError,
buildAttachmentContentDisposition,
buildAttachmentStorageKey,
validateAndNormalizePurchaseAttachment,
} = require("../services/purchaseAttachmentPolicy");
const { dispatchNotificationBatch } = require("../services/notificationDispatcher");
const {
ATTACHMENT_TYPE,
@@ -107,6 +109,12 @@ const PURCHASE_ARCHIVE_BODY_SCHEMA = z
})
.strict();
const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z
.object({
reason: z.string().optional(),
})
.strict();
const PURCHASE_SUMMARY_SELECT = {
id: true,
request_no: true,
@@ -972,53 +980,156 @@ async function recomputePurchaseRequestTotal(tx, purchaseRequestId, actorId) {
return nextTotal;
}
async function persistAttachment({ config, purchaseRequestId, filePart }) {
const chunks = [];
let fileSize = 0;
for await (const chunk of filePart.file) {
fileSize += chunk.length;
if (fileSize > config.maxUploadSizeBytes) {
throw buildValidationError({ file: "file_too_large" });
}
chunks.push(chunk);
class PurchaseAttachmentSizeLimitError extends Error {
constructor() {
super("purchase_attachment_size_limit");
this.name = "PurchaseAttachmentSizeLimitError";
this.reasonCode = "size_limit";
}
if (fileSize <= 0) {
throw buildValidationError({ file: "empty" });
}
const buffer = Buffer.concat(chunks);
const safeName = (filePart.filename || "attachment")
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "") || "attachment";
const extension = path.extname(safeName);
const basename = extension ? safeName.slice(0, -extension.length) : safeName;
const key = path.posix.join(
"purchase-requests",
purchaseRequestId,
`${compactDateString(config.appTimezone)}-${basename}-${crypto.randomUUID()}${extension}`,
);
const storagePath = await writeBuffer({
config,
key,
buffer,
contentType: filePart.mimetype,
});
return {
storagePath,
originalName: filePart.filename || safeName,
mimeType: filePart.mimetype || "application/octet-stream",
fileSize,
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
};
}
function sanitizeFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
function resolveAttachmentMaxBytes(config) {
const configured = Number(config?.maxUploadSizeBytes);
return Number.isSafeInteger(configured) && configured > 0 ? configured : 10 * 1024 * 1024;
}
function destroyStream(stream) {
if (!stream) {
return;
}
if (typeof stream.destroy === "function" && !stream.destroyed) {
try {
stream.destroy();
return;
} catch {
// Fall through to a Web Stream cancellation attempt.
}
}
if (typeof stream.cancel === "function") {
try {
Promise.resolve(stream.cancel()).catch(() => {});
} catch {
// The caller is already failing closed; cleanup must not replace the security error.
}
}
}
async function readBoundedStream(stream, maxBytes) {
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += bufferChunk.length;
if (size > maxBytes) {
destroyStream(stream);
throw new PurchaseAttachmentSizeLimitError();
}
chunks.push(bufferChunk);
}
} catch (error) {
if (error instanceof PurchaseAttachmentSizeLimitError) {
throw error;
}
destroyStream(stream);
throw error;
}
if (stream?.truncated) {
destroyStream(stream);
throw new PurchaseAttachmentSizeLimitError();
}
return Buffer.concat(chunks, size);
}
async function discardMultipartFile(stream, maxBytes) {
try {
await readBoundedStream(stream, maxBytes);
} catch {
destroyStream(stream);
}
}
function parsePurchaseAttachmentDownloadBody(body) {
const parsed = PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) {
return new AppError({
code: "VALID_001",
message,
statusCode,
details: {
attachment: "security_validation_failed",
reason: reasonCode,
},
});
}
async function auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode,
downloadReason,
}) {
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
targetType: "purchase_attachment",
targetId: attachmentId,
extra: {
purchaseRequestId,
attachmentType: attachment?.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment?.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment?.governance_status ?? "approved",
reason: downloadReason,
reasonCode,
},
}),
});
} catch {
fastify.log.error(
{
requestId: request.id,
purchaseRequestId,
attachmentId,
errorCode: "purchase_attachment_block_audit_failed",
reasonCode,
},
"Purchase attachment block audit failed",
);
}
}
function throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId }) {
fastify.log.error(
{
requestId: request.id,
purchaseRequestId,
attachmentId,
errorCode: "purchase_attachment_storage_read_failed",
},
"Purchase attachment storage read failed",
);
throw new AppError({
code: "SYS_001",
message: "附件暂时无法读取,请稍后重试",
statusCode: 500,
details: { attachment: "storage_read_failed" },
});
}
async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
@@ -1361,7 +1472,8 @@ async function handlePurchaseArchiveErpSync({
};
}
async function purchaseRequestRoutes(fastify) {
async function purchaseRequestRoutes(fastify, options = {}) {
const attachmentStorage = options.attachmentStorage ?? defaultStorage;
fastify.get(
"/purchase-requests",
{
@@ -2227,37 +2339,93 @@ async function purchaseRequestRoutes(fastify) {
throw buildValidationError({ status: "only_draft_editable" }, 409);
}
let filePart = null;
if (typeof request.isMultipart !== "function" || !request.isMultipart()) {
throw buildValidationError({ file: "multipart_required" });
}
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
let normalizedFile = null;
let governanceFields = {};
try {
for await (const part of request.parts()) {
for await (const part of request.parts({ preservePath: true })) {
if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue;
}
if (part.type === "file") {
if (filePart) {
if (normalizedFile) {
await discardMultipartFile(part.file, maxBytes);
throw buildValidationError({ file: "only_single_file_supported" });
}
filePart = part;
const fileBuffer = await readBoundedStream(part.file, maxBytes);
if (part.fieldname !== "file") {
throw buildValidationError({ file: "invalid_field" });
}
normalizedFile = await validateAndNormalizePurchaseAttachment({
buffer: fileBuffer,
fileName: part.filename,
declaredMimeType: part.mimetype,
maxBytes,
normalizeImages: true,
});
}
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
if (
error instanceof PurchaseAttachmentSizeLimitError ||
error?.code === "FST_REQ_FILE_TOO_LARGE"
) {
throw buildValidationError({ file: "file_too_large" });
}
if (error?.code === "FST_FILES_LIMIT") {
throw buildValidationError({ file: "only_single_file_supported" });
}
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
throw buildValidationError({ file: "multipart_limit_exceeded" });
}
if (error instanceof PurchaseAttachmentPolicyError) {
throw buildValidationError(error.details ?? { file: error.reasonCode });
}
throw error;
}
if (!filePart) {
if (!normalizedFile) {
throw buildValidationError({ file: "required" });
}
const stored = await persistAttachment({
config: fastify.config,
purchaseRequestId,
filePart,
});
let storagePath;
try {
storagePath = await attachmentStorage.writeBuffer({
config: fastify.config,
key: buildAttachmentStorageKey(purchaseRequestId, normalizedFile.extension),
buffer: normalizedFile.buffer,
contentType: normalizedFile.mimeType,
});
} catch {
fastify.log.error(
{
requestId: request.id,
purchaseRequestId,
errorCode: "purchase_attachment_storage_write_failed",
},
"Purchase attachment storage write failed",
);
throw new AppError({
code: "SYS_001",
message: "附件暂时无法保存,请稍后重试",
statusCode: 500,
details: { attachment: "storage_write_failed" },
});
}
const stored = {
storagePath,
originalName: normalizedFile.originalName,
mimeType: normalizedFile.mimeType,
fileSize: normalizedFile.fileSize,
fileSha256: normalizedFile.fileSha256,
};
const attachment = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseAttachment.create({
@@ -2415,15 +2583,18 @@ async function purchaseRequestRoutes(fastify) {
);
fastify.post(
"/purchase-requests/:id/attachments/:attachmentId/signed-url",
"/purchase-requests/:id/attachments/:attachmentId/download",
{
schema: { tags: ["Purchase Requests"], summary: "Issue Purchase Attachment Signed URL" },
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensurePurchaseReadPermission(fastify, request);
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId);
const body = parsePurchaseAttachmentDownloadBody(request.body);
const downloadReason = sanitizeDownloadReason(body.reason);
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request);
@@ -2434,116 +2605,10 @@ async function purchaseRequestRoutes(fastify) {
},
select: {
id: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
download_policy: true,
},
});
if (!attachment) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const downloadReason = sanitizeDownloadReason(request.body?.reason ?? request.query?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
{
purchaseRequestId,
attachmentId,
actorId: request.employee.id,
reason: downloadReason,
exp: expiresAt,
},
fastify.config.jwtSecret,
);
const origin =
fastify.config.appOrigin ||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
const signedUrl = `${origin}/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/file?token=${encodeURIComponent(token)}`;
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_SIGNED_URL_ISSUED",
targetType: "purchase_attachment",
targetId: attachmentId,
extra: {
purchaseRequestId,
expiresAt,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
reason: downloadReason,
},
}),
});
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
});
},
);
fastify.get(
"/purchase-requests/:id/attachments/:attachmentId/file",
{
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment By Token" },
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId);
const token = request.query?.token;
if (typeof token !== "string") {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const actorId = UUID_SCHEMA.safeParse(verified.payload?.actorId);
if (
verified.payload?.purchaseRequestId !== purchaseRequestId ||
verified.payload?.attachmentId !== attachmentId ||
typeof verified.payload?.exp !== "number" ||
verified.payload.exp <= Date.now() ||
!actorId.success
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
where: {
id: attachmentId,
purchase_request_id: purchaseRequestId,
},
select: {
storage_path: true,
mime_type: true,
original_name: true,
file_size: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
@@ -2558,38 +2623,138 @@ async function purchaseRequestRoutes(fastify) {
statusCode: 404,
});
}
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" });
}
let opened = null;
if (attachment.governance_status === "quarantined") {
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: "quarantined",
downloadReason,
});
throw buildAttachmentBlockedError("quarantined", "附件已隔离,无法下载");
}
const recordedSize = Number(attachment.file_size);
if (Number.isFinite(recordedSize) && recordedSize > maxBytes) {
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: "size_limit",
downloadReason,
});
throw buildAttachmentBlockedError("size_limit");
}
let opened;
try {
opened = await openReadStream({
opened = await attachmentStorage.openReadStream({
config: fastify.config,
storagePath: attachment.storage_path,
});
} catch (error) {
if (error instanceof StorageNotFoundError) {
if (
(typeof attachmentStorage.StorageNotFoundError === "function" &&
error instanceof attachmentStorage.StorageNotFoundError) ||
error?.name === "StorageNotFoundError"
) {
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: "missing_object",
downloadReason,
});
throw new AppError({
code: "SYS_404",
message: "Not Found",
message: "附件不存在",
statusCode: 404,
});
}
throw error;
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: "storage_error",
downloadReason,
});
throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId });
}
let storedBuffer;
try {
storedBuffer = await readBoundedStream(opened.stream, maxBytes);
} catch (error) {
if (error instanceof PurchaseAttachmentSizeLimitError) {
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: "size_limit",
downloadReason,
});
throw buildAttachmentBlockedError("size_limit");
}
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: "storage_error",
downloadReason,
});
throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId });
}
let validated;
try {
validated = await validateAndNormalizePurchaseAttachment({
buffer: storedBuffer,
fileName: attachment.original_name,
declaredMimeType: attachment.mime_type,
maxBytes,
normalizeImages: false,
});
} catch (error) {
if (!(error instanceof PurchaseAttachmentPolicyError)) {
throw error;
}
await auditBlockedAttachmentDownload({
fastify,
request,
purchaseRequestId,
attachmentId,
attachment,
reasonCode: error.reasonCode,
downloadReason,
});
throw buildAttachmentBlockedError(error.reasonCode);
}
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: actorId.data,
actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
targetType: "purchase_attachment",
targetId: attachmentId,
extra: {
purchaseRequestId,
exp: verified.payload.exp,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved",
@@ -2597,16 +2762,39 @@ async function purchaseRequestRoutes(fastify) {
},
}),
});
} catch (error) {
fastify.log.error({ err: error, attachmentId }, "Audit log write failed");
} catch {
fastify.log.error(
{
requestId: request.id,
purchaseRequestId,
attachmentId,
errorCode: "purchase_attachment_download_audit_failed",
},
"Purchase attachment download audit failed",
);
throw new AppError({
code: "SYS_001",
message: "附件下载留痕失败,请稍后重试",
statusCode: 500,
details: { attachment: "audit_failed" },
});
}
reply.type(attachment.mime_type || "application/octet-stream");
reply.type("application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeFilename(attachment.original_name)}\"`,
buildAttachmentContentDisposition(validated.originalName),
);
return reply.send(opened.stream);
reply.header("x-content-type-options", "nosniff");
reply.header("cache-control", "private, no-store, max-age=0");
reply.header("pragma", "no-cache");
reply.header("referrer-policy", "no-referrer");
reply.header(
"content-security-policy",
"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
);
reply.header("cross-origin-resource-policy", "same-origin");
return reply.send(validated.buffer);
},
);
}
@@ -0,0 +1,450 @@
const crypto = require("node:crypto");
const sharp = require("sharp");
const PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS = 40_000_000;
const PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS = 180;
const PDF_HEADER_PREFIX = Buffer.from("%PDF-", "ascii");
const PDF_EOF_MARKER = Buffer.from("%%EOF", "ascii");
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const PNG_IEND_CHUNK = Buffer.from([
0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4e, 0x44,
0xae, 0x42, 0x60, 0x82,
]);
const CONTENT_TYPES = Object.freeze({
pdf: Object.freeze({
extension: ".pdf",
mimeType: "application/pdf",
declaredMimeTypes: new Set(["application/pdf"]),
}),
jpeg: Object.freeze({
extension: ".jpg",
mimeType: "image/jpeg",
declaredMimeTypes: new Set(["image/jpeg", "image/jpg"]),
}),
png: Object.freeze({
extension: ".png",
mimeType: "image/png",
declaredMimeTypes: new Set(["image/png"]),
}),
webp: Object.freeze({
extension: ".webp",
mimeType: "image/webp",
declaredMimeTypes: new Set(["image/webp"]),
}),
});
const TYPE_BY_EXTENSION = new Map([
[".pdf", "pdf"],
[".jpg", "jpeg"],
[".jpeg", "jpeg"],
[".png", "png"],
[".webp", "webp"],
]);
const TYPE_BY_DECLARED_MIME = new Map(
Object.entries(CONTENT_TYPES).flatMap(([type, descriptor]) =>
[...descriptor.declaredMimeTypes].map((mimeType) => [mimeType, type]),
),
);
const CANONICAL_EXTENSIONS = new Set(
Object.values(CONTENT_TYPES).map((descriptor) => descriptor.extension),
);
const DANGEROUS_INTERMEDIATE_EXTENSIONS = new Set([
"asp",
"aspx",
"cjs",
"css",
"htm",
"html",
"js",
"jsp",
"mjs",
"php",
"phtml",
"shtml",
"svg",
"wasm",
"xht",
"xhtml",
"xml",
]);
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
const PATH_SEPARATORS = /[\\/]/u;
const BIDI_AND_INVISIBLE_CONTROLS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
class PurchaseAttachmentPolicyError extends Error {
constructor(reasonCode) {
super("purchase_attachment_rejected");
this.name = "PurchaseAttachmentPolicyError";
this.code = "PURCHASE_ATTACHMENT_POLICY_ERROR";
this.reasonCode = reasonCode;
this.details = { file: reasonCode };
}
}
function reject(reasonCode) {
throw new PurchaseAttachmentPolicyError(reasonCode);
}
function assertMaxBytes(maxBytes) {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
throw new TypeError("maxBytes must be a positive safe integer");
}
}
function truncateCodePoints(value, maxCodePoints) {
const codePoints = Array.from(value);
return codePoints.length <= maxCodePoints
? value
: codePoints.slice(0, maxCodePoints).join("");
}
function normalizeAndValidateFileName(fileName) {
if (typeof fileName !== "string") {
reject("dangerous_filename");
}
const normalized = fileName.normalize("NFC").trim();
if (
!normalized ||
normalized === "." ||
normalized === ".." ||
CONTROL_CHARACTERS.test(normalized) ||
PATH_SEPARATORS.test(normalized) ||
BIDI_AND_INVISIBLE_CONTROLS.test(normalized)
) {
reject("dangerous_filename");
}
const lastDotIndex = normalized.lastIndexOf(".");
if (lastDotIndex <= 0 || lastDotIndex === normalized.length - 1) {
reject("unsupported_type");
}
const suppliedExtension = normalized.slice(lastDotIndex).toLowerCase();
const expectedType = TYPE_BY_EXTENSION.get(suppliedExtension);
if (!expectedType) {
reject("unsupported_type");
}
const stem = normalized.slice(0, lastDotIndex);
const stemSegments = stem.split(".");
if (
stemSegments.some((segment) => !segment) ||
stemSegments.slice(1).some((segment) =>
DANGEROUS_INTERMEDIATE_EXTENSIONS.has(segment.toLowerCase()),
)
) {
reject("dangerous_filename");
}
const canonicalExtension = CONTENT_TYPES[expectedType].extension;
const maxStemLength = Math.max(
1,
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS - Array.from(canonicalExtension).length,
);
const truncatedStem = truncateCodePoints(stem, maxStemLength).trim();
if (!truncatedStem) {
reject("dangerous_filename");
}
return {
expectedType,
originalName: `${truncatedStem}${canonicalExtension}`,
};
}
function normalizeDeclaredMimeType(declaredMimeType) {
if (declaredMimeType == null) {
return null;
}
if (typeof declaredMimeType !== "string") {
reject("unsupported_type");
}
const normalized = declaredMimeType.trim().toLowerCase();
if (!normalized || normalized === "application/octet-stream") {
return null;
}
if (!TYPE_BY_DECLARED_MIME.has(normalized)) {
reject("unsupported_type");
}
return normalized;
}
function detectSignatureType(buffer) {
if (buffer.length >= PDF_HEADER_PREFIX.length && buffer.subarray(0, PDF_HEADER_PREFIX.length).equals(PDF_HEADER_PREFIX)) {
return "pdf";
}
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "jpeg";
}
if (buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
return "png";
}
if (
buffer.length >= 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "webp";
}
return null;
}
function validatePdfBoundaries(buffer) {
const header = buffer.toString("ascii", 0, Math.min(buffer.length, 16));
if (!/^%PDF-(?:1\.[0-7]|2\.0)(?:\r\n|\r|\n)/u.test(header)) {
reject("corrupt_content");
}
let lastContentIndex = buffer.length - 1;
while (
lastContentIndex >= 0 &&
[0x09, 0x0a, 0x0c, 0x0d, 0x20].includes(buffer[lastContentIndex])
) {
lastContentIndex -= 1;
}
const markerStart = lastContentIndex - PDF_EOF_MARKER.length + 1;
if (
markerStart < 0 ||
!buffer.subarray(markerStart, lastContentIndex + 1).equals(PDF_EOF_MARKER)
) {
reject("corrupt_content");
}
}
function validateJpegBoundaries(buffer) {
if (buffer.length < 4 || buffer[buffer.length - 2] !== 0xff || buffer[buffer.length - 1] !== 0xd9) {
reject("corrupt_content");
}
}
function validatePngBoundaries(buffer) {
if (
buffer.length < PNG_SIGNATURE.length + PNG_IEND_CHUNK.length ||
!buffer.subarray(buffer.length - PNG_IEND_CHUNK.length).equals(PNG_IEND_CHUNK)
) {
reject("corrupt_content");
}
let offset = PNG_SIGNATURE.length;
while (offset < buffer.length) {
if (offset + 12 > buffer.length) {
reject("corrupt_content");
}
const dataLength = buffer.readUInt32BE(offset);
const chunkEnd = offset + 12 + dataLength;
if (chunkEnd > buffer.length) {
reject("corrupt_content");
}
const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
if (chunkType === "acTL") {
reject("multiple_pages");
}
offset = chunkEnd;
}
}
function validateWebpBoundaries(buffer) {
if (buffer.length < 12 || buffer.readUInt32LE(4) + 8 !== buffer.length) {
reject("corrupt_content");
}
}
function validateFormatBoundaries(type, buffer) {
if (type === "pdf") {
validatePdfBoundaries(buffer);
} else if (type === "jpeg") {
validateJpegBoundaries(buffer);
} else if (type === "png") {
validatePngBoundaries(buffer);
} else if (type === "webp") {
validateWebpBoundaries(buffer);
}
}
function mapSharpFailure(error) {
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
reject(message.includes("pixel limit") ? "pixel_limit" : "corrupt_content");
}
async function inspectImage(buffer, expectedType) {
let metadata;
try {
metadata = await sharp(buffer, {
animated: true,
failOn: "warning",
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
sequentialRead: true,
}).metadata();
} catch (error) {
mapSharpFailure(error);
}
if (metadata.format !== expectedType || !metadata.width || !metadata.height) {
reject(metadata.format ? "type_mismatch" : "corrupt_content");
}
const pages = metadata.pages ?? 1;
if (!Number.isSafeInteger(pages) || pages !== 1) {
reject("multiple_pages");
}
if (metadata.width * metadata.height > PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS) {
reject("pixel_limit");
}
let normalizedBuffer;
try {
const pipeline = sharp(buffer, {
failOn: "warning",
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
pages: 1,
sequentialRead: true,
}).rotate();
if (expectedType === "jpeg") {
normalizedBuffer = await pipeline.jpeg({ quality: 90 }).toBuffer();
} else if (expectedType === "png") {
normalizedBuffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();
} else {
normalizedBuffer = await pipeline.webp({ quality: 90, effort: 4 }).toBuffer();
}
} catch (error) {
mapSharpFailure(error);
}
validateFormatBoundaries(expectedType, normalizedBuffer);
return normalizedBuffer;
}
async function validateAndNormalizePurchaseAttachment({
buffer,
fileName,
declaredMimeType,
maxBytes,
normalizeImages = true,
}) {
assertMaxBytes(maxBytes);
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
reject("empty_file");
}
if (buffer.length > maxBytes) {
reject("size_limit");
}
if (typeof normalizeImages !== "boolean") {
throw new TypeError("normalizeImages must be a boolean");
}
const { expectedType, originalName } = normalizeAndValidateFileName(fileName);
const normalizedDeclaredMimeType = normalizeDeclaredMimeType(declaredMimeType);
if (
normalizedDeclaredMimeType &&
TYPE_BY_DECLARED_MIME.get(normalizedDeclaredMimeType) !== expectedType
) {
reject("type_mismatch");
}
const detectedType = detectSignatureType(buffer);
if (!detectedType) {
reject("unsupported_type");
}
if (detectedType !== expectedType) {
reject("type_mismatch");
}
validateFormatBoundaries(detectedType, buffer);
let normalizedBuffer = buffer;
if (detectedType !== "pdf") {
const inspectedBuffer = await inspectImage(buffer, detectedType);
if (normalizeImages) {
if (inspectedBuffer.length > maxBytes) {
reject("size_limit");
}
normalizedBuffer = inspectedBuffer;
}
}
const descriptor = CONTENT_TYPES[detectedType];
return {
buffer: normalizedBuffer,
originalName,
extension: descriptor.extension,
mimeType: descriptor.mimeType,
fileSize: normalizedBuffer.length,
fileSha256: crypto.createHash("sha256").update(normalizedBuffer).digest("hex"),
};
}
function buildAttachmentStorageKey(purchaseRequestId, extension) {
if (typeof purchaseRequestId !== "string" || !UUID_PATTERN.test(purchaseRequestId)) {
throw new TypeError("purchaseRequestId must be a UUID");
}
if (!CANONICAL_EXTENSIONS.has(extension)) {
reject("unsupported_type");
}
return `purchase-requests/${purchaseRequestId.toLowerCase()}/${crypto.randomUUID()}${extension}`;
}
function sanitizeHeaderFileName(fileName) {
const source = typeof fileName === "string" && fileName.trim()
? fileName.normalize("NFC").trim()
: "attachment.bin";
const sanitized = truncateCodePoints(
source
.replace(/[\\/]/gu, "_")
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "_")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, "_")
.replace(/"+/gu, "_")
.replace(/\s+/gu, " ")
.trim(),
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
);
return sanitized && sanitized !== "." && sanitized !== ".."
? sanitized
: "attachment.bin";
}
function buildAsciiFallback(fileName) {
const lastDotIndex = fileName.lastIndexOf(".");
const rawExtension = lastDotIndex > 0 ? fileName.slice(lastDotIndex).toLowerCase() : ".bin";
const extension = /^\.[a-z0-9]{1,10}$/u.test(rawExtension) ? rawExtension : ".bin";
const rawStem = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
const asciiStem = rawStem
.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.replace(/[^a-zA-Z0-9._-]+/gu, "_")
.replace(/_+/gu, "_")
.replace(/^[._-]+|[._-]+$/gu, "")
.slice(0, 100);
return `${asciiStem || "attachment"}${extension}`;
}
function encodeRfc5987Value(value) {
return encodeURIComponent(value).replace(/[\u0027\u0028\u0029\u002a]/gu, (character) =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
function buildAttachmentContentDisposition(fileName) {
const safeFileName = sanitizeHeaderFileName(fileName);
const asciiFallback = buildAsciiFallback(safeFileName);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeRfc5987Value(safeFileName)}`;
}
module.exports = {
CANONICAL_PURCHASE_ATTACHMENT_EXTENSIONS: Object.freeze([...CANONICAL_EXTENSIONS]),
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
PurchaseAttachmentPolicyError,
buildAttachmentContentDisposition,
buildAttachmentStorageKey,
validateAndNormalizePurchaseAttachment,
};
@@ -0,0 +1,814 @@
const crypto = require("node:crypto");
const { Readable } = require("node:stream");
const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests");
const { AppError } = require("../../../src/utils/AppError");
const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111";
const ATTACHMENT_ID = "22222222-2222-4222-8222-222222222222";
const EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333";
const OTHER_EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444";
const NOW = new Date("2026-07-17T08:00:00.000Z");
const VALID_PDF = Buffer.from(
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
"ascii",
);
class TestStorageNotFoundError extends Error {
constructor() {
super("not_found");
this.name = "StorageNotFoundError";
}
}
function buildMultipartRequest(parts) {
const boundary = "----codex-purchase-attachment-boundary";
const segments = [];
for (const part of parts) {
if (part.type === "field") {
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"\r\n\r\n${part.value}\r\n`,
),
);
continue;
}
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${part.fieldname ?? "file"}"; filename="${part.filename}"\r\nContent-Type: ${part.contentType}\r\n\r\n`,
),
Buffer.isBuffer(part.content) ? part.content : Buffer.from(part.content),
Buffer.from("\r\n"),
);
}
segments.push(Buffer.from(`--${boundary}--\r\n`));
return {
payload: Buffer.concat(segments),
headers: {
authorization: "Bearer unit-test",
"content-type": `multipart/form-data; boundary=${boundary}`,
},
};
}
function buildPurchaseRequest(overrides = {}) {
return {
id: PURCHASE_REQUEST_ID,
applicant_id: EMPLOYEE_ID,
submitter_id: null,
status: "draft",
...overrides,
};
}
function buildAttachment(overrides = {}) {
return {
id: ATTACHMENT_ID,
purchase_request_id: PURCHASE_REQUEST_ID,
storage_path: `purchase-requests/${PURCHASE_REQUEST_ID}/${ATTACHMENT_ID}.pdf`,
original_name: "采购报价.pdf",
mime_type: "application/pdf",
file_size: VALID_PDF.length,
governance_status: "approved",
sensitivity_level: "internal",
attachment_type: "quote",
download_policy: "standard",
...overrides,
};
}
async function buildApp({
actor = null,
purchaseRequest = undefined,
attachment = undefined,
maxUploadSizeBytes = 1024 * 1024,
storageOverrides = {},
auditCreate = null,
} = {}) {
const effectiveActor = actor ?? {
id: EMPLOYEE_ID,
roleCodes: ["employee"],
permissionCodes: ["procurement.create", "procurement.view_own"],
};
const state = {
purchaseRequest: purchaseRequest === undefined ? buildPurchaseRequest() : purchaseRequest,
attachment: attachment === undefined ? buildAttachment() : attachment,
createdAttachmentData: null,
};
const uploadAuditCreate = vi.fn().mockResolvedValue({ id: "upload-audit" });
const downloadAuditCreate = auditCreate ?? vi.fn().mockResolvedValue({ id: "download-audit" });
const attachmentCreate = vi.fn(async ({ data }) => {
state.createdAttachmentData = data;
return {
id: ATTACHMENT_ID,
original_name: data.original_name,
mime_type: data.mime_type,
file_size: data.file_size,
file_sha256: data.file_sha256,
governance_status: data.governance_status,
sensitivity_level: data.sensitivity_level,
attachment_type: data.attachment_type,
reviewed_by: null,
reviewed_at: null,
review_note: null,
archived_by: null,
archived_at: null,
archive_note: null,
download_policy: data.download_policy,
uploaded_by: data.uploaded_by,
created_at: NOW,
uploader: {
id: effectiveActor.id,
employee_no: "E001",
name: "测试员工",
},
};
});
const tx = {
purchaseAttachment: { create: attachmentCreate },
auditLog: { create: uploadAuditCreate },
};
const prisma = {
purchaseRequest: {
findUnique: vi.fn(async () => state.purchaseRequest),
},
purchaseAttachment: {
findFirst: vi.fn(async ({ where }) => {
if (!state.attachment) return null;
return where.id === state.attachment.id &&
where.purchase_request_id === state.attachment.purchase_request_id
? state.attachment
: null;
}),
},
auditLog: { create: downloadAuditCreate },
$transaction: vi.fn(async (callback) => callback(tx)),
};
const storage = {
StorageNotFoundError: TestStorageNotFoundError,
writeBuffer: vi.fn(async ({ key }) => key),
openReadStream: vi.fn(async () => ({ stream: Readable.from([VALID_PDF]) })),
...storageOverrides,
};
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
maxUploadSizeBytes,
});
app.decorate("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee?.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("authenticate", async (request) => {
if (typeof request.headers.authorization !== "string") {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = effectiveActor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 1, fileSize: maxUploadSizeBytes },
});
await app.register(purchaseRequestRoutes, {
prefix: "/api",
attachmentStorage: storage,
});
return {
app,
attachmentCreate,
downloadAuditCreate,
prisma,
state,
storage,
uploadAuditCreate,
};
}
describe("purchase attachment upload", () => {
it("consumes the file immediately, accepts trailing governance fields, and persists canonical bytes", async () => {
const { app, attachmentCreate, state, storage } = await buildApp();
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{
type: "file",
filename: "采购报价.v2.pdf",
contentType: "application/octet-stream",
content: VALID_PDF,
},
{ type: "field", name: "sensitivityLevel", value: "confidential" },
]),
});
expect(response.statusCode).toBe(200);
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
const writeInput = storage.writeBuffer.mock.calls[0][0];
expect(writeInput.key).toMatch(
new RegExp(`^purchase-requests/${PURCHASE_REQUEST_ID}/[0-9a-f-]{36}\\.pdf$`),
);
expect(writeInput.key).not.toContain("采购报价");
expect(writeInput.contentType).toBe("application/pdf");
expect(writeInput.buffer.equals(VALID_PDF)).toBe(true);
expect(state.createdAttachmentData).toEqual(
expect.objectContaining({
original_name: "采购报价.v2.pdf",
mime_type: "application/pdf",
file_size: VALID_PDF.length,
sensitivity_level: "confidential",
}),
);
expect(state.createdAttachmentData.file_sha256).toBe(
crypto.createHash("sha256").update(writeInput.buffer).digest("hex"),
);
expect(attachmentCreate).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it("accepts governance fields before the file without hanging", async () => {
const { app, storage } = await buildApp();
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{ type: "field", name: "attachmentType", value: "acceptance" },
{
type: "file",
filename: "验收.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
});
expect(response.statusCode).toBe(200);
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it.each([
["non multipart", null, { file: "multipart_required" }],
[
"wrong file field",
buildMultipartRequest([
{
type: "file",
fieldname: "payload",
filename: "报价.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
{ file: "invalid_field" },
],
[
"path-shaped filename",
buildMultipartRequest([
{
type: "file",
filename: "../报价.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
{ file: "dangerous_filename" },
],
[
"active content",
buildMultipartRequest([
{
type: "file",
filename: "报价.pdf",
contentType: "application/pdf",
content: "<!doctype html><script>void 0</script>",
},
]),
null,
],
[
"empty content",
buildMultipartRequest([
{
type: "file",
filename: "空附件.pdf",
contentType: "application/pdf",
content: Buffer.alloc(0),
},
]),
{ file: "empty_file" },
],
])("rejects %s before storage or database writes", async (_label, multipartRequest, expectedDetails) => {
const { app, attachmentCreate, storage, uploadAuditCreate } = await buildApp();
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
headers: multipartRequest?.headers ?? { authorization: "Bearer unit-test" },
payload: multipartRequest?.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("VALID_001");
if (expectedDetails) {
expect(response.json().error.details).toEqual(expectedDetails);
}
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
expect(uploadAuditCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("maps the production single-file plugin limit to a stable validation error", async () => {
const { app, attachmentCreate, storage } = await buildApp();
const request = buildMultipartRequest([
{
type: "file",
filename: "报价一.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
{
type: "file",
filename: "报价二.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]);
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...request,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ file: "only_single_file_supported" });
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("maps the multipart byte limit to a stable validation error", async () => {
const { app, attachmentCreate, storage } = await buildApp({ maxUploadSizeBytes: 32 });
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{
type: "file",
filename: "超限.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ file: "file_too_large" });
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
["missing permission", { permissionCodes: ["procurement.view_own"] }, buildPurchaseRequest(), 403],
[
"another applicant",
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
buildPurchaseRequest({ applicant_id: OTHER_EMPLOYEE_ID }),
403,
],
[
"non-draft request",
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
buildPurchaseRequest({ status: "reviewing" }),
409,
],
])("does not write upload data for %s", async (_label, actorInput, purchaseRequest, statusCode) => {
const { app, attachmentCreate, storage } = await buildApp({
actor: {
id: EMPLOYEE_ID,
roleCodes: ["employee"],
...actorInput,
},
purchaseRequest,
});
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{
type: "file",
filename: "报价.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
});
expect(response.statusCode).toBe(statusCode);
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
});
describe("purchase attachment authenticated download", () => {
const downloadUrl = `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/download`;
it("requires current authentication before reading storage", async () => {
const { app, storage } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: downloadUrl, payload: {} });
expect(response.statusCode).toBe(401);
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
[
"missing purchase read permission",
{
actor: {
id: EMPLOYEE_ID,
roleCodes: ["employee"],
permissionCodes: ["procurement.create"],
},
},
403,
],
[
"another employee's purchase request",
{
purchaseRequest: buildPurchaseRequest({
applicant_id: OTHER_EMPLOYEE_ID,
submitter_id: OTHER_EMPLOYEE_ID,
}),
},
403,
],
["an attachment outside the purchase request", { attachment: null }, 404],
])("rejects %s before opening storage", async (_label, options, statusCode) => {
const { app, storage } = await buildApp(options);
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(statusCode);
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("requires a JSON-body reason for sensitive attachments", async () => {
const { app, storage } = await buildApp({
attachment: buildAttachment({
sensitivity_level: "confidential",
download_policy: "reason_required",
}),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ reason: "required" });
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("truncates an overlong download reason before writing audit metadata", async () => {
const { app, downloadAuditCreate } = await buildApp({
attachment: buildAttachment({
sensitivity_level: "confidential",
download_policy: "reason_required",
}),
});
const reason = "复".repeat(201);
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: {
authorization: "Bearer unit-test",
"content-type": "application/json",
},
payload: JSON.stringify({ reason }),
});
expect(response.statusCode).toBe(200);
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
extra: expect.objectContaining({ reason: "复".repeat(200) }),
}),
}),
);
} finally {
await app.close();
}
});
it("blocks quarantined attachments before storage and records a fixed reason", async () => {
const { app, downloadAuditCreate, storage } = await buildApp({
attachment: buildAttachment({ governance_status: "quarantined" }),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(storage.openReadStream).not.toHaveBeenCalled();
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
extra: expect.objectContaining({ reasonCode: "quarantined" }),
}),
}),
);
} finally {
await app.close();
}
});
it("blocks a recorded object above the hard limit before opening storage", async () => {
const { app, storage } = await buildApp({
maxUploadSizeBytes: 64,
attachment: buildAttachment({ file_size: 65 }),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("size_limit");
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("returns a safe not-found response and blocked audit when the stored object is missing", async () => {
const { app, downloadAuditCreate } = await buildApp({
storageOverrides: {
openReadStream: vi.fn(async () => {
throw new TestStorageNotFoundError();
}),
},
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(404);
expect(response.json().error.message).toBe("附件不存在");
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
extra: expect.objectContaining({ reasonCode: "missing_object" }),
}),
}),
);
} finally {
await app.close();
}
});
it("rejects an actual object larger than the configured limit and destroys its stream", async () => {
const oversizedStream = {
destroyed: false,
destroy: vi.fn(function destroy() {
this.destroyed = true;
}),
async *[Symbol.asyncIterator]() {
yield Buffer.alloc(65, 0x41);
},
};
const { app, storage } = await buildApp({
maxUploadSizeBytes: 64,
attachment: buildAttachment({ file_size: 1 }),
storageOverrides: {
openReadStream: vi.fn(async () => ({ stream: oversizedStream })),
},
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("size_limit");
expect(oversizedStream.destroy).toHaveBeenCalledTimes(1);
expect(storage.openReadStream).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it("blocks historical active content after byte validation and sends no attachment headers", async () => {
const { app, downloadAuditCreate } = await buildApp({
storageOverrides: {
openReadStream: vi.fn(async () => ({
stream: Readable.from([Buffer.from("<!doctype html><script>void 0</script>")]),
})),
},
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.headers["content-disposition"]).toBeUndefined();
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
}),
}),
);
} finally {
await app.close();
}
});
it("returns a validated historical attachment with the complete forced-download header contract", async () => {
const { app, downloadAuditCreate } = await buildApp({
attachment: buildAttachment({
mime_type: "application/octet-stream",
sensitivity_level: "confidential",
download_policy: "reason_required",
}),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: {
authorization: "Bearer unit-test",
"content-type": "application/json",
},
payload: JSON.stringify({ reason: "审批复核" }),
});
expect(response.statusCode).toBe(200);
expect(response.rawPayload.equals(VALID_PDF)).toBe(true);
expect(response.headers["content-type"]).toContain("application/octet-stream");
expect(response.headers["content-disposition"]).toContain("attachment;");
expect(response.headers["content-disposition"]).toContain("filename*=");
expect(response.headers["content-disposition"]).not.toContain("inline");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["cache-control"]).toBe("private, no-store, max-age=0");
expect(response.headers.pragma).toBe("no-cache");
expect(response.headers["referrer-policy"]).toBe("no-referrer");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.headers["cross-origin-resource-policy"]).toBe("same-origin");
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
extra: expect.objectContaining({ reason: "审批复核" }),
}),
}),
);
} finally {
await app.close();
}
});
it("fails closed before setting attachment headers when success auditing fails", async () => {
const auditCreate = vi.fn().mockRejectedValue(new Error("database unavailable"));
const { app } = await buildApp({ auditCreate });
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(500);
expect(response.headers["content-disposition"]).toBeUndefined();
expect(response.json().error.details).toEqual({ attachment: "audit_failed" });
} finally {
await app.close();
}
});
it("retires both legacy signed and query-token routes", async () => {
const { app, storage } = await buildApp();
try {
const signedResponse = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/signed-url`,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
const fileResponse = await app.inject({
method: "GET",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/file?token=legacy`,
});
expect(signedResponse.statusCode).toBe(404);
expect(fileResponse.statusCode).toBe(404);
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
});
@@ -0,0 +1,545 @@
const crypto = require("node:crypto");
const sharp = require("sharp");
const {
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
PurchaseAttachmentPolicyError,
buildAttachmentContentDisposition,
buildAttachmentStorageKey,
validateAndNormalizePurchaseAttachment,
} = require("../../../src/services/purchaseAttachmentPolicy");
const MAX_BYTES = 2 * 1024 * 1024;
function buildPdf(body = "1 0 obj\n<<>>\nendobj") {
return Buffer.from(`%PDF-1.7\n${body}\n%%EOF\n`, "ascii");
}
function expectPolicyError(error, reasonCode) {
expect(error).toBeInstanceOf(PurchaseAttachmentPolicyError);
expect(error).toMatchObject({
code: "PURCHASE_ATTACHMENT_POLICY_ERROR",
reasonCode,
details: { file: reasonCode },
});
}
async function capturePolicyError(input) {
try {
await validateAndNormalizePurchaseAttachment(input);
} catch (error) {
return error;
}
throw new Error("expected purchase attachment policy rejection");
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function patchPngDimensions(buffer, width, height) {
const patched = Buffer.from(buffer);
patched.writeUInt32BE(width, 16);
patched.writeUInt32BE(height, 20);
patched.writeUInt32BE(crc32(patched.subarray(12, 29)), 29);
return patched;
}
function buildPngChunk(type, data) {
const typeBuffer = Buffer.from(type, "ascii");
const chunk = Buffer.alloc(12 + data.length);
chunk.writeUInt32BE(data.length, 0);
typeBuffer.copy(chunk, 4);
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
return chunk;
}
function parsePngChunks(buffer) {
const chunks = [];
let offset = 8;
while (offset < buffer.length) {
const dataLength = buffer.readUInt32BE(offset);
const type = buffer.toString("ascii", offset + 4, offset + 8);
chunks.push({
type,
data: buffer.subarray(offset + 8, offset + 8 + dataLength),
});
offset += 12 + dataLength;
}
return chunks;
}
function buildAnimatedPng(firstFrame, secondFrame) {
const firstChunks = parsePngChunks(firstFrame);
const secondChunks = parsePngChunks(secondFrame);
const ihdr = firstChunks.find((chunk) => chunk.type === "IHDR").data;
const width = ihdr.readUInt32BE(0);
const height = ihdr.readUInt32BE(4);
let sequence = 0;
const animationControl = Buffer.alloc(8);
animationControl.writeUInt32BE(2, 0);
animationControl.writeUInt32BE(0, 4);
const frameControl = () => {
const data = Buffer.alloc(26);
data.writeUInt32BE(sequence, 0);
sequence += 1;
data.writeUInt32BE(width, 4);
data.writeUInt32BE(height, 8);
data.writeUInt16BE(1, 20);
data.writeUInt16BE(10, 22);
return buildPngChunk("fcTL", data);
};
const parts = [
firstFrame.subarray(0, 8),
buildPngChunk("IHDR", ihdr),
buildPngChunk("acTL", animationControl),
frameControl(),
...firstChunks
.filter((chunk) => chunk.type === "IDAT")
.map((chunk) => buildPngChunk("IDAT", chunk.data)),
frameControl(),
];
for (const chunk of secondChunks.filter((candidate) => candidate.type === "IDAT")) {
const frameData = Buffer.alloc(4 + chunk.data.length);
frameData.writeUInt32BE(sequence, 0);
sequence += 1;
chunk.data.copy(frameData, 4);
parts.push(buildPngChunk("fdAT", frameData));
}
parts.push(buildPngChunk("IEND", Buffer.alloc(0)));
return Buffer.concat(parts);
}
describe("purchase attachment content policy", () => {
let jpegBuffer;
let pngBuffer;
let webpBuffer;
beforeAll(async () => {
const source = {
create: {
width: 4,
height: 3,
channels: 4,
background: { r: 30, g: 120, b: 210, alpha: 1 },
},
};
[jpegBuffer, pngBuffer, webpBuffer] = await Promise.all([
sharp(source).jpeg().toBuffer(),
sharp(source).png().toBuffer(),
sharp(source).webp().toBuffer(),
]);
});
it("accepts a strict PDF boundary and preserves its bytes", async () => {
const buffer = buildPdf();
const result = await validateAndNormalizePurchaseAttachment({
buffer,
fileName: "报价.v2.PDF",
declaredMimeType: "application/pdf",
maxBytes: MAX_BYTES,
});
expect(result).toEqual({
buffer,
originalName: "报价.v2.pdf",
extension: ".pdf",
mimeType: "application/pdf",
fileSize: buffer.length,
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
});
});
it.each([
["photo.jpeg", "image/jpg", "jpeg", ".jpg", "image/jpeg"],
["evidence.png", "image/png", "png", ".png", "image/png"],
["evidence.webp", "image/webp", "webp", ".webp", "image/webp"],
])("decodes and normalizes an allowed %s image", async (fileName, declaredMimeType, fixture, extension, mimeType) => {
const source = { jpeg: jpegBuffer, png: pngBuffer, webp: webpBuffer }[fixture];
const result = await validateAndNormalizePurchaseAttachment({
buffer: source,
fileName,
declaredMimeType,
maxBytes: MAX_BYTES,
});
expect(result.originalName).toBe(`${fileName.slice(0, fileName.lastIndexOf("."))}${extension}`);
expect(result.extension).toBe(extension);
expect(result.mimeType).toBe(mimeType);
expect(result.fileSize).toBe(result.buffer.length);
expect(result.fileSha256).toBe(
crypto.createHash("sha256").update(result.buffer).digest("hex"),
);
const metadata = await sharp(result.buffer).metadata();
expect(metadata.format).toBe(fixture);
expect(metadata.pages ?? 1).toBe(1);
});
it("fully validates but does not rewrite historical image bytes when normalization is disabled", async () => {
const result = await validateAndNormalizePurchaseAttachment({
buffer: pngBuffer,
fileName: "历史附件.png",
declaredMimeType: "application/octet-stream",
maxBytes: MAX_BYTES,
normalizeImages: false,
});
expect(result.buffer).toBe(pngBuffer);
expect(result.fileSize).toBe(pngBuffer.length);
expect(result.mimeType).toBe("image/png");
expect(result.fileSha256).toBe(
crypto.createHash("sha256").update(pngBuffer).digest("hex"),
);
});
it("applies the normalized-output size limit only when storing a normalized upload", async () => {
const compactPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const historical = await validateAndNormalizePurchaseAttachment({
buffer: compactPng,
fileName: "历史附件.png",
declaredMimeType: "image/png",
maxBytes: compactPng.length,
normalizeImages: false,
});
expect(historical.buffer).toBe(compactPng);
expect(historical.fileSize).toBe(compactPng.length);
const uploadError = await capturePolicyError({
buffer: compactPng,
fileName: "新附件.png",
declaredMimeType: "image/png",
maxBytes: compactPng.length,
normalizeImages: true,
});
expectPolicyError(uploadError, "size_limit");
});
it("still fully decodes corrupt historical image bytes when normalization is disabled", async () => {
const boundaryOnlyJpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x00, 0xff, 0xd9]);
const error = await capturePolicyError({
buffer: boundaryOnlyJpeg,
fileName: "历史损坏附件.jpg",
declaredMimeType: "image/jpeg",
maxBytes: MAX_BYTES,
normalizeImages: false,
});
expectPolicyError(error, "corrupt_content");
});
it("rejects warning-only corrupt historical JPEG bytes when normalization is disabled", async () => {
const validJpeg = await sharp({
create: {
width: 64,
height: 64,
channels: 3,
background: { r: 30, g: 120, b: 210 },
},
}).jpeg().toBuffer();
expect(validJpeg.subarray(-2)).toEqual(Buffer.from([0xff, 0xd9]));
const warningOnlyCorruptJpeg = Buffer.concat([
validJpeg.subarray(0, validJpeg.length - 3),
Buffer.from([0xff, 0xd9]),
]);
const error = await capturePolicyError({
buffer: warningOnlyCorruptJpeg,
fileName: "历史损坏附件.jpg",
declaredMimeType: "image/jpeg",
maxBytes: MAX_BYTES,
normalizeImages: false,
});
expectPolicyError(error, "corrupt_content");
});
it("accepts an empty historical MIME only when extension and bytes agree", async () => {
const result = await validateAndNormalizePurchaseAttachment({
buffer: jpegBuffer,
fileName: "历史照片.jpg",
declaredMimeType: " ",
maxBytes: MAX_BYTES,
normalizeImages: false,
});
expect(result.extension).toBe(".jpg");
expect(result.mimeType).toBe("image/jpeg");
});
it("strips image metadata when normalizing", async () => {
const withMetadata = await sharp({
create: {
width: 3,
height: 2,
channels: 3,
background: { r: 200, g: 20, b: 10 },
},
}).withMetadata({ orientation: 6 }).jpeg().toBuffer();
const result = await validateAndNormalizePurchaseAttachment({
buffer: withMetadata,
fileName: "orientation.jpg",
declaredMimeType: "image/jpeg",
maxBytes: MAX_BYTES,
});
const metadata = await sharp(result.buffer).metadata();
expect(metadata.orientation).toBeUndefined();
expect(metadata.exif).toBeUndefined();
});
it.each([
["quote.html.pdf", "dangerous_filename"],
["quote.svg.png", "dangerous_filename"],
["../quote.pdf", "dangerous_filename"],
["folder\\quote.pdf", "dangerous_filename"],
["quote\r\nname.pdf", "dangerous_filename"],
["quote\u202Efdp.pdf", "dangerous_filename"],
["quote.pdf.html", "unsupported_type"],
["quote.txt", "unsupported_type"],
])("rejects unsafe or unsupported filename %s", async (fileName, reasonCode) => {
const error = await capturePolicyError({
buffer: buildPdf(),
fileName,
declaredMimeType: "application/pdf",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, reasonCode);
});
it("truncates a long safe display name while preserving its canonical extension", async () => {
const result = await validateAndNormalizePurchaseAttachment({
buffer: buildPdf(),
fileName: `${"报".repeat(300)}.pdf`,
declaredMimeType: "application/pdf",
maxBytes: MAX_BYTES,
});
expect(Array.from(result.originalName)).toHaveLength(
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
);
expect(result.originalName.endsWith(".pdf")).toBe(true);
});
it.each([
["text/html", "unsupported_type"],
["image/png", "type_mismatch"],
])("rejects invalid declared MIME %s", async (declaredMimeType, reasonCode) => {
const error = await capturePolicyError({
buffer: buildPdf(),
fileName: "quote.pdf",
declaredMimeType,
maxBytes: MAX_BYTES,
});
expectPolicyError(error, reasonCode);
});
it("rejects allowed bytes whose extension declares another allowed type", async () => {
const error = await capturePolicyError({
buffer: pngBuffer,
fileName: "quote.jpg",
declaredMimeType: "application/octet-stream",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "type_mismatch");
});
it.each([
[Buffer.from("<!doctype html><title>marker</title>", "utf8"), "quote.pdf"],
[Buffer.from("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>", "utf8"), "quote.png"],
[Buffer.from("plain marker", "utf8"), "quote.webp"],
])("rejects active or unknown content before Sharp", async (buffer, fileName) => {
const error = await capturePolicyError({
buffer,
fileName,
declaredMimeType: "application/octet-stream",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "unsupported_type");
});
it.each([
[Buffer.from("%PDF-1.7\ntruncated", "ascii"), "truncated.pdf", "application/pdf"],
[Buffer.from("%PDF-1.9\nmarker\n%%EOF\n", "ascii"), "version.pdf", "application/pdf"],
[Buffer.concat([buildPdf(), Buffer.from("<html>tail</html>", "ascii")]), "tail.pdf", "application/pdf"],
])("rejects truncated or trailing PDF content", async (buffer, fileName, declaredMimeType) => {
const error = await capturePolicyError({
buffer,
fileName,
declaredMimeType,
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "corrupt_content");
});
it.each([
["jpeg", "truncated.jpg", "image/jpeg", 2],
["png", "truncated.png", "image/png", 4],
])("rejects truncated %s content", async (fixture, fileName, declaredMimeType, bytesToRemove) => {
const source = { jpeg: jpegBuffer, png: pngBuffer }[fixture];
const error = await capturePolicyError({
buffer: source.subarray(0, source.length - bytesToRemove),
fileName,
declaredMimeType,
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "corrupt_content");
});
it("rejects a WEBP whose RIFF length does not match the actual bytes", async () => {
const corrupted = Buffer.from(webpBuffer);
corrupted.writeUInt32LE(corrupted.readUInt32LE(4) - 1, 4);
const error = await capturePolicyError({
buffer: corrupted,
fileName: "corrupted.webp",
declaredMimeType: "image/webp",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "corrupt_content");
});
it("rejects an animated WEBP as multiple pages", async () => {
const [firstFrame, secondFrame] = await Promise.all([
sharp({
create: {
width: 2,
height: 2,
channels: 4,
background: { r: 10, g: 200, b: 100, alpha: 1 },
},
}).png().toBuffer(),
sharp({
create: {
width: 2,
height: 2,
channels: 4,
background: { r: 200, g: 10, b: 100, alpha: 1 },
},
}).png().toBuffer(),
]);
const animated = await sharp(
[firstFrame, secondFrame],
{ join: { animated: true } },
).webp({ loop: 0, delay: [100, 100] }).toBuffer();
const error = await capturePolicyError({
buffer: animated,
fileName: "animated.webp",
declaredMimeType: "image/webp",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "multiple_pages");
});
it("rejects an APNG animation control stream as multiple pages", async () => {
const secondFrame = await sharp({
create: {
width: 4,
height: 3,
channels: 4,
background: { r: 210, g: 30, b: 120, alpha: 1 },
},
}).png().toBuffer();
const animatedPng = buildAnimatedPng(pngBuffer, secondFrame);
expect(animatedPng.includes(Buffer.from("acTL", "ascii"))).toBe(true);
expect(animatedPng.includes(Buffer.from("fdAT", "ascii"))).toBe(true);
const error = await capturePolicyError({
buffer: animatedPng,
fileName: "animated.png",
declaredMimeType: "image/png",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "multiple_pages");
});
it("rejects image metadata above the 40MP hard limit", async () => {
const oversizedHeader = patchPngDimensions(pngBuffer, 10_000, 4_001);
expect(10_000 * 4_001).toBeGreaterThan(PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS);
const error = await capturePolicyError({
buffer: oversizedHeader,
fileName: "oversized.png",
declaredMimeType: "image/png",
maxBytes: MAX_BYTES,
});
expectPolicyError(error, "pixel_limit");
});
it("rejects empty and oversized buffers with stable reasons", async () => {
const emptyError = await capturePolicyError({
buffer: Buffer.alloc(0),
fileName: "empty.pdf",
declaredMimeType: "application/pdf",
maxBytes: 10,
});
expectPolicyError(emptyError, "empty_file");
const sizeError = await capturePolicyError({
buffer: buildPdf(),
fileName: "large.pdf",
declaredMimeType: "application/pdf",
maxBytes: 4,
});
expectPolicyError(sizeError, "size_limit");
});
});
describe("purchase attachment storage and response helpers", () => {
it("builds a random canonical storage key without an original basename", () => {
const purchaseRequestId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const first = buildAttachmentStorageKey(purchaseRequestId, ".pdf");
const second = buildAttachmentStorageKey(purchaseRequestId, ".pdf");
expect(first).toMatch(
/^purchase-requests\/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\/[0-9a-f-]{36}\.pdf$/u,
);
expect(second).not.toBe(first);
expect(first).not.toContain("quote");
});
it("rejects invalid storage key inputs", () => {
expect(() => buildAttachmentStorageKey("not-an-id", ".pdf")).toThrow(TypeError);
try {
buildAttachmentStorageKey("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ".html");
throw new Error("expected invalid extension rejection");
} catch (error) {
expectPolicyError(error, "unsupported_type");
}
});
it("builds RFC 5987 Content-Disposition with a safe ASCII fallback", () => {
const header = buildAttachmentContentDisposition("采购报价.pdf");
expect(header).toBe(
"attachment; filename=\"attachment.pdf\"; filename*=UTF-8''%E9%87%87%E8%B4%AD%E6%8A%A5%E4%BB%B7.pdf",
);
});
it("removes header injection and path syntax from Content-Disposition", () => {
const header = buildAttachmentContentDisposition("folder/evil\"\r\nX-Test: marker\\quote.pdf");
expect(header.startsWith("attachment; filename=\"")).toBe(true);
expect(header).not.toMatch(/[\r\n]/u);
expect(header).not.toContain("/evil");
expect(header).not.toContain("\\quote");
expect(header).toContain("filename*=UTF-8''");
});
});