修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)
这个提交包含在:
@@ -43,6 +43,12 @@
|
||||
`archiveNote`.
|
||||
- Download APIs may accept `reason`; sensitive or reason-required attachments
|
||||
must reject downloads without a non-empty reason.
|
||||
- Upload content-policy inputs are `{ buffer, fileName, declaredMimeType,
|
||||
maxBytes, normalizeImages }`; accepted output must contain canonical
|
||||
`buffer`, `extension`, `mimeType`, `fileSize` and `fileSha256`.
|
||||
- Browser downloads of OA-stored attachments must use a currently
|
||||
authenticated request, preferably an API-local `POST` with optional JSON
|
||||
`{ "reason": "..." }`, rather than a query bearer URL.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -56,6 +62,24 @@
|
||||
- Audit logs for downloads must include actor, business target, attachment ID,
|
||||
attachment type, sensitivity level, governance status and sanitized reason
|
||||
when provided.
|
||||
- New uploads must use an allowlist and jointly verify the final extension,
|
||||
declared MIME and actual bytes before storage. Original names are display
|
||||
metadata only; storage keys use a server-generated random identifier and a
|
||||
canonical extension.
|
||||
- PDF basic validation requires a strict header and terminal EOF boundary. Safe
|
||||
image formats must first match fixed signature bytes, then decode with Sharp
|
||||
using `failOn: "warning"`, a fixed pixel ceiling and a one-page constraint;
|
||||
new images are re-encoded without metadata before size/hash/MIME persistence.
|
||||
- A governance state, existing hash, extension or stored MIME never proves
|
||||
historical content safe. Every download must re-authorize the current actor
|
||||
and business object, read under a hard byte limit and re-run the actual-byte
|
||||
policy before any response bytes are sent.
|
||||
- Successful download audit is part of authorization: write it before setting
|
||||
attachment headers or sending bytes. Audit failure must return a non-success
|
||||
response with no file body.
|
||||
- Successful responses use `application/octet-stream`, `attachment`,
|
||||
`nosniff`, private no-store caching, no-referrer and a route-level sandbox
|
||||
CSP. Do not restore same-origin inline preview or query-token download routes.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -67,6 +91,14 @@
|
||||
- User lacks business read permission -> `403 PERM_001`.
|
||||
- Download reason longer than the server limit -> trim before writing audit
|
||||
metadata.
|
||||
- Unsupported/active bytes, extension-MIME-content mismatch, dangerous file
|
||||
name, corrupt/truncated content, animation/multiple pages or pixel/byte limit
|
||||
breach -> reject before object-storage and database writes.
|
||||
- Explicitly quarantined, missing, oversized, corrupt or unverifiable stored
|
||||
object -> do not return file bytes; write a fixed blocked-reason audit where
|
||||
possible.
|
||||
- Successful-download audit write fails -> return 5xx before download headers
|
||||
or file bytes.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
@@ -75,37 +107,68 @@
|
||||
- Base: a reimbursement supporting material upload defaults to `internal`,
|
||||
`supporting_material`, `standard` and remains downloadable by authorized
|
||||
participants.
|
||||
- Good: an allowed image is signature-checked, strictly decoded, normalized,
|
||||
stored under a random canonical key, then later revalidated and returned only
|
||||
as a forced binary download.
|
||||
- Base: an allowed historical PDF remains byte-for-byte unchanged in storage;
|
||||
every access still revalidates it and forces attachment download.
|
||||
- Bad: a page hides the download button but the backend route still streams the
|
||||
file without permission or reason checks.
|
||||
- Bad: Enterprise WeChat messages or reports include original attachments
|
||||
instead of OA links and desensitized summaries.
|
||||
- Bad: trust `multipart.mimetype`, reuse an original basename in the storage
|
||||
path, or return a stored MIME with `inline` from the OA origin.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Prisma validate after schema or migration changes.
|
||||
- Backend route tests for default governance fields, custom multipart fields
|
||||
when exposed, sensitive-download reason rejection and audit `extra` metadata.
|
||||
- Content-policy tests for every allowed type plus active/unknown content,
|
||||
mismatch, truncation, warning-only JPEG corruption, animation/multiple pages,
|
||||
pixel/byte limits, metadata removal, random keys and safe disposition names.
|
||||
- Route tests must prove immediate bounded multipart consumption, rejection
|
||||
before storage/DB writes, current authentication and object authorization,
|
||||
historical revalidation, blocked-audit reasons, audit-before-send and the
|
||||
complete forced-download header contract.
|
||||
- Frontend typecheck/build after API type changes.
|
||||
- Focused UI verification for pages that add attachment governance labels or
|
||||
download-reason interactions.
|
||||
- Frontend download tests must assert authenticated JSON request + Blob browser
|
||||
download and absence of popup/inline preview. Use only synthetic in-memory
|
||||
fixtures; production attachments and historical bulk scanning require a
|
||||
separate approval.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
return reply.send(stream);
|
||||
const storageKey = `${businessId}/${originalFileName}`;
|
||||
await storage.writeBuffer({ key: storageKey, buffer, contentType: clientMime });
|
||||
return reply.type(clientMime).header("content-disposition", "inline").send(buffer);
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const reason = sanitizeDownloadReason(request.query?.reason);
|
||||
const validated = await validateActualAttachmentBytes({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
normalizeImages: false,
|
||||
});
|
||||
const reason = sanitizeDownloadReason(request.body?.reason);
|
||||
if (requiresDownloadReason(attachment) && !reason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
await prisma.auditLog.create({ data: buildDownloadAudit({ attachment, reason }) });
|
||||
return reply.send(stream);
|
||||
return reply
|
||||
.type("application/octet-stream")
|
||||
.header("content-disposition", buildSafeAttachmentDisposition(validated.originalName))
|
||||
.header("x-content-type-options", "nosniff")
|
||||
.header("cache-control", "private, no-store")
|
||||
.send(validated.buffer);
|
||||
```
|
||||
|
||||
## Scenario: Company Documents And OA Collaboration
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"检查批准、范围、只读 reviewer、门禁与提交合同。"}
|
||||
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"检查后端实现、Prisma 验证和回归覆盖。"}
|
||||
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"检查 Blob 下载、前端构建和聚焦 UI 回归。"}
|
||||
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"检查文件上传、下载权限、响应头、审计与敏感信息边界。"}
|
||||
{"file":".trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md","reason":"按原始证据复核 P0 链路是否被完整切断。"}
|
||||
@@ -0,0 +1,218 @@
|
||||
# 采购附件同源 XSS 安全设计
|
||||
|
||||
## Security Invariants
|
||||
|
||||
1. 未通过服务端实际字节校验的文件不得写入新的采购附件记录。
|
||||
2. 客户端 MIME、原文件名、扩展名、业务治理状态和既有 SHA-256 都不是内容安全凭据。
|
||||
3. 任一采购附件在返回浏览器前必须重新检查当前用户、当前采购对象、附件归属、用途要求和实际字节。
|
||||
4. 浏览器永远只收到强制下载的二进制响应;采购附件不得作为 OA 同源 HTML、SVG、XML、脚本、图片或 PDF 文档内联解释。
|
||||
5. 历史附件不删除、不移动、不改写;不安全或不可验证内容只逻辑阻断并写脱敏审计。
|
||||
6. 安全版本出错时允许暂停附件能力,不允许回退到 `inline`、客户端 MIME权威或旧 query bearer URL。
|
||||
|
||||
## Allowed Content Contract
|
||||
|
||||
| 业务格式 | 合法扩展名 | 规范 MIME | 实际内容判定 | 上传规范化 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PDF | `.pdf` | `application/pdf` | 首字节为 `%PDF-`;版本字符合法;最后非空白内容以 `%%EOF` 结束;无前置/尾随 active 内容 | 保留原字节,不做内容消毒 |
|
||||
| JPEG | `.jpg` / `.jpeg` | `image/jpeg` | Sharp 探测为 jpeg、单页、像素数不超限,输入以 JPEG EOI 结束 | Sharp 按 JPEG 重编码并移除元数据 |
|
||||
| PNG | `.png` | `image/png` | Sharp 探测为 png、单页、像素数不超限,IEND 为最终 chunk | Sharp 按 PNG 重编码并移除元数据 |
|
||||
| WEBP | `.webp` | `image/webp` | Sharp 探测为 webp、单页、像素数不超限,RIFF 声明长度等于实际长度 | Sharp 按 WEBP 重编码并移除元数据 |
|
||||
|
||||
固定策略:输入最大值沿用 `MAX_UPLOAD_SIZE_BYTES`(当前默认 10 MiB);图片最大解码像素常量固定为 `40_000_000`;动画、APNG、animated WEBP 和其他多页输入拒绝。规范化输出再次执行最大字节检查。
|
||||
|
||||
图片进入 Sharp 之前必须先匹配 JPEG SOI、PNG signature 或 RIFF/WEBP 固定字节;SVG、GIF、XML、HTML 和未知内容不得交给通用图片解码器。Sharp 使用明确的 `limitInputPixels: 40_000_000`、严格损坏处理和动画元数据读取,要求 `pages` 缺失或等于 `1`,再核对 `metadata.format` 与前置签名、扩展名和声明 MIME 一致。
|
||||
|
||||
声明 MIME处理:
|
||||
|
||||
- `application/pdf`、`image/jpeg`、`image/jpg`、`image/png`、`image/webp` 必须与扩展名和探测结果匹配。
|
||||
- 空值或 `application/octet-stream` 只表示客户端未知,仍必须由扩展名和字节结果唯一确认。
|
||||
- 任何 active、文本、Office、GIF 或其他 MIME直接拒绝。
|
||||
|
||||
危险文件名处理:
|
||||
|
||||
- 先移除路径语义并拒绝 `/`、`\\`、NUL、CR/LF 和其他控制字符。
|
||||
- 限制显示名长度,同时保留最终规范扩展名。
|
||||
- 允许普通版本点号(如 `报价.v2.pdf`),但任一中间扩展名若为 `html/htm/xhtml/svg/xml/js/mjs/cjs/css` 等 active 类型则拒绝。
|
||||
- 存储 key 不使用显示名,只使用业务 UUID、随机 UUID 和规范扩展名。
|
||||
|
||||
## Upload Flow
|
||||
|
||||
1. 检查 multipart、权限、采购对象存在、本人草稿和可编辑状态;失败时尚未读取/写入存储。
|
||||
2. 迭代 `request.parts()`:
|
||||
- 允许的治理字段继续规范化;
|
||||
- 遇到第一个 `file` part 时立即在硬上限内读取,不把未消费 stream 留到循环结束;即使元数据已可判定非法,也先受限排空或显式销毁该 stream;
|
||||
- 字段名必须为 `file`;出现第二文件或插件 `FST_FILES_LIMIT` 时稳定返回 `VALID_001`;
|
||||
- 统一把 `FST_REQ_FILE_TOO_LARGE` 与 `FST_FILES_LIMIT` 映射为 `400 VALID_001`;
|
||||
- 在内存中完成文件名、扩展名、声明 MIME、字节探测和图片规范化。
|
||||
3. 循环结束后确认恰好一份已规范化文件,规范化输出非空且不超限。
|
||||
4. 生成 `purchase-requests/<purchase-id>/<uuid>.<ext>`,用规范 MIME 调用 `writeBuffer()`。
|
||||
5. 对最终存储 buffer 计算大小和 SHA-256;数据库只保存规范 MIME、规范显示名和最终摘要。
|
||||
6. 在现有事务内创建附件与上传审计;审计不记录原始 payload 或存储绝对路径。
|
||||
|
||||
路由不得先写原始文件再验证。数据库失败后的存储孤儿清理不是本 P0 根因;若实现中发现可安全复用的删除抽象,可另记后续,不在本轮扩大存储层。
|
||||
|
||||
## Download API
|
||||
|
||||
### 新接口
|
||||
|
||||
```text
|
||||
POST /api/purchase-requests/:id/attachments/:attachmentId/download
|
||||
Authorization: Bearer <current access token>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "reason": "可选;敏感附件必填" }
|
||||
```
|
||||
|
||||
请求 body 使用严格 schema,只允许可选且限长的 `reason`。响应是二进制 Blob,不返回 JSON signed URL。
|
||||
|
||||
前端采购 API helper 必须显式传入 `headers: { "content-type": "application/json" }` 和 `JSON.stringify(...)`;不修改共享 `apiRequestBlob` 的默认行为,避免扩大其他 Blob 调用的回归面。
|
||||
|
||||
### 退役接口
|
||||
|
||||
```text
|
||||
POST /api/purchase-requests/:id/attachments/:attachmentId/signed-url
|
||||
GET /api/purchase-requests/:id/attachments/:attachmentId/file?token=...
|
||||
```
|
||||
|
||||
二者从应用路由和前端 API中移除。已经签发的旧 URL 在新版本上不能取得文件;这属于安全失效,不做兼容回放。
|
||||
|
||||
### 下载处理顺序
|
||||
|
||||
1. `fastify.authenticate` 验证当前 access token。
|
||||
2. 执行采购读取权限和 `loadReadablePurchaseRequest()` 对象级范围检查。
|
||||
3. 查询附件并要求 `purchase_request_id` 匹配;读取业务治理字段、存储路径、文件名、MIME 和记录大小。
|
||||
4. 规范化 JSON body 中的用途;按既有 `requiresDownloadReason()` 执行。
|
||||
5. 若业务治理状态为 `quarantined`,立即写阻断审计并返回 `409 VALID_001`。
|
||||
6. 记录大小必须在硬上限内;使用现有 storage stream 逐块读取并再次执行实际上限,不能只信数据库大小。实际对象一旦超限,立即销毁 local/S3 stream 并拒绝,不能调用现有无上限 `readFileBuffer()`。
|
||||
7. 使用与上传相同的格式策略验证实际字节。历史 active/错配/损坏内容不进入响应;存储暂时错误保持 fail closed,不把瞬时错误误报为安全通过。
|
||||
8. 成功写 `PURCHASE_ATTACHMENT_DOWNLOADED` 审计,随后设置固定响应头并发送 buffer;审计写入失败必须 fail closed,不得发送未留痕文件。
|
||||
|
||||
## Response Header Contract
|
||||
|
||||
所有成功下载固定为:
|
||||
|
||||
```text
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: attachment; filename="<ascii-fallback>"; filename*=UTF-8''<encoded-name>
|
||||
X-Content-Type-Options: nosniff
|
||||
Cache-Control: private, no-store, max-age=0
|
||||
Pragma: no-cache
|
||||
Referrer-Policy: no-referrer
|
||||
Content-Security-Policy: sandbox; default-src 'none'; base-uri 'none'; form-action 'none'
|
||||
Cross-Origin-Resource-Policy: same-origin
|
||||
```
|
||||
|
||||
`Content-Disposition` 由集中 helper 生成:ASCII fallback 只含安全字符;UTF-8 名称按 RFC 5987 编码。不得直接插值数据库原名。
|
||||
|
||||
即使代理或浏览器行为异常,`octet-stream + attachment + nosniff + sandbox` 仍提供多层同源 active-content 防线。
|
||||
|
||||
## Historical Attachment Strategy
|
||||
|
||||
- 不新增 schema 字段,不批量读取或回填真实记录。
|
||||
- 每个历史下载请求都验证实际字节,因此不存在“旧记录因 `approved` 或旧 SHA-256 而自动可信”的路径。
|
||||
- 合法历史 PDF/JPEG/PNG/WEBP:验证通过后按安全响应头下载,存储原件保持字节不变。
|
||||
- 明确 active MIME/扩展名:可在读取前快速阻断;最终决策仍以实际内容策略为准。
|
||||
- 错配、损坏、超限或缺失:不发送文件;返回自然中文隔离/缺失提示并写固定原因码审计。
|
||||
- 显式业务 `quarantined`:前端禁用,服务端强制拒绝;`approved` 仅表示业务治理通过,不表示内容验证通过。
|
||||
- 后续若附件量导致每次复验成本不可接受,再以独立任务设计持久化 `content_validation_version/status` 和受控重扫;该优化不能弱化下载响应头。
|
||||
|
||||
## Error And Audit Matrix
|
||||
|
||||
| 场景 | HTTP / Error | 存储/DB | 审计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 非 multipart / 无文件 / 错字段 / 第二文件 | `400 VALID_001` | 不写 | 普通请求错误日志,不含 payload |
|
||||
| 空文件 / 输入或规范化输出超限 | `400 VALID_001` | 不写 | 不记录内容 |
|
||||
| 不允许扩展名/MIME/危险双扩展名 | `400 VALID_001` | 不写 | 可记录固定拒绝类别 |
|
||||
| 字节类型错配、损坏、像素/页数超限 | `400 VALID_001` | 不写 | 可记录固定拒绝类别 |
|
||||
| 无上传/读取权限 | `403 PERM_001` | 不写 | 沿用权限审计策略 |
|
||||
| 非本人草稿或不可编辑状态 | `403` / `409 VALID_001` | 不写 | 沿用业务审计 |
|
||||
| 下载缺用途 | `400 VALID_001` | 不读文件 | 沿用用途策略 |
|
||||
| 显式隔离或历史内容校验失败 | `409 VALID_001` | 不改/不删 | `PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED` + 固定 reason code |
|
||||
| 存储对象不存在 | `404` | 不改/不删 | 可记录 `missing`,不写路径 |
|
||||
| 存储暂时故障 | `5xx` fail closed | 不改/不删 | 安全标量错误,不序列化 provider 原始对象 |
|
||||
| 成功下载审计写入失败 | `5xx` fail closed | 不改/不删 | 不发送文件;日志只含安全标量,不序列化原始 DB/provider error |
|
||||
| 成功下载 | `200` 二进制 | 不改 | 现有 actor、业务对象、附件 ID、类型、敏感级别、用途 |
|
||||
|
||||
固定内容拒绝码建议:`active_type`、`unsupported_type`、`type_mismatch`、`dangerous_filename`、`corrupt_content`、`pixel_limit`、`multiple_pages`、`size_limit`、`missing_object`。响应只映射成用户可理解文案,不暴露探测细节。
|
||||
|
||||
## Frontend Flow
|
||||
|
||||
1. 文件 input 增加:
|
||||
|
||||
```text
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp"
|
||||
```
|
||||
|
||||
2. 选择后仍可立即上传,但旁边固定显示允许格式;服务端拒绝时保留当前采购草稿和其他表单内容。
|
||||
3. 下载先走现有用途弹窗;随后 `apiRequestBlob(POST .../download)`。
|
||||
4. 对详情 DTO 中的原名再次移除控制字符/路径分隔符并限长,再调用 `downloadBlob(response.blob, safeAttachmentName)` 触发 `<a download>`;不调用 `window.open`。HTTP `Content-Disposition` 仍保留安全 ASCII fallback 与 UTF-8 `filename*`,供非前端调用者使用。
|
||||
5. `governanceStatus === "quarantined"` 时显示“已隔离”并禁用下载;服务器仍是最终边界。
|
||||
6. 移动详情维持只读元数据,不增加下载/预览;本任务不做移动页面重构。
|
||||
|
||||
## Rollout
|
||||
|
||||
1. 本地实现、聚焦测试、完整门禁和只读安全复核完成后形成一个采购附件安全提交。
|
||||
2. 部署必须另行批准;部署前核对数据库/存储备份、实际 storage driver 和对象存储私有性。
|
||||
3. 前后端同批切换;上线前必须另行批准、配置并验证代理对采购附件上传、旧 signed URL 和旧文件端点的精确拒绝规则。没有该外部门禁时禁止部署。
|
||||
4. 发布后用合成 PDF/图片和无害 active-content fixture 做授权隔离 smoke;不得使用真实附件或把测试 payload 留在生产。
|
||||
5. 只有部署来源 commit、公网响应头、角色权限和下载事件都通过后,才能告知用户线上可测。
|
||||
|
||||
## Rollback
|
||||
|
||||
- 没有 schema 或历史数据写入需要回滚;新文件 key 与旧存储抽象兼容。
|
||||
- 校验误判时只回滚/调整格式策略和前端提示,保留认证下载、强制 attachment、安全 MIME 与响应头。
|
||||
- 若新版本不可用,优先向前修复;如确需恢复旧应用,只有在已经验证的代理拒绝规则持续关闭上传、signed URL 和旧 file 端点时才可操作。旧版不能重新提供 `text/html + inline`。
|
||||
- 不通过删除历史文件、重置数据库、改写 migration 或公开 S3对象来恢复使用。
|
||||
|
||||
## Test Matrix
|
||||
|
||||
### 策略单测
|
||||
|
||||
- 合法最小 PDF、JPEG、PNG、WEBP。
|
||||
- HTML/SVG/XML/JS/文本/未知二进制。
|
||||
- `.html.pdf`、`.pdf.html`、普通 `.v2.pdf`。
|
||||
- HTML 伪装 PDF;PNG 字节配 JPEG/PDF MIME或扩展名。
|
||||
- 截断 PDF/PNG/JPEG、PDF 前置/尾随 active 内容。
|
||||
- 图片尾随标记在规范化结果中消失;元数据不保留。
|
||||
- 超像素、多页/动画、输入/输出超字节上限。
|
||||
- APNG、animated WEBP、伪造巨大尺寸头,以及 Sharp 可解析但 allowlist 禁止的 SVG。
|
||||
- 中文、长名、CRLF、引号、路径分隔符和 Content-Disposition。
|
||||
|
||||
### 路由测试
|
||||
|
||||
- multipart 文件在治理字段之前与之后均完成,不挂起。
|
||||
- 第二文件、错误字段、拒绝 MIME、空文件、非 multipart、插件大小/文件数错误;用超时断言证明流被消费/销毁且请求正常结束。
|
||||
- 无权限、他人草稿、非草稿均不调用存储或附件 create。
|
||||
- 新 key 不含 basename;DB/S3 MIME、大小、hash 对应规范化 buffer。
|
||||
- 新认证下载重新检查对象权限与用途;旧 signed/file 路由不可用。
|
||||
- 前端采购 API helper 的真实请求带 JSON Content-Type/body,敏感附件用途能被 Fastify 解析。
|
||||
- 历史合法、active、伪装、损坏、显式 quarantined、缺失、超限。
|
||||
- 成功响应每个安全头与 `Content-Disposition`;任何路径均无 `inline`。
|
||||
- 阻断/成功审计不包含原始 payload、完整路径或 token。
|
||||
- 审计写入异常时不返回成功或文件内容;数据库/存储原始错误对象不进入日志。
|
||||
- 数据库记录大小小于实际对象大小时,实际流上限仍阻断并销毁 stream。
|
||||
|
||||
### 前端/E2E
|
||||
|
||||
- `accept` 与格式说明可见。
|
||||
- 直接构造恶意 multipart 被拒绝且附件列表不新增。
|
||||
- 点击下载产生 Playwright download event,不产生新 page/popup。
|
||||
- `reason_required` 弹窗取消、缺失与成功路径。
|
||||
- 显式隔离附件禁用下载。
|
||||
- 移动详情继续只显示名称、大小、上传人和治理标签。
|
||||
- R103 和 R7E 使用合成有效 PDF/图片 fixture。
|
||||
|
||||
## Expected File Scope
|
||||
|
||||
- `server/src/services/purchaseAttachmentPolicy.js`(新增)
|
||||
- `server/src/routes/purchaseRequests.js`
|
||||
- `server/tests/unit/services/purchaseAttachmentPolicy.test.js`(新增)
|
||||
- `server/tests/unit/routes/purchase.attachments.test.js`(新增)
|
||||
- `client/src/api/purchaseRequests.ts`
|
||||
- `client/src/pages/PurchaseRequestsPage.tsx`
|
||||
- `client/src/api/errorMessages.ts`
|
||||
- `client/tests/e2e/r103-cross-page-ui-consistency.spec.ts`
|
||||
- `client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts`
|
||||
- 当前 Trellis task 文件
|
||||
|
||||
若实现证明无需修改某文件,则不为满足清单而制造改动。不得修改或提交 `.trellis/tasks/07-16-system-multidimensional-audit/`。
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"遵循 Trellis 实施、批准、范围、回滚和提交边界。"}
|
||||
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"约束 Fastify 路由、Prisma 无漂移验证和后端测试。"}
|
||||
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"保持现有 React/Vite 下载交互与前端验证合同。"}
|
||||
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"执行业务附件权限、下载审计、私有存储和脱敏边界。"}
|
||||
{"file":".trellis/tasks/07-17-purchase-attachment-xss-hardening/research/attachment-security-design-research.md","reason":"提供已确认 XSS 链路、方案比较、历史兼容和复用证据。"}
|
||||
@@ -0,0 +1,155 @@
|
||||
# 采购附件同源 XSS 安全收口
|
||||
|
||||
## Goal
|
||||
|
||||
关闭采购附件从普通员工上传到审批角色打开之间的同源存储型 XSS 链,并把采购附件固定为“严格格式入库、服务端授权、浏览器强制下载”的安全模式;兼容现有 local/S3 存储和历史记录,不删除、不搬动、不改写真实附件。
|
||||
|
||||
## Scope
|
||||
|
||||
- 采购附件 multipart 读取、大小限制、文件名、扩展名、声明 MIME、字节内容校验、图片规范化、存储 key、哈希与数据库 MIME。
|
||||
- 采购附件下载权限、下载用途、历史字节复验、响应头和审计。
|
||||
- 退役采购附件 query bearer signed URL,改为当前会话已认证的 Blob 下载。
|
||||
- PC 采购详情的文件选择提示、格式 accept、下载行为和安全错误文案。
|
||||
- 采购附件策略/路由单测、前端聚焦 E2E、受影响的 R7E 采购附件 fixture 和项目门禁。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 1. 允许格式与真实内容校验
|
||||
|
||||
- 采购附件只允许 PDF、JPEG/JPG、PNG、WEBP;不允许 HTML、XHTML、SVG、XML、JavaScript、CSS、纯文本、Office、GIF 或未知二进制。
|
||||
- 服务端同时检查最终扩展名、客户端声明 MIME 与实际字节内容;客户端 `accept` 仅用于文件选择体验,不能作为安全边界。
|
||||
- MIME 为空或 `application/octet-stream` 时可继续按扩展名和字节探测;其他不在 allowlist 的声明 MIME直接拒绝。
|
||||
- 拒绝类型错配、损坏/截断内容、危险双扩展名、路径型文件名、控制字符,以及 PDF/图片前置或尾随的明显 active-content 伪装。
|
||||
- PDF 必须从首字节开始具有合法 PDF 头,且最后非空白内容以 EOF 标记结束;不把该检查描述为杀毒或 PDF 内容消毒。
|
||||
- 图片必须先通过 JPEG/PNG/WEBP 固定签名字节分类,active/未知格式不得进入 Sharp;随后由 Sharp 以 `40_000_000` 像素硬上限、严格损坏策略和单页约束解码并按真实格式重新编码;规范化后再次执行大小上限,去除元数据和尾随内容。
|
||||
- multipart 文件流必须在 `request.parts()` 迭代到该 part 时立即受限消费;即使字段名、文件名或声明 MIME 已知非法,也必须先安全排空或显式销毁当前流再返回。第二个文件、错误字段、插件文件数上限和文件过大都返回稳定的 `VALID_001`,不得挂起或退化为 500。
|
||||
|
||||
### 2. 安全命名与私有存储
|
||||
|
||||
- 原文件名只作为经规范化、限长的显示名,不得参与存储路径拼接。
|
||||
- 新存储 key 固定为 `purchase-requests/<purchase-id>/<random-uuid>.<canonical-extension>`;数据库与对象存储记录服务端探测后的规范 MIME。
|
||||
- SHA-256、`file_size`、S3 ContentType 和数据库 MIME均基于最终实际存储的规范化字节。
|
||||
- 继续使用现有私有 local/S3 抽象和根目录逃逸校验,不新增静态目录、不公开对象 ACL。
|
||||
|
||||
### 3. 下载必须重新授权并强制 attachment
|
||||
|
||||
- 删除采购附件 `/signed-url` 与 `GET .../file?token=...` 链路;新下载使用当前 access token 的已认证 POST Blob 请求,并在下载时重新执行采购对象读取权限和附件归属校验。
|
||||
- 下载用途放在 JSON body,不再进入 query string;采购 API helper 必须显式发送 `Content-Type: application/json`,因为现有 `apiRequestBlob` 不会自动为字符串 body 补该 header。`reason_required`、保密或受限附件继续要求非空用途并写审计。
|
||||
- 所有成功响应固定使用 `Content-Type: application/octet-stream`、`Content-Disposition: attachment`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, no-store`、`Referrer-Policy: no-referrer`,并增加无脚本能力的 route-level sandbox CSP 作为纵深防护。
|
||||
- 服务端生成 RFC 安全的下载文件名;不得把控制字符、路径分隔符或未转义原名写入响应头。
|
||||
- PC 端通过现有 `apiRequestBlob` 与 `downloadBlob` 触发浏览器下载,不使用 `window.open`,不创建 OA 同源预览页。
|
||||
- 成功下载审计必须在发送文件前写入;审计失败时返回 5xx 且不得发送任何文件字节。
|
||||
|
||||
### 4. 历史附件兼容与逻辑隔离
|
||||
|
||||
- 现有附件记录、业务治理状态、存储路径和文件字节全部保留;本 task 不做物理删除、移动、批量重写或生产扫描。
|
||||
- 每次下载都在实际返回前,以硬大小上限读取并复用同一内容策略验证历史字节;`governance_status=approved`、安全扩展名或已有 SHA-256 均不得跳过验证。
|
||||
- 显式 `governance_status=quarantined` 的附件直接拒绝;其他历史附件若为 HTML/SVG/XML/文本、类型错配、损坏、缺失或超限,均不得返回文件内容。
|
||||
- 内容校验失败写固定原因码安全审计并返回可理解的隔离提示;不得记录原文件内容、完整存储路径或原始 payload。
|
||||
- 合法历史 PDF/JPEG/PNG/WEBP 通过字节验证后仍只能作为二进制附件下载,绝不恢复同源内联预览。
|
||||
- 若后续需要批量扫描、持久化内容状态或物理隔离目录,另建任务并在读取/写入真实数据前取得明确批准。
|
||||
|
||||
### 5. 前端体验
|
||||
|
||||
- PC 材料区明确说明“支持 PDF、JPG、PNG、WEBP;下载会留痕,OA 内不直接预览”。
|
||||
- 文件 input 同时声明允许扩展名和 MIME;用户绕过 `accept` 直接构造 multipart 时仍由服务端拒绝。
|
||||
- 保留现有用途填写弹窗和“下载”动作;显式隔离附件禁用下载,历史内容校验失败显示“附件未通过安全校验,请重新上传允许格式或联系管理员”。
|
||||
- 移动采购详情继续只展示安全元数据,不新增 iframe/object/embed、Blob 预览或在线 active-content 能力。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 合法 PDF、JPEG/JPG、PNG、WEBP 上传成功;保存的 key 随机且不含原 basename,MIME/大小/SHA-256 对应规范化字节。
|
||||
- [x] HTML、SVG、XML、JS、文本、未知格式、危险双扩展名、伪造 MIME、扩展名错配、损坏图片和截断 PDF均在写存储/建 DB 行前拒绝。
|
||||
- [x] 图片像素炸弹、多页/动画输入、规范化后超限和 multipart 第二文件均 fail closed,不挂起、不返回 500。
|
||||
- [x] 普通员工只能给本人草稿上传;无权限、非本人或非草稿请求不写存储、不建附件行。
|
||||
- [x] 采购附件不再签发 query bearer URL;旧签名 URL 无法取得文件,新下载必须携带当前认证并重新校验读取权限。
|
||||
- [x] 下载用途不出现在 URL;敏感附件缺用途仍返回 `400 VALID_001`,成功下载保留既有审计语义。
|
||||
- [x] 前端 Blob helper 实际发送 JSON header/body;敏感附件携用途时可下载,审计写入失败时响应无文件内容且非成功状态。
|
||||
- [x] 所有成功下载均为 `octet-stream + attachment + nosniff + no-store + no-referrer + sandbox`,响应中不存在 `inline`。
|
||||
- [x] 历史 HTML/SVG/文本、伪装类型、损坏或缺失文件均不返回内容;合法历史 PDF/图片只强制下载;任何历史文件都不被删除、移动或改写。
|
||||
- [x] CRLF、引号、路径分隔符、中文和超长原名不能注入响应头或逃逸存储根。
|
||||
- [x] PC 端显示允许格式,点击下载触发 browser download 而非新页面;移动详情不新增预览能力。
|
||||
- [x] R7E text/plain 失真 fixture 已替换为有效安全样本;新增服务端策略/路由测试和前端聚焦测试通过。
|
||||
- [x] Prisma validate(确认无 schema 漂移)、服务端聚焦测试、完整服务端测试、前端 typecheck/build、Trellis governance 和 `git diff --check` 通过。
|
||||
- [x] 独立只读 reviewer 复核上传、下载、历史兼容、权限、响应头和回滚,不修改代码。
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- 普通员工上传的 active content 无法入库;已有 active content 无法作为 OA 同源响应返回。
|
||||
- 采购附件只存在“授权后二进制下载”路径,不存在在线预览、query bearer URL 或客户端 MIME 直达响应头的路径。
|
||||
- 历史数据保持原样,异常文件以逻辑阻断和审计处理;无生产连接、真实附件扫描、部署或公网配置修改。
|
||||
- 自动化覆盖直接 multipart 绕过、历史恶意内容和浏览器下载行为,提交使用中文并引用 `07-17-purchase-attachment-xss-hardening`。
|
||||
|
||||
## Technical Approach
|
||||
|
||||
- 新增采购附件安全策略服务,使用 Node 内置字节检查与现有 Sharp;不增加 `file-type`、PDF 解析器或杀毒依赖。
|
||||
- 路由在 multipart 迭代中立即读入并规范化单个文件;写存储前完成全部校验。
|
||||
- 历史下载使用受限缓冲读取和同一策略复验;成功后返回安全二进制,失败时只写脱敏审计并拒绝。
|
||||
- 前后端共同移除 signed URL/window.open,复用现有认证 Blob 下载组件。
|
||||
- 不改 Prisma schema;业务治理状态仅用于人工隔离,不作为内容已安全的证明。
|
||||
|
||||
## Decision (ADR-lite)
|
||||
|
||||
**Context**:只把 `inline` 改成 `attachment` 可以立即切断同源执行,但仍会允许任意 active content 入库;只加 MIME allowlist 又会被伪造客户端 MIME绕过。新增持久化内容状态和批量历史扫描会引入生产数据迁移,却不能取代实际下载响应边界。
|
||||
|
||||
**Decision**:采用“双重实际字节校验 + 认证强制下载”。上传前严格规范化,下载前对新旧文件都复验;签名 URL/query token 退役;历史文件不迁移、不删除。显式业务隔离状态可提前阻断,但任何业务状态都不能绕过内容校验。
|
||||
|
||||
**Consequences**:文本、Office、GIF 和旧 `.txt` 附件不再可下载,需要转为 PDF 或安全图片重新上传;每次下载最多增加一次受限字节读取和图片解码。换来无需生产数据迁移、无状态陈旧、对历史伪装文件同样 fail closed 的边界。
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. 后端策略:新增文件名/扩展名/MIME/字节校验、PDF 边界检查、Sharp 规范化和安全 Content-Disposition 工具及单测。
|
||||
2. 上传路由:在 multipart 迭代中立即消费,先校验再写入;改用随机规范 key和服务端探测 MIME,补上传路由测试。
|
||||
3. 下载路由:以认证 POST Blob 替代签名 URL,重新校验权限、用途和历史内容,固定安全响应头与审计,补历史/权限/响应头测试。
|
||||
4. 前端与验收:增加 `accept`/说明,改用 Blob 下载,更新错误映射、R103 与 R7E fixture;不改移动预览能力。
|
||||
5. 收口:执行全量相关门禁,启动不改代码的独立安全 reviewer,按结论修正后提交;部署与真实数据操作另行申请。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 策略单测:四类合法格式;HTML/SVG/XML/JS/文本;类型错配;危险双扩展名;截断/损坏;图片尾随标记清除;PDF 前后 active content;APNG/animated WEBP;伪造大尺寸头;像素/页数/大小;文件名和 Content-Disposition。
|
||||
- 后端路由:multipart 文件在字段前/后、第二文件、错误字段、拒绝 MIME、空/超限和请求超时守卫;权限/草稿边界;随机 key、规范 MIME/hash;认证下载、JSON用途、历史恶意/合法/缺失对象、实际对象大于记录大小、显式隔离、审计失败和全部响应头。
|
||||
- 前端聚焦:input `accept` 与中文说明;服务端拒绝提示;download event 且无 popup;用途弹窗;隔离按钮;移动端只显示元数据。
|
||||
- 受影响 fixture:R103 与手工 R7E 采购附件改用内存有效 PDF/图片,不读取真实附件。
|
||||
- 静态/项目门禁:`cd server && npm run prisma:validate`;相关 `node --check`;服务端聚焦 Vitest 和完整 `npm test`;`cd client && npm run typecheck && npm run build`;聚焦 Playwright;`bash scripts/check-minimal.sh`;`node scripts/qa/check-trellis-governance.mjs`;`git diff --check`。
|
||||
- 不运行生产数据库、真实附件、企业微信、真实付款、部署或公网写入。
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `node --check`:采购策略、采购路由及对应两份单测均通过。
|
||||
- 聚焦 Vitest:策略 39/39、采购附件路由 26/26,合计 65/65 通过。
|
||||
- `bash scripts/check-minimal.sh`:受控本地权限下 7 段连续通过;服务端 157 个测试文件、896/896,通过 Prisma validate、前端 typecheck/build、治理、敏感内容与特性矩阵。
|
||||
- 聚焦 Playwright:R103 采购附件认证 JSON POST、Blob download、无 popup、隔离禁用和自然中文错误共 3/3 通过。
|
||||
- Trellis task validate、process baseline、Trellis governance 与 `git diff --check` 均通过。
|
||||
- 首轮沙箱门禁仅有临时 Git 状态与回环端口两项环境失败;受控权限隔离复跑 12/12 后,完整统一门禁连续 PASS。
|
||||
- 独立只读安全 reviewer 收口无剩余 P0/P1;Trellis 质量检查补齐超长用途截断和路径型 multipart 拒绝两项 P2,未改 schema、生产数据或部署配置。
|
||||
- 未连接生产/本地业务数据库,未读取真实附件,未运行 R7E live 写入,未部署、push 或修改公网代理。
|
||||
|
||||
## Rollout And Rollback
|
||||
|
||||
- 当前任务只实现和本地验证,不部署。上线前必须单独批准部署、数据库/存储备份和公网检查。
|
||||
- 前后端需同批发布,因为旧前端会调用被退役的 signed URL;切换窗口可暂时阻断采购附件上传与下载。
|
||||
- 部署前必须另行批准并验证边缘对采购附件上传、旧 signed URL 和旧 file 端点的精确拒绝规则;该门禁未满足时禁止部署。安全版本失败时优先向前修复,不能把脆弱旧二进制直接恢复为对外服务。
|
||||
- 无 schema/data migration,历史路径和字节不变;local/S3 均继续使用现有存储抽象。
|
||||
- 若格式策略出现兼容误判,可只调整 allowlist/校验实现;`attachment + octet-stream + nosniff`、重新授权和禁止同源预览不得回退。
|
||||
- 若必须回滚到旧应用版本,代理层必须继续阻断采购附件上传、signed URL 和 file 端点,直到安全版本恢复;不得让旧 `text/html + inline` 路径重新对外。
|
||||
- 历史批量扫描、物理隔离或生产状态写入均不在本 task 自动执行,需另行授权。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 全站 CSP enforce、Trusted Types、独立附件预览域、在线 PDF/图片预览。
|
||||
- 杀毒、恶意 PDF CDR、Office 转码、对象存储 ACL/MinIO 运维重构。
|
||||
- 报销附件、付款回单、员工合同、发票 signed URL 或其他模块的附件统一平台化。
|
||||
- 采购材料类型选择器、治理字段交互或附件信息架构重构;本 P0 只保留现有材料区并增加允许格式/下载边界。
|
||||
- 生产数据库/存储扫描、物理删除、移动、批量治理状态回填、真实附件修复。
|
||||
- 公网部署、反向代理修改、真实账号/附件验证、企业微信写入或付款动作。
|
||||
|
||||
## Research References
|
||||
|
||||
- [`research/attachment-security-design-research.md`](research/attachment-security-design-research.md) — 当前代码链路、历史兼容、方案比较和复用点。
|
||||
- `.trellis/tasks/07-16-system-multidimensional-audit/research/business-security-review.md` — `BSR-SEC-002` 前序只读审查证据。
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 当前复杂度为中高:后端上传/下载、历史兼容、前端下载和自动化跨层,但不涉及权限模型重构或生产迁移。
|
||||
- task priority 已从生成时的 `P2` 修正为已确认的 `P0`。
|
||||
- 本地实现、独立只读复核和统一门禁已完成;未读取真实附件、未连接任何业务数据库、未部署或修改公网配置。
|
||||
+89
@@ -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": {}
|
||||
}
|
||||
@@ -333,6 +333,52 @@ export function mapApiErrorMessage(
|
||||
return "请假冻结账本状态异常,请联系管理员处理。";
|
||||
}
|
||||
|
||||
if (details?.attachment === "security_validation_failed") {
|
||||
if (details?.reason === "quarantined") {
|
||||
return "附件已隔离,无法下载,请联系管理员。";
|
||||
}
|
||||
if (details?.reason === "missing_object") {
|
||||
return "附件文件不存在或已缺失,请联系管理员。";
|
||||
}
|
||||
return "附件未通过安全校验,请重新上传允许格式或联系管理员。";
|
||||
}
|
||||
|
||||
if (details?.file === "empty_file") {
|
||||
return "附件内容为空,请重新选择文件。";
|
||||
}
|
||||
|
||||
if (details?.file === "size_limit" || details?.file === "file_too_large") {
|
||||
return "附件超过大小限制,请压缩后重新上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "dangerous_filename") {
|
||||
return "附件名称不安全,请重命名后重新上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "unsupported_type") {
|
||||
return "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。";
|
||||
}
|
||||
|
||||
if (details?.file === "type_mismatch") {
|
||||
return "附件扩展名、声明类型与实际内容不一致,请重新选择原始文件或转换为支持格式。";
|
||||
}
|
||||
|
||||
if (details?.file === "corrupt_content") {
|
||||
return "附件已损坏或内容不完整,请重新导出后上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "pixel_limit") {
|
||||
return "图片尺寸过大,请压缩后重新上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "multiple_pages") {
|
||||
return "暂不支持多页或动画图片,请转换为 PDF 或单页安全图片。";
|
||||
}
|
||||
|
||||
if (details?.file === "only_single_file_supported") {
|
||||
return "一次只能上传一个附件,请重新选择。";
|
||||
}
|
||||
|
||||
if (details?.file === "required") {
|
||||
return "当前假种要求上传附件后才能提交。";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequestData } from "./http";
|
||||
import { apiRequestBlob, apiRequestData } from "./http";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import type { PrecheckResponse, RiskFlag } from "./feeControl";
|
||||
|
||||
@@ -386,11 +386,12 @@ export async function deletePurchaseAttachment(purchaseRequestId: string, attach
|
||||
);
|
||||
}
|
||||
|
||||
export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string, reason?: string) {
|
||||
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
|
||||
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`,
|
||||
export async function downloadPurchaseAttachment(purchaseRequestId: string, attachmentId: string, reason?: string) {
|
||||
return apiRequestBlob(
|
||||
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/download`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
archivePurchaseOrder,
|
||||
createPurchaseRequest,
|
||||
deletePurchaseAttachment,
|
||||
downloadPurchaseAttachment,
|
||||
fetchPurchaseOrderArchive,
|
||||
fetchPurchaseRequestDetail,
|
||||
fetchPurchaseRequests,
|
||||
issuePurchaseAttachmentSignedUrl,
|
||||
precheckPurchaseRequest,
|
||||
submitPurchaseRequest,
|
||||
updatePurchaseRequest,
|
||||
@@ -54,6 +54,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
formatApprovalHistoryTime,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
} from "../lib/approvalDisplay";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH = 180;
|
||||
|
||||
type DraftItem = {
|
||||
id: string;
|
||||
@@ -77,7 +79,7 @@ type DraftItem = {
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
return mapApiErrorMessage(error.code, error.details, error.message);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
@@ -85,6 +87,26 @@ function resolveError(error: unknown) {
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function sanitizePurchaseAttachmentDownloadName(value?: string) {
|
||||
const cleaned = (value ?? "")
|
||||
.normalize("NFC")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f/\\]+/g, "_")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]+/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^[\s._]+|[\s._]+$/g, "")
|
||||
.trim();
|
||||
const fallback = cleaned || "采购附件";
|
||||
const characters = Array.from(fallback);
|
||||
if (characters.length <= MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const extension = fallback.match(/\.[a-z0-9]{1,10}$/i)?.[0] ?? "";
|
||||
const stem = extension ? fallback.slice(0, -extension.length) : fallback;
|
||||
const stemLength = Math.max(1, MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH - Array.from(extension).length);
|
||||
return `${Array.from(stem).slice(0, stemLength).join("")}${extension}`;
|
||||
}
|
||||
|
||||
function moneyPreview(quantity: string, unitPrice: string) {
|
||||
const quantityNumber = Number(quantity);
|
||||
const unitPriceNumber = Number(unitPrice);
|
||||
@@ -717,13 +739,17 @@ export function PurchaseRequestsPage() {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
if (attachment.governanceStatus === "quarantined") {
|
||||
toast.error("附件已隔离,无法下载,请联系管理员。");
|
||||
return;
|
||||
}
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason);
|
||||
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
|
||||
const response = await downloadPurchaseAttachment(selectedId, attachment.id, reason);
|
||||
downloadBlob(response.blob, sanitizePurchaseAttachmentDownloadName(attachment.originalName));
|
||||
} catch (error) {
|
||||
const message = resolveError(error);
|
||||
setFormError(message);
|
||||
@@ -1566,7 +1592,9 @@ export function PurchaseRequestsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-zinc-900">材料证据</h4>
|
||||
<p className="text-sm text-zinc-500">用于报价、订单、验收或情况说明;草稿状态可增删,下载会留痕。</p>
|
||||
<p id="purchase-attachment-format-hint" className="text-sm text-zinc-500">
|
||||
用于报价、订单、验收或情况说明;支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<input
|
||||
@@ -1574,6 +1602,8 @@ export function PurchaseRequestsPage() {
|
||||
hidden
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp"
|
||||
aria-describedby="purchase-attachment-format-hint"
|
||||
disabled={!canEditDraft || uploadMutation.isPending}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -1624,7 +1654,15 @@ export function PurchaseRequestsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
|
||||
<Button
|
||||
data-testid={`purchase-attachment-download-mobile-${attachment.id}`}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={attachment.governanceStatus === "quarantined"}
|
||||
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
|
||||
onClick={() => void handleDownloadAttachment(attachment)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1666,7 +1704,15 @@ export function PurchaseRequestsPage() {
|
||||
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
|
||||
<Button
|
||||
data-testid={`purchase-attachment-download-desktop-${attachment.id}`}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={attachment.governanceStatus === "quarantined"}
|
||||
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
|
||||
onClick={() => void handleDownloadAttachment(attachment)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -134,6 +134,10 @@ const defaultCloseoutReimbursementId = "40876ef3-0652-4972-aa14-1aad1a855ae0";
|
||||
const defaultCloseoutReimbursementCode = "BX-20260514-0016";
|
||||
const wecomUa =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
const safePurchaseAttachmentPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function nowStamp() {
|
||||
return `${trialrunMarkerPrefix}-${Date.now()}`;
|
||||
@@ -1029,15 +1033,15 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("1");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("188");
|
||||
|
||||
const purchaseAttachmentName = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.txt`;
|
||||
const purchaseAttachmentName = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.png`;
|
||||
const uploadAttachmentResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`;
|
||||
});
|
||||
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
|
||||
name: purchaseAttachmentName,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(`${trialrunMarkerPrefix} purchase attachment ${marker}\n`, "utf-8"),
|
||||
mimeType: "image/png",
|
||||
buffer: safePurchaseAttachmentPng,
|
||||
});
|
||||
const uploadAttachmentResponse = await uploadAttachmentResponsePromise;
|
||||
if (!uploadAttachmentResponse.ok()) {
|
||||
|
||||
@@ -45,6 +45,12 @@ const me = {
|
||||
const purchaseId = "purchase-r103";
|
||||
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
||||
const invoiceId = "invoice-r103";
|
||||
const safePurchaseAttachmentId = "attachment-safe-r103";
|
||||
const quarantinedPurchaseAttachmentId = "attachment-quarantined-r103";
|
||||
const safePngBuffer = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||
@@ -97,7 +103,48 @@ function purchaseDetail() {
|
||||
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||
},
|
||||
],
|
||||
attachments: [],
|
||||
attachments: [
|
||||
{
|
||||
id: safePurchaseAttachmentId,
|
||||
originalName: "采购报价.png",
|
||||
mimeType: "image/png",
|
||||
fileSize: safePngBuffer.length,
|
||||
fileSha256: null,
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "quote",
|
||||
downloadPolicy: "reason_required",
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewNote: null,
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
uploadedBy: me.id,
|
||||
createdAt: "2026-05-06T09:20:00.000Z",
|
||||
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
},
|
||||
{
|
||||
id: quarantinedPurchaseAttachmentId,
|
||||
originalName: "历史隔离附件.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 128,
|
||||
fileSha256: null,
|
||||
governanceStatus: "quarantined",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "quote",
|
||||
downloadPolicy: "standard",
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewNote: null,
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
uploadedBy: me.id,
|
||||
createdAt: "2026-05-06T09:15:00.000Z",
|
||||
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
},
|
||||
],
|
||||
pendingWorkflow: null,
|
||||
archiveOrder: null,
|
||||
permissions: {
|
||||
@@ -300,12 +347,38 @@ async function mockApis(page: Page) {
|
||||
}
|
||||
|
||||
if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") {
|
||||
if (request.postDataBuffer()?.includes(Buffer.from('filename="unsupported.txt"'))) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r103-unsupported-attachment",
|
||||
error: {
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
details: { file: "unsupported_type" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
attachment: {
|
||||
id: "attachment-r103",
|
||||
originalName: "采购报价.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4,
|
||||
originalName: "采购报价.png",
|
||||
mimeType: "image/png",
|
||||
fileSize: safePngBuffer.length,
|
||||
fileSha256: null,
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "quote",
|
||||
downloadPolicy: "standard",
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewNote: null,
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
uploadedBy: me.id,
|
||||
createdAt: "2026-05-06T09:20:00.000Z",
|
||||
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
@@ -314,6 +387,25 @@ async function mockApis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/purchase-requests/${purchaseId}/attachments/${safePurchaseAttachmentId}/download` &&
|
||||
request.method() === "POST"
|
||||
) {
|
||||
expect(request.headers().authorization).toBe("Bearer mock-r103-token");
|
||||
expect(request.headers()["content-type"]).toContain("application/json");
|
||||
expect(request.postDataJSON()).toEqual({ reason: "采购复核" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": 'attachment; filename="purchase-attachment.png"',
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
body: safePngBuffer,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/invoice-pool") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] });
|
||||
return;
|
||||
@@ -342,7 +434,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
|
||||
test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/purchase-requests");
|
||||
|
||||
@@ -361,14 +453,44 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||
await expect(page.getByText("支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。")).toBeVisible();
|
||||
await expect(page.getByText("Choose File")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
|
||||
name: "采购报价.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("r103"),
|
||||
const attachmentInput = page.getByTestId("purchase-attachment-file-input");
|
||||
await expect(attachmentInput).toHaveAttribute(
|
||||
"accept",
|
||||
".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp",
|
||||
);
|
||||
await attachmentInput.setInputFiles({
|
||||
name: "采购报价.png",
|
||||
mimeType: "image/png",
|
||||
buffer: safePngBuffer,
|
||||
});
|
||||
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.pdf");
|
||||
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.png");
|
||||
|
||||
await attachmentInput.setInputFiles({
|
||||
name: "unsupported.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("synthetic unsupported attachment", "utf8"),
|
||||
});
|
||||
await expect(page.getByRole("main").getByText("仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。")).toBeVisible();
|
||||
|
||||
let popupOpened = false;
|
||||
page.on("popup", () => {
|
||||
popupOpened = true;
|
||||
});
|
||||
await page.getByTestId(`purchase-attachment-download-desktop-${safePurchaseAttachmentId}`).click();
|
||||
await expect(page.getByRole("heading", { name: "填写下载用途" })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "下载用途" }).fill("采购复核");
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "继续下载" }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("采购报价.png");
|
||||
expect(popupOpened).toBe(false);
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`purchase-attachment-download-desktop-${quarantinedPurchaseAttachmentId}`),
|
||||
).toBeDisabled();
|
||||
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
|
||||
});
|
||||
|
||||
|
||||
+361
-173
@@ -1,13 +1,9 @@
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { compactDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { parseDateOnlyString } = require("../utils/date");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
@@ -32,7 +28,13 @@ const {
|
||||
buildWorkflowEntityMeta,
|
||||
} = require("../services/notifications/workflowCopy");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const defaultStorage = require("../services/storage");
|
||||
const {
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
} = require("../services/purchaseAttachmentPolicy");
|
||||
const { dispatchNotificationBatch } = require("../services/notificationDispatcher");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
@@ -107,6 +109,12 @@ const PURCHASE_ARCHIVE_BODY_SCHEMA = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z
|
||||
.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PURCHASE_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
request_no: true,
|
||||
@@ -972,53 +980,156 @@ async function recomputePurchaseRequestTotal(tx, purchaseRequestId, actorId) {
|
||||
return nextTotal;
|
||||
}
|
||||
|
||||
async function persistAttachment({ config, purchaseRequestId, filePart }) {
|
||||
class PurchaseAttachmentSizeLimitError extends Error {
|
||||
constructor() {
|
||||
super("purchase_attachment_size_limit");
|
||||
this.name = "PurchaseAttachmentSizeLimitError";
|
||||
this.reasonCode = "size_limit";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAttachmentMaxBytes(config) {
|
||||
const configured = Number(config?.maxUploadSizeBytes);
|
||||
return Number.isSafeInteger(configured) && configured > 0 ? configured : 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
function destroyStream(stream) {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof stream.destroy === "function" && !stream.destroyed) {
|
||||
try {
|
||||
stream.destroy();
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to a Web Stream cancellation attempt.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof stream.cancel === "function") {
|
||||
try {
|
||||
Promise.resolve(stream.cancel()).catch(() => {});
|
||||
} catch {
|
||||
// The caller is already failing closed; cleanup must not replace the security error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedStream(stream, maxBytes) {
|
||||
const chunks = [];
|
||||
let fileSize = 0;
|
||||
let size = 0;
|
||||
|
||||
for await (const chunk of filePart.file) {
|
||||
fileSize += chunk.length;
|
||||
if (fileSize > config.maxUploadSizeBytes) {
|
||||
throw buildValidationError({ file: "file_too_large" });
|
||||
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(chunk);
|
||||
chunks.push(bufferChunk);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PurchaseAttachmentSizeLimitError) {
|
||||
throw error;
|
||||
}
|
||||
destroyStream(stream);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (fileSize <= 0) {
|
||||
throw buildValidationError({ file: "empty" });
|
||||
if (stream?.truncated) {
|
||||
destroyStream(stream);
|
||||
throw new PurchaseAttachmentSizeLimitError();
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
return Buffer.concat(chunks, size);
|
||||
}
|
||||
|
||||
const storagePath = await writeBuffer({
|
||||
config,
|
||||
key,
|
||||
buffer,
|
||||
contentType: filePart.mimetype,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
storagePath,
|
||||
originalName: filePart.filename || safeName,
|
||||
mimeType: filePart.mimetype || "application/octet-stream",
|
||||
fileSize,
|
||||
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
async function auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode,
|
||||
downloadReason,
|
||||
}) {
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
targetType: "purchase_attachment",
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
attachmentType: attachment?.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment?.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment?.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
reasonCode,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
errorCode: "purchase_attachment_block_audit_failed",
|
||||
reasonCode,
|
||||
},
|
||||
"Purchase attachment block audit failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId }) {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
errorCode: "purchase_attachment_storage_read_failed",
|
||||
},
|
||||
"Purchase attachment storage read failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "附件暂时无法读取,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "storage_read_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
|
||||
@@ -1361,7 +1472,8 @@ async function handlePurchaseArchiveErpSync({
|
||||
};
|
||||
}
|
||||
|
||||
async function purchaseRequestRoutes(fastify) {
|
||||
async function purchaseRequestRoutes(fastify, options = {}) {
|
||||
const attachmentStorage = options.attachmentStorage ?? defaultStorage;
|
||||
fastify.get(
|
||||
"/purchase-requests",
|
||||
{
|
||||
@@ -2227,37 +2339,93 @@ async function purchaseRequestRoutes(fastify) {
|
||||
throw buildValidationError({ status: "only_draft_editable" }, 409);
|
||||
}
|
||||
|
||||
let filePart = null;
|
||||
if (typeof request.isMultipart !== "function" || !request.isMultipart()) {
|
||||
throw buildValidationError({ file: "multipart_required" });
|
||||
}
|
||||
|
||||
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
|
||||
let normalizedFile = null;
|
||||
let governanceFields = {};
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
for await (const part of request.parts({ preservePath: true })) {
|
||||
if (part.type === "field") {
|
||||
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
||||
continue;
|
||||
}
|
||||
if (part.type === "file") {
|
||||
if (filePart) {
|
||||
if (normalizedFile) {
|
||||
await discardMultipartFile(part.file, maxBytes);
|
||||
throw buildValidationError({ file: "only_single_file_supported" });
|
||||
}
|
||||
filePart = part;
|
||||
|
||||
const fileBuffer = await readBoundedStream(part.file, maxBytes);
|
||||
if (part.fieldname !== "file") {
|
||||
throw buildValidationError({ file: "invalid_field" });
|
||||
}
|
||||
|
||||
normalizedFile = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: fileBuffer,
|
||||
fileName: part.filename,
|
||||
declaredMimeType: part.mimetype,
|
||||
maxBytes,
|
||||
normalizeImages: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
if (
|
||||
error instanceof PurchaseAttachmentSizeLimitError ||
|
||||
error?.code === "FST_REQ_FILE_TOO_LARGE"
|
||||
) {
|
||||
throw buildValidationError({ file: "file_too_large" });
|
||||
}
|
||||
if (error?.code === "FST_FILES_LIMIT") {
|
||||
throw buildValidationError({ file: "only_single_file_supported" });
|
||||
}
|
||||
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
|
||||
throw buildValidationError({ file: "multipart_limit_exceeded" });
|
||||
}
|
||||
if (error instanceof PurchaseAttachmentPolicyError) {
|
||||
throw buildValidationError(error.details ?? { file: error.reasonCode });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!filePart) {
|
||||
if (!normalizedFile) {
|
||||
throw buildValidationError({ file: "required" });
|
||||
}
|
||||
|
||||
const stored = await persistAttachment({
|
||||
let storagePath;
|
||||
try {
|
||||
storagePath = await attachmentStorage.writeBuffer({
|
||||
config: fastify.config,
|
||||
purchaseRequestId,
|
||||
filePart,
|
||||
key: buildAttachmentStorageKey(purchaseRequestId, normalizedFile.extension),
|
||||
buffer: normalizedFile.buffer,
|
||||
contentType: normalizedFile.mimeType,
|
||||
});
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
errorCode: "purchase_attachment_storage_write_failed",
|
||||
},
|
||||
"Purchase attachment storage write failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "附件暂时无法保存,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "storage_write_failed" },
|
||||
});
|
||||
}
|
||||
const stored = {
|
||||
storagePath,
|
||||
originalName: normalizedFile.originalName,
|
||||
mimeType: normalizedFile.mimeType,
|
||||
fileSize: normalizedFile.fileSize,
|
||||
fileSha256: normalizedFile.fileSha256,
|
||||
};
|
||||
|
||||
const attachment = await fastify.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.purchaseAttachment.create({
|
||||
@@ -2415,15 +2583,18 @@ async function purchaseRequestRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/purchase-requests/:id/attachments/:attachmentId/signed-url",
|
||||
"/purchase-requests/:id/attachments/:attachmentId/download",
|
||||
{
|
||||
schema: { tags: ["Purchase Requests"], summary: "Issue Purchase Attachment Signed URL" },
|
||||
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
ensurePurchaseReadPermission(fastify, request);
|
||||
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
||||
const attachmentId = parseAttachmentId(request.params?.attachmentId);
|
||||
const body = parsePurchaseAttachmentDownloadBody(request.body);
|
||||
const downloadReason = sanitizeDownloadReason(body.reason);
|
||||
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
|
||||
|
||||
await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request);
|
||||
|
||||
@@ -2434,116 +2605,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.body?.reason ?? request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
|
||||
const token = signPayload(
|
||||
{
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
actorId: request.employee.id,
|
||||
reason: downloadReason,
|
||||
exp: expiresAt,
|
||||
},
|
||||
fastify.config.jwtSecret,
|
||||
);
|
||||
const origin =
|
||||
fastify.config.appOrigin ||
|
||||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
|
||||
const signedUrl = `${origin}/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/file?token=${encodeURIComponent(token)}`;
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PURCHASE_ATTACHMENT_SIGNED_URL_ISSUED",
|
||||
targetType: "purchase_attachment",
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
expiresAt,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
signedUrl,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/purchase-requests/:id/attachments/:attachmentId/file",
|
||||
{
|
||||
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment By Token" },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
||||
const attachmentId = parseAttachmentId(request.params?.attachmentId);
|
||||
const token = request.query?.token;
|
||||
|
||||
if (typeof token !== "string") {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const verified = verifyToken(token, fastify.config.jwtSecret);
|
||||
if (!verified.ok) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const actorId = UUID_SCHEMA.safeParse(verified.payload?.actorId);
|
||||
if (
|
||||
verified.payload?.purchaseRequestId !== purchaseRequestId ||
|
||||
verified.payload?.attachmentId !== attachmentId ||
|
||||
typeof verified.payload?.exp !== "number" ||
|
||||
verified.payload.exp <= Date.now() ||
|
||||
!actorId.success
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
|
||||
where: {
|
||||
id: attachmentId,
|
||||
purchase_request_id: purchaseRequestId,
|
||||
},
|
||||
select: {
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
original_name: true,
|
||||
file_size: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -2558,38 +2623,138 @@ async function purchaseRequestRoutes(fastify) {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
let opened = null;
|
||||
if (attachment.governance_status === "quarantined") {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "quarantined",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError("quarantined", "附件已隔离,无法下载");
|
||||
}
|
||||
|
||||
const recordedSize = Number(attachment.file_size);
|
||||
if (Number.isFinite(recordedSize) && recordedSize > maxBytes) {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "size_limit",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError("size_limit");
|
||||
}
|
||||
|
||||
let opened;
|
||||
try {
|
||||
opened = await openReadStream({
|
||||
opened = await attachmentStorage.openReadStream({
|
||||
config: fastify.config,
|
||||
storagePath: attachment.storage_path,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
if (
|
||||
(typeof attachmentStorage.StorageNotFoundError === "function" &&
|
||||
error instanceof attachmentStorage.StorageNotFoundError) ||
|
||||
error?.name === "StorageNotFoundError"
|
||||
) {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "missing_object",
|
||||
downloadReason,
|
||||
});
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
message: "附件不存在",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "storage_error",
|
||||
downloadReason,
|
||||
});
|
||||
throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId });
|
||||
}
|
||||
|
||||
let storedBuffer;
|
||||
try {
|
||||
storedBuffer = await readBoundedStream(opened.stream, maxBytes);
|
||||
} catch (error) {
|
||||
if (error instanceof PurchaseAttachmentSizeLimitError) {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "size_limit",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError("size_limit");
|
||||
}
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "storage_error",
|
||||
downloadReason,
|
||||
});
|
||||
throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId });
|
||||
}
|
||||
|
||||
let validated;
|
||||
try {
|
||||
validated = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: storedBuffer,
|
||||
fileName: attachment.original_name,
|
||||
declaredMimeType: attachment.mime_type,
|
||||
maxBytes,
|
||||
normalizeImages: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof PurchaseAttachmentPolicyError)) {
|
||||
throw error;
|
||||
}
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: error.reasonCode,
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError(error.reasonCode);
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: actorId.data,
|
||||
actorId: request.employee.id,
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
||||
targetType: "purchase_attachment",
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
exp: verified.payload.exp,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
@@ -2597,16 +2762,39 @@ async function purchaseRequestRoutes(fastify) {
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, attachmentId }, "Audit log write failed");
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
errorCode: "purchase_attachment_download_audit_failed",
|
||||
},
|
||||
"Purchase attachment download audit failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "附件下载留痕失败,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "audit_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
reply.type(attachment.mime_type || "application/octet-stream");
|
||||
reply.type("application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeFilename(attachment.original_name)}\"`,
|
||||
buildAttachmentContentDisposition(validated.originalName),
|
||||
);
|
||||
return reply.send(opened.stream);
|
||||
reply.header("x-content-type-options", "nosniff");
|
||||
reply.header("cache-control", "private, no-store, max-age=0");
|
||||
reply.header("pragma", "no-cache");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header(
|
||||
"content-security-policy",
|
||||
"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
);
|
||||
reply.header("cross-origin-resource-policy", "same-origin");
|
||||
return reply.send(validated.buffer);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sharp = require("sharp");
|
||||
|
||||
const PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS = 40_000_000;
|
||||
const PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS = 180;
|
||||
|
||||
const PDF_HEADER_PREFIX = Buffer.from("%PDF-", "ascii");
|
||||
const PDF_EOF_MARKER = Buffer.from("%%EOF", "ascii");
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const PNG_IEND_CHUNK = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x49, 0x45, 0x4e, 0x44,
|
||||
0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
pdf: Object.freeze({
|
||||
extension: ".pdf",
|
||||
mimeType: "application/pdf",
|
||||
declaredMimeTypes: new Set(["application/pdf"]),
|
||||
}),
|
||||
jpeg: Object.freeze({
|
||||
extension: ".jpg",
|
||||
mimeType: "image/jpeg",
|
||||
declaredMimeTypes: new Set(["image/jpeg", "image/jpg"]),
|
||||
}),
|
||||
png: Object.freeze({
|
||||
extension: ".png",
|
||||
mimeType: "image/png",
|
||||
declaredMimeTypes: new Set(["image/png"]),
|
||||
}),
|
||||
webp: Object.freeze({
|
||||
extension: ".webp",
|
||||
mimeType: "image/webp",
|
||||
declaredMimeTypes: new Set(["image/webp"]),
|
||||
}),
|
||||
});
|
||||
|
||||
const TYPE_BY_EXTENSION = new Map([
|
||||
[".pdf", "pdf"],
|
||||
[".jpg", "jpeg"],
|
||||
[".jpeg", "jpeg"],
|
||||
[".png", "png"],
|
||||
[".webp", "webp"],
|
||||
]);
|
||||
const TYPE_BY_DECLARED_MIME = new Map(
|
||||
Object.entries(CONTENT_TYPES).flatMap(([type, descriptor]) =>
|
||||
[...descriptor.declaredMimeTypes].map((mimeType) => [mimeType, type]),
|
||||
),
|
||||
);
|
||||
const CANONICAL_EXTENSIONS = new Set(
|
||||
Object.values(CONTENT_TYPES).map((descriptor) => descriptor.extension),
|
||||
);
|
||||
const DANGEROUS_INTERMEDIATE_EXTENSIONS = new Set([
|
||||
"asp",
|
||||
"aspx",
|
||||
"cjs",
|
||||
"css",
|
||||
"htm",
|
||||
"html",
|
||||
"js",
|
||||
"jsp",
|
||||
"mjs",
|
||||
"php",
|
||||
"phtml",
|
||||
"shtml",
|
||||
"svg",
|
||||
"wasm",
|
||||
"xht",
|
||||
"xhtml",
|
||||
"xml",
|
||||
]);
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
||||
const PATH_SEPARATORS = /[\\/]/u;
|
||||
const BIDI_AND_INVISIBLE_CONTROLS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u;
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
||||
|
||||
class PurchaseAttachmentPolicyError extends Error {
|
||||
constructor(reasonCode) {
|
||||
super("purchase_attachment_rejected");
|
||||
this.name = "PurchaseAttachmentPolicyError";
|
||||
this.code = "PURCHASE_ATTACHMENT_POLICY_ERROR";
|
||||
this.reasonCode = reasonCode;
|
||||
this.details = { file: reasonCode };
|
||||
}
|
||||
}
|
||||
|
||||
function reject(reasonCode) {
|
||||
throw new PurchaseAttachmentPolicyError(reasonCode);
|
||||
}
|
||||
|
||||
function assertMaxBytes(maxBytes) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new TypeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateCodePoints(value, maxCodePoints) {
|
||||
const codePoints = Array.from(value);
|
||||
return codePoints.length <= maxCodePoints
|
||||
? value
|
||||
: codePoints.slice(0, maxCodePoints).join("");
|
||||
}
|
||||
|
||||
function normalizeAndValidateFileName(fileName) {
|
||||
if (typeof fileName !== "string") {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const normalized = fileName.normalize("NFC").trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
CONTROL_CHARACTERS.test(normalized) ||
|
||||
PATH_SEPARATORS.test(normalized) ||
|
||||
BIDI_AND_INVISIBLE_CONTROLS.test(normalized)
|
||||
) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const lastDotIndex = normalized.lastIndexOf(".");
|
||||
if (lastDotIndex <= 0 || lastDotIndex === normalized.length - 1) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const suppliedExtension = normalized.slice(lastDotIndex).toLowerCase();
|
||||
const expectedType = TYPE_BY_EXTENSION.get(suppliedExtension);
|
||||
if (!expectedType) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const stem = normalized.slice(0, lastDotIndex);
|
||||
const stemSegments = stem.split(".");
|
||||
if (
|
||||
stemSegments.some((segment) => !segment) ||
|
||||
stemSegments.slice(1).some((segment) =>
|
||||
DANGEROUS_INTERMEDIATE_EXTENSIONS.has(segment.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const canonicalExtension = CONTENT_TYPES[expectedType].extension;
|
||||
const maxStemLength = Math.max(
|
||||
1,
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS - Array.from(canonicalExtension).length,
|
||||
);
|
||||
const truncatedStem = truncateCodePoints(stem, maxStemLength).trim();
|
||||
if (!truncatedStem) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
return {
|
||||
expectedType,
|
||||
originalName: `${truncatedStem}${canonicalExtension}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeclaredMimeType(declaredMimeType) {
|
||||
if (declaredMimeType == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof declaredMimeType !== "string") {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const normalized = declaredMimeType.trim().toLowerCase();
|
||||
if (!normalized || normalized === "application/octet-stream") {
|
||||
return null;
|
||||
}
|
||||
if (!TYPE_BY_DECLARED_MIME.has(normalized)) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function detectSignatureType(buffer) {
|
||||
if (buffer.length >= PDF_HEADER_PREFIX.length && buffer.subarray(0, PDF_HEADER_PREFIX.length).equals(PDF_HEADER_PREFIX)) {
|
||||
return "pdf";
|
||||
}
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
if (buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||
buffer.toString("ascii", 8, 12) === "WEBP"
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePdfBoundaries(buffer) {
|
||||
const header = buffer.toString("ascii", 0, Math.min(buffer.length, 16));
|
||||
if (!/^%PDF-(?:1\.[0-7]|2\.0)(?:\r\n|\r|\n)/u.test(header)) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
|
||||
let lastContentIndex = buffer.length - 1;
|
||||
while (
|
||||
lastContentIndex >= 0 &&
|
||||
[0x09, 0x0a, 0x0c, 0x0d, 0x20].includes(buffer[lastContentIndex])
|
||||
) {
|
||||
lastContentIndex -= 1;
|
||||
}
|
||||
const markerStart = lastContentIndex - PDF_EOF_MARKER.length + 1;
|
||||
if (
|
||||
markerStart < 0 ||
|
||||
!buffer.subarray(markerStart, lastContentIndex + 1).equals(PDF_EOF_MARKER)
|
||||
) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateJpegBoundaries(buffer) {
|
||||
if (buffer.length < 4 || buffer[buffer.length - 2] !== 0xff || buffer[buffer.length - 1] !== 0xd9) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePngBoundaries(buffer) {
|
||||
if (
|
||||
buffer.length < PNG_SIGNATURE.length + PNG_IEND_CHUNK.length ||
|
||||
!buffer.subarray(buffer.length - PNG_IEND_CHUNK.length).equals(PNG_IEND_CHUNK)
|
||||
) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
|
||||
let offset = PNG_SIGNATURE.length;
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 12 > buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
const dataLength = buffer.readUInt32BE(offset);
|
||||
const chunkEnd = offset + 12 + dataLength;
|
||||
if (chunkEnd > buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
if (chunkType === "acTL") {
|
||||
reject("multiple_pages");
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
}
|
||||
|
||||
function validateWebpBoundaries(buffer) {
|
||||
if (buffer.length < 12 || buffer.readUInt32LE(4) + 8 !== buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormatBoundaries(type, buffer) {
|
||||
if (type === "pdf") {
|
||||
validatePdfBoundaries(buffer);
|
||||
} else if (type === "jpeg") {
|
||||
validateJpegBoundaries(buffer);
|
||||
} else if (type === "png") {
|
||||
validatePngBoundaries(buffer);
|
||||
} else if (type === "webp") {
|
||||
validateWebpBoundaries(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function mapSharpFailure(error) {
|
||||
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
||||
reject(message.includes("pixel limit") ? "pixel_limit" : "corrupt_content");
|
||||
}
|
||||
|
||||
async function inspectImage(buffer, expectedType) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(buffer, {
|
||||
animated: true,
|
||||
failOn: "warning",
|
||||
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
sequentialRead: true,
|
||||
}).metadata();
|
||||
} catch (error) {
|
||||
mapSharpFailure(error);
|
||||
}
|
||||
|
||||
if (metadata.format !== expectedType || !metadata.width || !metadata.height) {
|
||||
reject(metadata.format ? "type_mismatch" : "corrupt_content");
|
||||
}
|
||||
|
||||
const pages = metadata.pages ?? 1;
|
||||
if (!Number.isSafeInteger(pages) || pages !== 1) {
|
||||
reject("multiple_pages");
|
||||
}
|
||||
|
||||
if (metadata.width * metadata.height > PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS) {
|
||||
reject("pixel_limit");
|
||||
}
|
||||
|
||||
let normalizedBuffer;
|
||||
try {
|
||||
const pipeline = sharp(buffer, {
|
||||
failOn: "warning",
|
||||
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
pages: 1,
|
||||
sequentialRead: true,
|
||||
}).rotate();
|
||||
|
||||
if (expectedType === "jpeg") {
|
||||
normalizedBuffer = await pipeline.jpeg({ quality: 90 }).toBuffer();
|
||||
} else if (expectedType === "png") {
|
||||
normalizedBuffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();
|
||||
} else {
|
||||
normalizedBuffer = await pipeline.webp({ quality: 90, effort: 4 }).toBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
mapSharpFailure(error);
|
||||
}
|
||||
|
||||
validateFormatBoundaries(expectedType, normalizedBuffer);
|
||||
return normalizedBuffer;
|
||||
}
|
||||
|
||||
async function validateAndNormalizePurchaseAttachment({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes,
|
||||
normalizeImages = true,
|
||||
}) {
|
||||
assertMaxBytes(maxBytes);
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
reject("empty_file");
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
reject("size_limit");
|
||||
}
|
||||
if (typeof normalizeImages !== "boolean") {
|
||||
throw new TypeError("normalizeImages must be a boolean");
|
||||
}
|
||||
|
||||
const { expectedType, originalName } = normalizeAndValidateFileName(fileName);
|
||||
const normalizedDeclaredMimeType = normalizeDeclaredMimeType(declaredMimeType);
|
||||
if (
|
||||
normalizedDeclaredMimeType &&
|
||||
TYPE_BY_DECLARED_MIME.get(normalizedDeclaredMimeType) !== expectedType
|
||||
) {
|
||||
reject("type_mismatch");
|
||||
}
|
||||
|
||||
const detectedType = detectSignatureType(buffer);
|
||||
if (!detectedType) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
if (detectedType !== expectedType) {
|
||||
reject("type_mismatch");
|
||||
}
|
||||
|
||||
validateFormatBoundaries(detectedType, buffer);
|
||||
|
||||
let normalizedBuffer = buffer;
|
||||
if (detectedType !== "pdf") {
|
||||
const inspectedBuffer = await inspectImage(buffer, detectedType);
|
||||
if (normalizeImages) {
|
||||
if (inspectedBuffer.length > maxBytes) {
|
||||
reject("size_limit");
|
||||
}
|
||||
normalizedBuffer = inspectedBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = CONTENT_TYPES[detectedType];
|
||||
return {
|
||||
buffer: normalizedBuffer,
|
||||
originalName,
|
||||
extension: descriptor.extension,
|
||||
mimeType: descriptor.mimeType,
|
||||
fileSize: normalizedBuffer.length,
|
||||
fileSha256: crypto.createHash("sha256").update(normalizedBuffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachmentStorageKey(purchaseRequestId, extension) {
|
||||
if (typeof purchaseRequestId !== "string" || !UUID_PATTERN.test(purchaseRequestId)) {
|
||||
throw new TypeError("purchaseRequestId must be a UUID");
|
||||
}
|
||||
if (!CANONICAL_EXTENSIONS.has(extension)) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
return `purchase-requests/${purchaseRequestId.toLowerCase()}/${crypto.randomUUID()}${extension}`;
|
||||
}
|
||||
|
||||
function sanitizeHeaderFileName(fileName) {
|
||||
const source = typeof fileName === "string" && fileName.trim()
|
||||
? fileName.normalize("NFC").trim()
|
||||
: "attachment.bin";
|
||||
const sanitized = truncateCodePoints(
|
||||
source
|
||||
.replace(/[\\/]/gu, "_")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "_")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, "_")
|
||||
.replace(/"+/gu, "_")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
);
|
||||
return sanitized && sanitized !== "." && sanitized !== ".."
|
||||
? sanitized
|
||||
: "attachment.bin";
|
||||
}
|
||||
|
||||
function buildAsciiFallback(fileName) {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
const rawExtension = lastDotIndex > 0 ? fileName.slice(lastDotIndex).toLowerCase() : ".bin";
|
||||
const extension = /^\.[a-z0-9]{1,10}$/u.test(rawExtension) ? rawExtension : ".bin";
|
||||
const rawStem = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
|
||||
const asciiStem = rawStem
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/gu, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/gu, "_")
|
||||
.replace(/_+/gu, "_")
|
||||
.replace(/^[._-]+|[._-]+$/gu, "")
|
||||
.slice(0, 100);
|
||||
return `${asciiStem || "attachment"}${extension}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987Value(value) {
|
||||
return encodeURIComponent(value).replace(/[\u0027\u0028\u0029\u002a]/gu, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentContentDisposition(fileName) {
|
||||
const safeFileName = sanitizeHeaderFileName(fileName);
|
||||
const asciiFallback = buildAsciiFallback(safeFileName);
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeRfc5987Value(safeFileName)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANONICAL_PURCHASE_ATTACHMENT_EXTENSIONS: Object.freeze([...CANONICAL_EXTENSIONS]),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
};
|
||||
@@ -0,0 +1,814 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { Readable } = require("node:stream");
|
||||
|
||||
const Fastify = require("fastify");
|
||||
const multipart = require("@fastify/multipart");
|
||||
|
||||
const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ATTACHMENT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const OTHER_EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const NOW = new Date("2026-07-17T08:00:00.000Z");
|
||||
const VALID_PDF = Buffer.from(
|
||||
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
|
||||
"ascii",
|
||||
);
|
||||
|
||||
class TestStorageNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("not_found");
|
||||
this.name = "StorageNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildMultipartRequest(parts) {
|
||||
const boundary = "----codex-purchase-attachment-boundary";
|
||||
const segments = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "field") {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"\r\n\r\n${part.value}\r\n`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${part.fieldname ?? "file"}"; filename="${part.filename}"\r\nContent-Type: ${part.contentType}\r\n\r\n`,
|
||||
),
|
||||
Buffer.isBuffer(part.content) ? part.content : Buffer.from(part.content),
|
||||
Buffer.from("\r\n"),
|
||||
);
|
||||
}
|
||||
|
||||
segments.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
return {
|
||||
payload: Buffer.concat(segments),
|
||||
headers: {
|
||||
authorization: "Bearer unit-test",
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPurchaseRequest(overrides = {}) {
|
||||
return {
|
||||
id: PURCHASE_REQUEST_ID,
|
||||
applicant_id: EMPLOYEE_ID,
|
||||
submitter_id: null,
|
||||
status: "draft",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachment(overrides = {}) {
|
||||
return {
|
||||
id: ATTACHMENT_ID,
|
||||
purchase_request_id: PURCHASE_REQUEST_ID,
|
||||
storage_path: `purchase-requests/${PURCHASE_REQUEST_ID}/${ATTACHMENT_ID}.pdf`,
|
||||
original_name: "采购报价.pdf",
|
||||
mime_type: "application/pdf",
|
||||
file_size: VALID_PDF.length,
|
||||
governance_status: "approved",
|
||||
sensitivity_level: "internal",
|
||||
attachment_type: "quote",
|
||||
download_policy: "standard",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = null,
|
||||
purchaseRequest = undefined,
|
||||
attachment = undefined,
|
||||
maxUploadSizeBytes = 1024 * 1024,
|
||||
storageOverrides = {},
|
||||
auditCreate = null,
|
||||
} = {}) {
|
||||
const effectiveActor = actor ?? {
|
||||
id: EMPLOYEE_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["procurement.create", "procurement.view_own"],
|
||||
};
|
||||
const state = {
|
||||
purchaseRequest: purchaseRequest === undefined ? buildPurchaseRequest() : purchaseRequest,
|
||||
attachment: attachment === undefined ? buildAttachment() : attachment,
|
||||
createdAttachmentData: null,
|
||||
};
|
||||
|
||||
const uploadAuditCreate = vi.fn().mockResolvedValue({ id: "upload-audit" });
|
||||
const downloadAuditCreate = auditCreate ?? vi.fn().mockResolvedValue({ id: "download-audit" });
|
||||
const attachmentCreate = vi.fn(async ({ data }) => {
|
||||
state.createdAttachmentData = data;
|
||||
return {
|
||||
id: ATTACHMENT_ID,
|
||||
original_name: data.original_name,
|
||||
mime_type: data.mime_type,
|
||||
file_size: data.file_size,
|
||||
file_sha256: data.file_sha256,
|
||||
governance_status: data.governance_status,
|
||||
sensitivity_level: data.sensitivity_level,
|
||||
attachment_type: data.attachment_type,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
review_note: null,
|
||||
archived_by: null,
|
||||
archived_at: null,
|
||||
archive_note: null,
|
||||
download_policy: data.download_policy,
|
||||
uploaded_by: data.uploaded_by,
|
||||
created_at: NOW,
|
||||
uploader: {
|
||||
id: effectiveActor.id,
|
||||
employee_no: "E001",
|
||||
name: "测试员工",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const tx = {
|
||||
purchaseAttachment: { create: attachmentCreate },
|
||||
auditLog: { create: uploadAuditCreate },
|
||||
};
|
||||
const prisma = {
|
||||
purchaseRequest: {
|
||||
findUnique: vi.fn(async () => state.purchaseRequest),
|
||||
},
|
||||
purchaseAttachment: {
|
||||
findFirst: vi.fn(async ({ where }) => {
|
||||
if (!state.attachment) return null;
|
||||
return where.id === state.attachment.id &&
|
||||
where.purchase_request_id === state.attachment.purchase_request_id
|
||||
? state.attachment
|
||||
: null;
|
||||
}),
|
||||
},
|
||||
auditLog: { create: downloadAuditCreate },
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
};
|
||||
|
||||
const storage = {
|
||||
StorageNotFoundError: TestStorageNotFoundError,
|
||||
writeBuffer: vi.fn(async ({ key }) => key),
|
||||
openReadStream: vi.fn(async () => ({ stream: Readable.from([VALID_PDF]) })),
|
||||
...storageOverrides,
|
||||
};
|
||||
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
maxUploadSizeBytes,
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const permissionCodes = employee?.permissionCodes ?? [];
|
||||
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (typeof request.headers.authorization !== "string") {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = effectiveActor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
if (!app.hasPermission(request.employee, permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(multipart, {
|
||||
attachFieldsToBody: false,
|
||||
limits: { files: 1, fileSize: maxUploadSizeBytes },
|
||||
});
|
||||
await app.register(purchaseRequestRoutes, {
|
||||
prefix: "/api",
|
||||
attachmentStorage: storage,
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
attachmentCreate,
|
||||
downloadAuditCreate,
|
||||
prisma,
|
||||
state,
|
||||
storage,
|
||||
uploadAuditCreate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("purchase attachment upload", () => {
|
||||
it("consumes the file immediately, accepts trailing governance fields, and persists canonical bytes", async () => {
|
||||
const { app, attachmentCreate, state, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "采购报价.v2.pdf",
|
||||
contentType: "application/octet-stream",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
{ type: "field", name: "sensitivityLevel", value: "confidential" },
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
|
||||
const writeInput = storage.writeBuffer.mock.calls[0][0];
|
||||
expect(writeInput.key).toMatch(
|
||||
new RegExp(`^purchase-requests/${PURCHASE_REQUEST_ID}/[0-9a-f-]{36}\\.pdf$`),
|
||||
);
|
||||
expect(writeInput.key).not.toContain("采购报价");
|
||||
expect(writeInput.contentType).toBe("application/pdf");
|
||||
expect(writeInput.buffer.equals(VALID_PDF)).toBe(true);
|
||||
expect(state.createdAttachmentData).toEqual(
|
||||
expect.objectContaining({
|
||||
original_name: "采购报价.v2.pdf",
|
||||
mime_type: "application/pdf",
|
||||
file_size: VALID_PDF.length,
|
||||
sensitivity_level: "confidential",
|
||||
}),
|
||||
);
|
||||
expect(state.createdAttachmentData.file_sha256).toBe(
|
||||
crypto.createHash("sha256").update(writeInput.buffer).digest("hex"),
|
||||
);
|
||||
expect(attachmentCreate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts governance fields before the file without hanging", async () => {
|
||||
const { app, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{ type: "field", name: "attachmentType", value: "acceptance" },
|
||||
{
|
||||
type: "file",
|
||||
filename: "验收.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["non multipart", null, { file: "multipart_required" }],
|
||||
[
|
||||
"wrong file field",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
fieldname: "payload",
|
||||
filename: "报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
{ file: "invalid_field" },
|
||||
],
|
||||
[
|
||||
"path-shaped filename",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "../报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
{ file: "dangerous_filename" },
|
||||
],
|
||||
[
|
||||
"active content",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: "<!doctype html><script>void 0</script>",
|
||||
},
|
||||
]),
|
||||
null,
|
||||
],
|
||||
[
|
||||
"empty content",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "空附件.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: Buffer.alloc(0),
|
||||
},
|
||||
]),
|
||||
{ file: "empty_file" },
|
||||
],
|
||||
])("rejects %s before storage or database writes", async (_label, multipartRequest, expectedDetails) => {
|
||||
const { app, attachmentCreate, storage, uploadAuditCreate } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
headers: multipartRequest?.headers ?? { authorization: "Bearer unit-test" },
|
||||
payload: multipartRequest?.payload,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.code).toBe("VALID_001");
|
||||
if (expectedDetails) {
|
||||
expect(response.json().error.details).toEqual(expectedDetails);
|
||||
}
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
expect(uploadAuditCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps the production single-file plugin limit to a stable validation error", async () => {
|
||||
const { app, attachmentCreate, storage } = await buildApp();
|
||||
const request = buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价一.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价二.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...request,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ file: "only_single_file_supported" });
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps the multipart byte limit to a stable validation error", async () => {
|
||||
const { app, attachmentCreate, storage } = await buildApp({ maxUploadSizeBytes: 32 });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "超限.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ file: "file_too_large" });
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing permission", { permissionCodes: ["procurement.view_own"] }, buildPurchaseRequest(), 403],
|
||||
[
|
||||
"another applicant",
|
||||
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
|
||||
buildPurchaseRequest({ applicant_id: OTHER_EMPLOYEE_ID }),
|
||||
403,
|
||||
],
|
||||
[
|
||||
"non-draft request",
|
||||
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
|
||||
buildPurchaseRequest({ status: "reviewing" }),
|
||||
409,
|
||||
],
|
||||
])("does not write upload data for %s", async (_label, actorInput, purchaseRequest, statusCode) => {
|
||||
const { app, attachmentCreate, storage } = await buildApp({
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
roleCodes: ["employee"],
|
||||
...actorInput,
|
||||
},
|
||||
purchaseRequest,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(statusCode);
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchase attachment authenticated download", () => {
|
||||
const downloadUrl = `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/download`;
|
||||
|
||||
it("requires current authentication before reading storage", async () => {
|
||||
const { app, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: downloadUrl, payload: {} });
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"missing purchase read permission",
|
||||
{
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["procurement.create"],
|
||||
},
|
||||
},
|
||||
403,
|
||||
],
|
||||
[
|
||||
"another employee's purchase request",
|
||||
{
|
||||
purchaseRequest: buildPurchaseRequest({
|
||||
applicant_id: OTHER_EMPLOYEE_ID,
|
||||
submitter_id: OTHER_EMPLOYEE_ID,
|
||||
}),
|
||||
},
|
||||
403,
|
||||
],
|
||||
["an attachment outside the purchase request", { attachment: null }, 404],
|
||||
])("rejects %s before opening storage", async (_label, options, statusCode) => {
|
||||
const { app, storage } = await buildApp(options);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(statusCode);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a JSON-body reason for sensitive attachments", async () => {
|
||||
const { app, storage } = await buildApp({
|
||||
attachment: buildAttachment({
|
||||
sensitivity_level: "confidential",
|
||||
download_policy: "reason_required",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ reason: "required" });
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates an overlong download reason before writing audit metadata", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
attachment: buildAttachment({
|
||||
sensitivity_level: "confidential",
|
||||
download_policy: "reason_required",
|
||||
}),
|
||||
});
|
||||
const reason = "复".repeat(201);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: {
|
||||
authorization: "Bearer unit-test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
||||
extra: expect.objectContaining({ reason: "复".repeat(200) }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks quarantined attachments before storage and records a fixed reason", async () => {
|
||||
const { app, downloadAuditCreate, storage } = await buildApp({
|
||||
attachment: buildAttachment({ governance_status: "quarantined" }),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
extra: expect.objectContaining({ reasonCode: "quarantined" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks a recorded object above the hard limit before opening storage", async () => {
|
||||
const { app, storage } = await buildApp({
|
||||
maxUploadSizeBytes: 64,
|
||||
attachment: buildAttachment({ file_size: 65 }),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("size_limit");
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a safe not-found response and blocked audit when the stored object is missing", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
storageOverrides: {
|
||||
openReadStream: vi.fn(async () => {
|
||||
throw new TestStorageNotFoundError();
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error.message).toBe("附件不存在");
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
extra: expect.objectContaining({ reasonCode: "missing_object" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an actual object larger than the configured limit and destroys its stream", async () => {
|
||||
const oversizedStream = {
|
||||
destroyed: false,
|
||||
destroy: vi.fn(function destroy() {
|
||||
this.destroyed = true;
|
||||
}),
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield Buffer.alloc(65, 0x41);
|
||||
},
|
||||
};
|
||||
const { app, storage } = await buildApp({
|
||||
maxUploadSizeBytes: 64,
|
||||
attachment: buildAttachment({ file_size: 1 }),
|
||||
storageOverrides: {
|
||||
openReadStream: vi.fn(async () => ({ stream: oversizedStream })),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("size_limit");
|
||||
expect(oversizedStream.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(storage.openReadStream).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks historical active content after byte validation and sends no attachment headers", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
storageOverrides: {
|
||||
openReadStream: vi.fn(async () => ({
|
||||
stream: Readable.from([Buffer.from("<!doctype html><script>void 0</script>")]),
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.headers["content-disposition"]).toBeUndefined();
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a validated historical attachment with the complete forced-download header contract", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
attachment: buildAttachment({
|
||||
mime_type: "application/octet-stream",
|
||||
sensitivity_level: "confidential",
|
||||
download_policy: "reason_required",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: {
|
||||
authorization: "Bearer unit-test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: JSON.stringify({ reason: "审批复核" }),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.rawPayload.equals(VALID_PDF)).toBe(true);
|
||||
expect(response.headers["content-type"]).toContain("application/octet-stream");
|
||||
expect(response.headers["content-disposition"]).toContain("attachment;");
|
||||
expect(response.headers["content-disposition"]).toContain("filename*=");
|
||||
expect(response.headers["content-disposition"]).not.toContain("inline");
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(response.headers["cache-control"]).toBe("private, no-store, max-age=0");
|
||||
expect(response.headers.pragma).toBe("no-cache");
|
||||
expect(response.headers["referrer-policy"]).toBe("no-referrer");
|
||||
expect(response.headers["content-security-policy"]).toContain("sandbox");
|
||||
expect(response.headers["cross-origin-resource-policy"]).toBe("same-origin");
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
||||
extra: expect.objectContaining({ reason: "审批复核" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed before setting attachment headers when success auditing fails", async () => {
|
||||
const auditCreate = vi.fn().mockRejectedValue(new Error("database unavailable"));
|
||||
const { app } = await buildApp({ auditCreate });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.headers["content-disposition"]).toBeUndefined();
|
||||
expect(response.json().error.details).toEqual({ attachment: "audit_failed" });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retires both legacy signed and query-token routes", async () => {
|
||||
const { app, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const signedResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/signed-url`,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
const fileResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/file?token=legacy`,
|
||||
});
|
||||
|
||||
expect(signedResponse.statusCode).toBe(404);
|
||||
expect(fileResponse.statusCode).toBe(404);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sharp = require("sharp");
|
||||
|
||||
const {
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
} = require("../../../src/services/purchaseAttachmentPolicy");
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function buildPdf(body = "1 0 obj\n<<>>\nendobj") {
|
||||
return Buffer.from(`%PDF-1.7\n${body}\n%%EOF\n`, "ascii");
|
||||
}
|
||||
|
||||
function expectPolicyError(error, reasonCode) {
|
||||
expect(error).toBeInstanceOf(PurchaseAttachmentPolicyError);
|
||||
expect(error).toMatchObject({
|
||||
code: "PURCHASE_ATTACHMENT_POLICY_ERROR",
|
||||
reasonCode,
|
||||
details: { file: reasonCode },
|
||||
});
|
||||
}
|
||||
|
||||
async function capturePolicyError(input) {
|
||||
try {
|
||||
await validateAndNormalizePurchaseAttachment(input);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("expected purchase attachment policy rejection");
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function patchPngDimensions(buffer, width, height) {
|
||||
const patched = Buffer.from(buffer);
|
||||
patched.writeUInt32BE(width, 16);
|
||||
patched.writeUInt32BE(height, 20);
|
||||
patched.writeUInt32BE(crc32(patched.subarray(12, 29)), 29);
|
||||
return patched;
|
||||
}
|
||||
|
||||
function buildPngChunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const chunk = Buffer.alloc(12 + data.length);
|
||||
chunk.writeUInt32BE(data.length, 0);
|
||||
typeBuffer.copy(chunk, 4);
|
||||
data.copy(chunk, 8);
|
||||
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parsePngChunks(buffer) {
|
||||
const chunks = [];
|
||||
let offset = 8;
|
||||
while (offset < buffer.length) {
|
||||
const dataLength = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
chunks.push({
|
||||
type,
|
||||
data: buffer.subarray(offset + 8, offset + 8 + dataLength),
|
||||
});
|
||||
offset += 12 + dataLength;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildAnimatedPng(firstFrame, secondFrame) {
|
||||
const firstChunks = parsePngChunks(firstFrame);
|
||||
const secondChunks = parsePngChunks(secondFrame);
|
||||
const ihdr = firstChunks.find((chunk) => chunk.type === "IHDR").data;
|
||||
const width = ihdr.readUInt32BE(0);
|
||||
const height = ihdr.readUInt32BE(4);
|
||||
let sequence = 0;
|
||||
|
||||
const animationControl = Buffer.alloc(8);
|
||||
animationControl.writeUInt32BE(2, 0);
|
||||
animationControl.writeUInt32BE(0, 4);
|
||||
|
||||
const frameControl = () => {
|
||||
const data = Buffer.alloc(26);
|
||||
data.writeUInt32BE(sequence, 0);
|
||||
sequence += 1;
|
||||
data.writeUInt32BE(width, 4);
|
||||
data.writeUInt32BE(height, 8);
|
||||
data.writeUInt16BE(1, 20);
|
||||
data.writeUInt16BE(10, 22);
|
||||
return buildPngChunk("fcTL", data);
|
||||
};
|
||||
|
||||
const parts = [
|
||||
firstFrame.subarray(0, 8),
|
||||
buildPngChunk("IHDR", ihdr),
|
||||
buildPngChunk("acTL", animationControl),
|
||||
frameControl(),
|
||||
...firstChunks
|
||||
.filter((chunk) => chunk.type === "IDAT")
|
||||
.map((chunk) => buildPngChunk("IDAT", chunk.data)),
|
||||
frameControl(),
|
||||
];
|
||||
for (const chunk of secondChunks.filter((candidate) => candidate.type === "IDAT")) {
|
||||
const frameData = Buffer.alloc(4 + chunk.data.length);
|
||||
frameData.writeUInt32BE(sequence, 0);
|
||||
sequence += 1;
|
||||
chunk.data.copy(frameData, 4);
|
||||
parts.push(buildPngChunk("fdAT", frameData));
|
||||
}
|
||||
parts.push(buildPngChunk("IEND", Buffer.alloc(0)));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
describe("purchase attachment content policy", () => {
|
||||
let jpegBuffer;
|
||||
let pngBuffer;
|
||||
let webpBuffer;
|
||||
|
||||
beforeAll(async () => {
|
||||
const source = {
|
||||
create: {
|
||||
width: 4,
|
||||
height: 3,
|
||||
channels: 4,
|
||||
background: { r: 30, g: 120, b: 210, alpha: 1 },
|
||||
},
|
||||
};
|
||||
[jpegBuffer, pngBuffer, webpBuffer] = await Promise.all([
|
||||
sharp(source).jpeg().toBuffer(),
|
||||
sharp(source).png().toBuffer(),
|
||||
sharp(source).webp().toBuffer(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a strict PDF boundary and preserves its bytes", async () => {
|
||||
const buffer = buildPdf();
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer,
|
||||
fileName: "报价.v2.PDF",
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer,
|
||||
originalName: "报价.v2.pdf",
|
||||
extension: ".pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: buffer.length,
|
||||
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["photo.jpeg", "image/jpg", "jpeg", ".jpg", "image/jpeg"],
|
||||
["evidence.png", "image/png", "png", ".png", "image/png"],
|
||||
["evidence.webp", "image/webp", "webp", ".webp", "image/webp"],
|
||||
])("decodes and normalizes an allowed %s image", async (fileName, declaredMimeType, fixture, extension, mimeType) => {
|
||||
const source = { jpeg: jpegBuffer, png: pngBuffer, webp: webpBuffer }[fixture];
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: source,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(result.originalName).toBe(`${fileName.slice(0, fileName.lastIndexOf("."))}${extension}`);
|
||||
expect(result.extension).toBe(extension);
|
||||
expect(result.mimeType).toBe(mimeType);
|
||||
expect(result.fileSize).toBe(result.buffer.length);
|
||||
expect(result.fileSha256).toBe(
|
||||
crypto.createHash("sha256").update(result.buffer).digest("hex"),
|
||||
);
|
||||
const metadata = await sharp(result.buffer).metadata();
|
||||
expect(metadata.format).toBe(fixture);
|
||||
expect(metadata.pages ?? 1).toBe(1);
|
||||
});
|
||||
|
||||
it("fully validates but does not rewrite historical image bytes when normalization is disabled", async () => {
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: pngBuffer,
|
||||
fileName: "历史附件.png",
|
||||
declaredMimeType: "application/octet-stream",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expect(result.buffer).toBe(pngBuffer);
|
||||
expect(result.fileSize).toBe(pngBuffer.length);
|
||||
expect(result.mimeType).toBe("image/png");
|
||||
expect(result.fileSha256).toBe(
|
||||
crypto.createHash("sha256").update(pngBuffer).digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the normalized-output size limit only when storing a normalized upload", async () => {
|
||||
const compactPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const historical = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: compactPng,
|
||||
fileName: "历史附件.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: compactPng.length,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expect(historical.buffer).toBe(compactPng);
|
||||
expect(historical.fileSize).toBe(compactPng.length);
|
||||
|
||||
const uploadError = await capturePolicyError({
|
||||
buffer: compactPng,
|
||||
fileName: "新附件.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: compactPng.length,
|
||||
normalizeImages: true,
|
||||
});
|
||||
expectPolicyError(uploadError, "size_limit");
|
||||
});
|
||||
|
||||
it("still fully decodes corrupt historical image bytes when normalization is disabled", async () => {
|
||||
const boundaryOnlyJpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x00, 0xff, 0xd9]);
|
||||
const error = await capturePolicyError({
|
||||
buffer: boundaryOnlyJpeg,
|
||||
fileName: "历史损坏附件.jpg",
|
||||
declaredMimeType: "image/jpeg",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("rejects warning-only corrupt historical JPEG bytes when normalization is disabled", async () => {
|
||||
const validJpeg = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 3,
|
||||
background: { r: 30, g: 120, b: 210 },
|
||||
},
|
||||
}).jpeg().toBuffer();
|
||||
expect(validJpeg.subarray(-2)).toEqual(Buffer.from([0xff, 0xd9]));
|
||||
|
||||
const warningOnlyCorruptJpeg = Buffer.concat([
|
||||
validJpeg.subarray(0, validJpeg.length - 3),
|
||||
Buffer.from([0xff, 0xd9]),
|
||||
]);
|
||||
const error = await capturePolicyError({
|
||||
buffer: warningOnlyCorruptJpeg,
|
||||
fileName: "历史损坏附件.jpg",
|
||||
declaredMimeType: "image/jpeg",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("accepts an empty historical MIME only when extension and bytes agree", async () => {
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: jpegBuffer,
|
||||
fileName: "历史照片.jpg",
|
||||
declaredMimeType: " ",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expect(result.extension).toBe(".jpg");
|
||||
expect(result.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("strips image metadata when normalizing", async () => {
|
||||
const withMetadata = await sharp({
|
||||
create: {
|
||||
width: 3,
|
||||
height: 2,
|
||||
channels: 3,
|
||||
background: { r: 200, g: 20, b: 10 },
|
||||
},
|
||||
}).withMetadata({ orientation: 6 }).jpeg().toBuffer();
|
||||
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: withMetadata,
|
||||
fileName: "orientation.jpg",
|
||||
declaredMimeType: "image/jpeg",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
const metadata = await sharp(result.buffer).metadata();
|
||||
expect(metadata.orientation).toBeUndefined();
|
||||
expect(metadata.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["quote.html.pdf", "dangerous_filename"],
|
||||
["quote.svg.png", "dangerous_filename"],
|
||||
["../quote.pdf", "dangerous_filename"],
|
||||
["folder\\quote.pdf", "dangerous_filename"],
|
||||
["quote\r\nname.pdf", "dangerous_filename"],
|
||||
["quote\u202Efdp.pdf", "dangerous_filename"],
|
||||
["quote.pdf.html", "unsupported_type"],
|
||||
["quote.txt", "unsupported_type"],
|
||||
])("rejects unsafe or unsupported filename %s", async (fileName, reasonCode) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer: buildPdf(),
|
||||
fileName,
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, reasonCode);
|
||||
});
|
||||
|
||||
it("truncates a long safe display name while preserving its canonical extension", async () => {
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: buildPdf(),
|
||||
fileName: `${"报".repeat(300)}.pdf`,
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(Array.from(result.originalName)).toHaveLength(
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
);
|
||||
expect(result.originalName.endsWith(".pdf")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["text/html", "unsupported_type"],
|
||||
["image/png", "type_mismatch"],
|
||||
])("rejects invalid declared MIME %s", async (declaredMimeType, reasonCode) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer: buildPdf(),
|
||||
fileName: "quote.pdf",
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, reasonCode);
|
||||
});
|
||||
|
||||
it("rejects allowed bytes whose extension declares another allowed type", async () => {
|
||||
const error = await capturePolicyError({
|
||||
buffer: pngBuffer,
|
||||
fileName: "quote.jpg",
|
||||
declaredMimeType: "application/octet-stream",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "type_mismatch");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Buffer.from("<!doctype html><title>marker</title>", "utf8"), "quote.pdf"],
|
||||
[Buffer.from("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>", "utf8"), "quote.png"],
|
||||
[Buffer.from("plain marker", "utf8"), "quote.webp"],
|
||||
])("rejects active or unknown content before Sharp", async (buffer, fileName) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType: "application/octet-stream",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "unsupported_type");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Buffer.from("%PDF-1.7\ntruncated", "ascii"), "truncated.pdf", "application/pdf"],
|
||||
[Buffer.from("%PDF-1.9\nmarker\n%%EOF\n", "ascii"), "version.pdf", "application/pdf"],
|
||||
[Buffer.concat([buildPdf(), Buffer.from("<html>tail</html>", "ascii")]), "tail.pdf", "application/pdf"],
|
||||
])("rejects truncated or trailing PDF content", async (buffer, fileName, declaredMimeType) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["jpeg", "truncated.jpg", "image/jpeg", 2],
|
||||
["png", "truncated.png", "image/png", 4],
|
||||
])("rejects truncated %s content", async (fixture, fileName, declaredMimeType, bytesToRemove) => {
|
||||
const source = { jpeg: jpegBuffer, png: pngBuffer }[fixture];
|
||||
const error = await capturePolicyError({
|
||||
buffer: source.subarray(0, source.length - bytesToRemove),
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("rejects a WEBP whose RIFF length does not match the actual bytes", async () => {
|
||||
const corrupted = Buffer.from(webpBuffer);
|
||||
corrupted.writeUInt32LE(corrupted.readUInt32LE(4) - 1, 4);
|
||||
const error = await capturePolicyError({
|
||||
buffer: corrupted,
|
||||
fileName: "corrupted.webp",
|
||||
declaredMimeType: "image/webp",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("rejects an animated WEBP as multiple pages", async () => {
|
||||
const [firstFrame, secondFrame] = await Promise.all([
|
||||
sharp({
|
||||
create: {
|
||||
width: 2,
|
||||
height: 2,
|
||||
channels: 4,
|
||||
background: { r: 10, g: 200, b: 100, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer(),
|
||||
sharp({
|
||||
create: {
|
||||
width: 2,
|
||||
height: 2,
|
||||
channels: 4,
|
||||
background: { r: 200, g: 10, b: 100, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer(),
|
||||
]);
|
||||
const animated = await sharp(
|
||||
[firstFrame, secondFrame],
|
||||
{ join: { animated: true } },
|
||||
).webp({ loop: 0, delay: [100, 100] }).toBuffer();
|
||||
|
||||
const error = await capturePolicyError({
|
||||
buffer: animated,
|
||||
fileName: "animated.webp",
|
||||
declaredMimeType: "image/webp",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "multiple_pages");
|
||||
});
|
||||
|
||||
it("rejects an APNG animation control stream as multiple pages", async () => {
|
||||
const secondFrame = await sharp({
|
||||
create: {
|
||||
width: 4,
|
||||
height: 3,
|
||||
channels: 4,
|
||||
background: { r: 210, g: 30, b: 120, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer();
|
||||
const animatedPng = buildAnimatedPng(pngBuffer, secondFrame);
|
||||
|
||||
expect(animatedPng.includes(Buffer.from("acTL", "ascii"))).toBe(true);
|
||||
expect(animatedPng.includes(Buffer.from("fdAT", "ascii"))).toBe(true);
|
||||
const error = await capturePolicyError({
|
||||
buffer: animatedPng,
|
||||
fileName: "animated.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "multiple_pages");
|
||||
});
|
||||
|
||||
it("rejects image metadata above the 40MP hard limit", async () => {
|
||||
const oversizedHeader = patchPngDimensions(pngBuffer, 10_000, 4_001);
|
||||
expect(10_000 * 4_001).toBeGreaterThan(PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS);
|
||||
|
||||
const error = await capturePolicyError({
|
||||
buffer: oversizedHeader,
|
||||
fileName: "oversized.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "pixel_limit");
|
||||
});
|
||||
|
||||
it("rejects empty and oversized buffers with stable reasons", async () => {
|
||||
const emptyError = await capturePolicyError({
|
||||
buffer: Buffer.alloc(0),
|
||||
fileName: "empty.pdf",
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: 10,
|
||||
});
|
||||
expectPolicyError(emptyError, "empty_file");
|
||||
|
||||
const sizeError = await capturePolicyError({
|
||||
buffer: buildPdf(),
|
||||
fileName: "large.pdf",
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: 4,
|
||||
});
|
||||
expectPolicyError(sizeError, "size_limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchase attachment storage and response helpers", () => {
|
||||
it("builds a random canonical storage key without an original basename", () => {
|
||||
const purchaseRequestId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const first = buildAttachmentStorageKey(purchaseRequestId, ".pdf");
|
||||
const second = buildAttachmentStorageKey(purchaseRequestId, ".pdf");
|
||||
|
||||
expect(first).toMatch(
|
||||
/^purchase-requests\/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\/[0-9a-f-]{36}\.pdf$/u,
|
||||
);
|
||||
expect(second).not.toBe(first);
|
||||
expect(first).not.toContain("quote");
|
||||
});
|
||||
|
||||
it("rejects invalid storage key inputs", () => {
|
||||
expect(() => buildAttachmentStorageKey("not-an-id", ".pdf")).toThrow(TypeError);
|
||||
try {
|
||||
buildAttachmentStorageKey("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ".html");
|
||||
throw new Error("expected invalid extension rejection");
|
||||
} catch (error) {
|
||||
expectPolicyError(error, "unsupported_type");
|
||||
}
|
||||
});
|
||||
|
||||
it("builds RFC 5987 Content-Disposition with a safe ASCII fallback", () => {
|
||||
const header = buildAttachmentContentDisposition("采购报价.pdf");
|
||||
|
||||
expect(header).toBe(
|
||||
"attachment; filename=\"attachment.pdf\"; filename*=UTF-8''%E9%87%87%E8%B4%AD%E6%8A%A5%E4%BB%B7.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes header injection and path syntax from Content-Disposition", () => {
|
||||
const header = buildAttachmentContentDisposition("folder/evil\"\r\nX-Test: marker\\quote.pdf");
|
||||
|
||||
expect(header.startsWith("attachment; filename=\"")).toBe(true);
|
||||
expect(header).not.toMatch(/[\r\n]/u);
|
||||
expect(header).not.toContain("/evil");
|
||||
expect(header).not.toContain("\\quote");
|
||||
expect(header).toContain("filename*=UTF-8''");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户