修复采购附件同源 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`. `archiveNote`.
- Download APIs may accept `reason`; sensitive or reason-required attachments - Download APIs may accept `reason`; sensitive or reason-required attachments
must reject downloads without a non-empty reason. 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 ### 3. Contracts
@@ -56,6 +62,24 @@
- Audit logs for downloads must include actor, business target, attachment ID, - Audit logs for downloads must include actor, business target, attachment ID,
attachment type, sensitivity level, governance status and sanitized reason attachment type, sensitivity level, governance status and sanitized reason
when provided. 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 ### 4. Validation & Error Matrix
@@ -67,6 +91,14 @@
- User lacks business read permission -> `403 PERM_001`. - User lacks business read permission -> `403 PERM_001`.
- Download reason longer than the server limit -> trim before writing audit - Download reason longer than the server limit -> trim before writing audit
metadata. 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 ### 5. Good/Base/Bad Cases
@@ -75,37 +107,68 @@
- Base: a reimbursement supporting material upload defaults to `internal`, - Base: a reimbursement supporting material upload defaults to `internal`,
`supporting_material`, `standard` and remains downloadable by authorized `supporting_material`, `standard` and remains downloadable by authorized
participants. 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 - Bad: a page hides the download button but the backend route still streams the
file without permission or reason checks. file without permission or reason checks.
- Bad: Enterprise WeChat messages or reports include original attachments - Bad: Enterprise WeChat messages or reports include original attachments
instead of OA links and desensitized summaries. 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 ### 6. Tests Required
- Prisma validate after schema or migration changes. - Prisma validate after schema or migration changes.
- Backend route tests for default governance fields, custom multipart fields - Backend route tests for default governance fields, custom multipart fields
when exposed, sensitive-download reason rejection and audit `extra` metadata. 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. - Frontend typecheck/build after API type changes.
- Focused UI verification for pages that add attachment governance labels or - Focused UI verification for pages that add attachment governance labels or
download-reason interactions. 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 ### 7. Wrong vs Correct
#### Wrong #### Wrong
```js ```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 #### Correct
```js ```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) { if (requiresDownloadReason(attachment) && !reason) {
throw buildValidationError({ reason: "required" }); throw buildValidationError({ reason: "required" });
} }
await prisma.auditLog.create({ data: buildDownloadAudit({ attachment, reason }) }); 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 ## 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 "请假冻结账本状态异常,请联系管理员处理。"; 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") { if (details?.file === "required") {
return "当前假种要求上传附件后才能提交。"; return "当前假种要求上传附件后才能提交。";
} }
+5 -4
查看文件
@@ -1,4 +1,4 @@
import { apiRequestData } from "./http"; import { apiRequestBlob, apiRequestData } from "./http";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { PrecheckResponse, RiskFlag } from "./feeControl"; 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) { export async function downloadPurchaseAttachment(purchaseRequestId: string, attachmentId: string, reason?: string) {
return apiRequestData<{ signedUrl: string; expiresAt: string }>( return apiRequestBlob(
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`, `/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/download`,
{ {
method: "POST", method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(reason ? { reason } : {}), body: JSON.stringify(reason ? { reason } : {}),
}, },
); );
+53 -7
查看文件
@@ -8,10 +8,10 @@ import {
archivePurchaseOrder, archivePurchaseOrder,
createPurchaseRequest, createPurchaseRequest,
deletePurchaseAttachment, deletePurchaseAttachment,
downloadPurchaseAttachment,
fetchPurchaseOrderArchive, fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail, fetchPurchaseRequestDetail,
fetchPurchaseRequests, fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest, precheckPurchaseRequest,
submitPurchaseRequest, submitPurchaseRequest,
updatePurchaseRequest, updatePurchaseRequest,
@@ -54,6 +54,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat"; import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
import { downloadBlob } from "../lib/download";
import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildPrecheckDialogMessage } from "../lib/precheck";
import { import {
formatApprovalHistoryTime, formatApprovalHistoryTime,
@@ -65,6 +66,7 @@ import {
} from "../lib/approvalDisplay"; } from "../lib/approvalDisplay";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH = 180;
type DraftItem = { type DraftItem = {
id: string; id: string;
@@ -77,7 +79,7 @@ type DraftItem = {
function resolveError(error: unknown) { function resolveError(error: unknown) {
if (isApiError(error)) { 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) { if (error instanceof Error) {
return error.message; return error.message;
@@ -85,6 +87,26 @@ function resolveError(error: unknown) {
return "请求失败,请稍后重试。"; 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) { function moneyPreview(quantity: string, unitPrice: string) {
const quantityNumber = Number(quantity); const quantityNumber = Number(quantity);
const unitPriceNumber = Number(unitPrice); const unitPriceNumber = Number(unitPrice);
@@ -717,13 +739,17 @@ export function PurchaseRequestsPage() {
if (!selectedId) { if (!selectedId) {
return; return;
} }
if (attachment.governanceStatus === "quarantined") {
toast.error("附件已隔离,无法下载,请联系管理员。");
return;
}
const reason = await requestDownloadReason(attachment); const reason = await requestDownloadReason(attachment);
if (reason === null) { if (reason === null) {
return; return;
} }
try { try {
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason); const response = await downloadPurchaseAttachment(selectedId, attachment.id, reason);
window.open(response.signedUrl, "_blank", "noopener,noreferrer"); downloadBlob(response.blob, sanitizePurchaseAttachmentDownloadName(attachment.originalName));
} catch (error) { } catch (error) {
const message = resolveError(error); const message = resolveError(error);
setFormError(message); setFormError(message);
@@ -1566,7 +1592,9 @@ export function PurchaseRequestsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="text-base font-semibold text-zinc-900">材料证据</h4> <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>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<input <input
@@ -1574,6 +1602,8 @@ export function PurchaseRequestsPage() {
hidden hidden
ref={attachmentInputRef} ref={attachmentInputRef}
type="file" 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} disabled={!canEditDraft || uploadMutation.isPending}
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -1624,7 +1654,15 @@ export function PurchaseRequestsPage() {
</div> </div>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <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>
<Button <Button
@@ -1666,7 +1704,15 @@ export function PurchaseRequestsPage() {
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell> <TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell> <TableCell>
<div className="flex gap-2"> <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>
<Button <Button
@@ -134,6 +134,10 @@ const defaultCloseoutReimbursementId = "40876ef3-0652-4972-aa14-1aad1a855ae0";
const defaultCloseoutReimbursementCode = "BX-20260514-0016"; const defaultCloseoutReimbursementCode = "BX-20260514-0016";
const wecomUa = 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"; "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() { function nowStamp() {
return `${trialrunMarkerPrefix}-${Date.now()}`; 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-quantity-"]:visible').first().fill("1");
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("188"); 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 uploadAttachmentResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`; return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`;
}); });
await page.getByTestId("purchase-attachment-file-input").setInputFiles({ await page.getByTestId("purchase-attachment-file-input").setInputFiles({
name: purchaseAttachmentName, name: purchaseAttachmentName,
mimeType: "text/plain", mimeType: "image/png",
buffer: Buffer.from(`${trialrunMarkerPrefix} purchase attachment ${marker}\n`, "utf-8"), buffer: safePurchaseAttachmentPng,
}); });
const uploadAttachmentResponse = await uploadAttachmentResponsePromise; const uploadAttachmentResponse = await uploadAttachmentResponsePromise;
if (!uploadAttachmentResponse.ok()) { if (!uploadAttachmentResponse.ok()) {
@@ -45,6 +45,12 @@ const me = {
const purchaseId = "purchase-r103"; const purchaseId = "purchase-r103";
const reimbursementId = "11111111-1111-4111-8111-111111111111"; const reimbursementId = "11111111-1111-4111-8111-111111111111";
const invoiceId = "invoice-r103"; 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) { function navigationItem(key: string, href: string, label: string, icon: string) {
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
@@ -97,7 +103,48 @@ function purchaseDetail() {
updatedAt: "2026-05-06T09:10:00.000Z", 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, pendingWorkflow: null,
archiveOrder: null, archiveOrder: null,
permissions: { permissions: {
@@ -300,12 +347,38 @@ async function mockApis(page: Page) {
} }
if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") { 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, { await fulfillJson(route, {
attachment: { attachment: {
id: "attachment-r103", id: "attachment-r103",
originalName: "采购报价.pdf", originalName: "采购报价.png",
mimeType: "application/pdf", mimeType: "image/png",
fileSize: 4, 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, uploadedBy: me.id,
createdAt: "2026-05-06T09:20:00.000Z", createdAt: "2026-05-06T09:20:00.000Z",
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name }, uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
@@ -314,6 +387,25 @@ async function mockApis(page: Page) {
return; 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") { if (pathName === "/api/invoice-pool") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] }); await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] });
return; return;
@@ -342,7 +434,7 @@ test.describe("R103 cross page UI consistency", () => {
await login(page); await login(page);
}); });
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => { test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/purchase-requests"); 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-requests-table")).toHaveCount(0);
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0); await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件"); 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 expect(page.getByText("Choose File")).toHaveCount(0);
await page.getByTestId("purchase-attachment-file-input").setInputFiles({ const attachmentInput = page.getByTestId("purchase-attachment-file-input");
name: "采购报价.pdf", await expect(attachmentInput).toHaveAttribute(
mimeType: "application/pdf", "accept",
buffer: Buffer.from("r103"), ".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 }); 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 { z } = require("zod");
const { AppError } = require("../utils/AppError"); const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit"); const { buildAuditLogData } = require("../utils/audit");
const { compactDateString, parseDateOnlyString } = require("../utils/date"); const { parseDateOnlyString } = require("../utils/date");
const { sendData } = require("../utils/reply"); const { sendData } = require("../utils/reply");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence"); const { issueDocumentNumber } = require("../services/documentSequence");
@@ -32,7 +28,13 @@ const {
buildWorkflowEntityMeta, buildWorkflowEntityMeta,
} = require("../services/notifications/workflowCopy"); } = require("../services/notifications/workflowCopy");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); 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 { dispatchNotificationBatch } = require("../services/notificationDispatcher");
const { const {
ATTACHMENT_TYPE, ATTACHMENT_TYPE,
@@ -107,6 +109,12 @@ const PURCHASE_ARCHIVE_BODY_SCHEMA = z
}) })
.strict(); .strict();
const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z
.object({
reason: z.string().optional(),
})
.strict();
const PURCHASE_SUMMARY_SELECT = { const PURCHASE_SUMMARY_SELECT = {
id: true, id: true,
request_no: true, request_no: true,
@@ -972,53 +980,156 @@ async function recomputePurchaseRequestTotal(tx, purchaseRequestId, actorId) {
return nextTotal; return nextTotal;
} }
async function persistAttachment({ config, purchaseRequestId, filePart }) { class PurchaseAttachmentSizeLimitError extends Error {
const chunks = []; constructor() {
let fileSize = 0; super("purchase_attachment_size_limit");
this.name = "PurchaseAttachmentSizeLimitError";
for await (const chunk of filePart.file) { this.reasonCode = "size_limit";
fileSize += chunk.length;
if (fileSize > config.maxUploadSizeBytes) {
throw buildValidationError({ file: "file_too_large" });
}
chunks.push(chunk);
} }
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) { function resolveAttachmentMaxBytes(config) {
if (!fileName) return "file"; const configured = Number(config?.maxUploadSizeBytes);
return fileName.replace(/[\r\n"]/g, "_"); 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) { 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( fastify.get(
"/purchase-requests", "/purchase-requests",
{ {
@@ -2227,37 +2339,93 @@ async function purchaseRequestRoutes(fastify) {
throw buildValidationError({ status: "only_draft_editable" }, 409); 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 = {}; let governanceFields = {};
try { try {
for await (const part of request.parts()) { for await (const part of request.parts({ preservePath: true })) {
if (part.type === "field") { if (part.type === "field") {
governanceFields = parseMultipartGovernanceField(part, governanceFields); governanceFields = parseMultipartGovernanceField(part, governanceFields);
continue; continue;
} }
if (part.type === "file") { if (part.type === "file") {
if (filePart) { if (normalizedFile) {
await discardMultipartFile(part.file, maxBytes);
throw buildValidationError({ file: "only_single_file_supported" }); 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) { } 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" }); 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; throw error;
} }
if (!filePart) { if (!normalizedFile) {
throw buildValidationError({ file: "required" }); throw buildValidationError({ file: "required" });
} }
const stored = await persistAttachment({ let storagePath;
config: fastify.config, try {
purchaseRequestId, storagePath = await attachmentStorage.writeBuffer({
filePart, 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 attachment = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseAttachment.create({ const created = await tx.purchaseAttachment.create({
@@ -2415,15 +2583,18 @@ async function purchaseRequestRoutes(fastify) {
); );
fastify.post( 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, preHandler: fastify.authenticate,
}, },
async (request, reply) => { async (request, reply) => {
ensurePurchaseReadPermission(fastify, request); ensurePurchaseReadPermission(fastify, request);
const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const attachmentId = parseAttachmentId(request.params?.attachmentId); 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); await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request);
@@ -2434,116 +2605,10 @@ async function purchaseRequestRoutes(fastify) {
}, },
select: { select: {
id: true, 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, storage_path: true,
mime_type: true, mime_type: true,
original_name: true, original_name: true,
file_size: true,
governance_status: true, governance_status: true,
sensitivity_level: true, sensitivity_level: true,
attachment_type: true, attachment_type: true,
@@ -2558,38 +2623,138 @@ async function purchaseRequestRoutes(fastify) {
statusCode: 404, statusCode: 404,
}); });
} }
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
if (requiresDownloadReason(attachment) && !downloadReason) { if (requiresDownloadReason(attachment) && !downloadReason) {
throw buildValidationError({ reason: "required" }); 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 { try {
opened = await openReadStream({ opened = await attachmentStorage.openReadStream({
config: fastify.config, config: fastify.config,
storagePath: attachment.storage_path, storagePath: attachment.storage_path,
}); });
} catch (error) { } 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({ throw new AppError({
code: "SYS_404", code: "SYS_404",
message: "Not Found", message: "附件不存在",
statusCode: 404, 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 { try {
await fastify.prisma.auditLog.create({ await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, { data: buildAuditLogData(request, {
actorId: actorId.data, actorId: request.employee.id,
action: "PURCHASE_ATTACHMENT_DOWNLOADED", action: "PURCHASE_ATTACHMENT_DOWNLOADED",
targetType: "purchase_attachment", targetType: "purchase_attachment",
targetId: attachmentId, targetId: attachmentId,
extra: { extra: {
purchaseRequestId, purchaseRequestId,
exp: verified.payload.exp,
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote, attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal, sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
governanceStatus: attachment.governance_status ?? "approved", governanceStatus: attachment.governance_status ?? "approved",
@@ -2597,16 +2762,39 @@ async function purchaseRequestRoutes(fastify) {
}, },
}), }),
}); });
} catch (error) { } catch {
fastify.log.error({ err: error, attachmentId }, "Audit log write failed"); 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( reply.header(
"content-disposition", "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''");
});
});