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

这个提交包含在:
2026-07-17 20:32:05 +08:00
父节点 4c5eb7e22e
当前提交 f424492751
共修改 16 个文件,包含 2999 行新增和 211 行删除
+66 -3
查看文件
@@ -43,6 +43,12 @@
`archiveNote`.
- Download APIs may accept `reason`; sensitive or reason-required attachments
must reject downloads without a non-empty reason.
- Upload content-policy inputs are `{ buffer, fileName, declaredMimeType,
maxBytes, normalizeImages }`; accepted output must contain canonical
`buffer`, `extension`, `mimeType`, `fileSize` and `fileSha256`.
- Browser downloads of OA-stored attachments must use a currently
authenticated request, preferably an API-local `POST` with optional JSON
`{ "reason": "..." }`, rather than a query bearer URL.
### 3. Contracts
@@ -56,6 +62,24 @@
- Audit logs for downloads must include actor, business target, attachment ID,
attachment type, sensitivity level, governance status and sanitized reason
when provided.
- New uploads must use an allowlist and jointly verify the final extension,
declared MIME and actual bytes before storage. Original names are display
metadata only; storage keys use a server-generated random identifier and a
canonical extension.
- PDF basic validation requires a strict header and terminal EOF boundary. Safe
image formats must first match fixed signature bytes, then decode with Sharp
using `failOn: "warning"`, a fixed pixel ceiling and a one-page constraint;
new images are re-encoded without metadata before size/hash/MIME persistence.
- A governance state, existing hash, extension or stored MIME never proves
historical content safe. Every download must re-authorize the current actor
and business object, read under a hard byte limit and re-run the actual-byte
policy before any response bytes are sent.
- Successful download audit is part of authorization: write it before setting
attachment headers or sending bytes. Audit failure must return a non-success
response with no file body.
- Successful responses use `application/octet-stream`, `attachment`,
`nosniff`, private no-store caching, no-referrer and a route-level sandbox
CSP. Do not restore same-origin inline preview or query-token download routes.
### 4. Validation & Error Matrix
@@ -67,6 +91,14 @@
- User lacks business read permission -> `403 PERM_001`.
- Download reason longer than the server limit -> trim before writing audit
metadata.
- Unsupported/active bytes, extension-MIME-content mismatch, dangerous file
name, corrupt/truncated content, animation/multiple pages or pixel/byte limit
breach -> reject before object-storage and database writes.
- Explicitly quarantined, missing, oversized, corrupt or unverifiable stored
object -> do not return file bytes; write a fixed blocked-reason audit where
possible.
- Successful-download audit write fails -> return 5xx before download headers
or file bytes.
### 5. Good/Base/Bad Cases
@@ -75,37 +107,68 @@
- Base: a reimbursement supporting material upload defaults to `internal`,
`supporting_material`, `standard` and remains downloadable by authorized
participants.
- Good: an allowed image is signature-checked, strictly decoded, normalized,
stored under a random canonical key, then later revalidated and returned only
as a forced binary download.
- Base: an allowed historical PDF remains byte-for-byte unchanged in storage;
every access still revalidates it and forces attachment download.
- Bad: a page hides the download button but the backend route still streams the
file without permission or reason checks.
- Bad: Enterprise WeChat messages or reports include original attachments
instead of OA links and desensitized summaries.
- Bad: trust `multipart.mimetype`, reuse an original basename in the storage
path, or return a stored MIME with `inline` from the OA origin.
### 6. Tests Required
- Prisma validate after schema or migration changes.
- Backend route tests for default governance fields, custom multipart fields
when exposed, sensitive-download reason rejection and audit `extra` metadata.
- Content-policy tests for every allowed type plus active/unknown content,
mismatch, truncation, warning-only JPEG corruption, animation/multiple pages,
pixel/byte limits, metadata removal, random keys and safe disposition names.
- Route tests must prove immediate bounded multipart consumption, rejection
before storage/DB writes, current authentication and object authorization,
historical revalidation, blocked-audit reasons, audit-before-send and the
complete forced-download header contract.
- Frontend typecheck/build after API type changes.
- Focused UI verification for pages that add attachment governance labels or
download-reason interactions.
- Frontend download tests must assert authenticated JSON request + Blob browser
download and absence of popup/inline preview. Use only synthetic in-memory
fixtures; production attachments and historical bulk scanning require a
separate approval.
### 7. Wrong vs Correct
#### Wrong
```js
return reply.send(stream);
const storageKey = `${businessId}/${originalFileName}`;
await storage.writeBuffer({ key: storageKey, buffer, contentType: clientMime });
return reply.type(clientMime).header("content-disposition", "inline").send(buffer);
```
#### Correct
```js
const reason = sanitizeDownloadReason(request.query?.reason);
const validated = await validateActualAttachmentBytes({
buffer,
fileName,
declaredMimeType,
normalizeImages: false,
});
const reason = sanitizeDownloadReason(request.body?.reason);
if (requiresDownloadReason(attachment) && !reason) {
throw buildValidationError({ reason: "required" });
}
await prisma.auditLog.create({ data: buildDownloadAudit({ attachment, reason }) });
return reply.send(stream);
return reply
.type("application/octet-stream")
.header("content-disposition", buildSafeAttachmentDisposition(validated.originalName))
.header("x-content-type-options", "nosniff")
.header("cache-control", "private, no-store")
.send(validated.buffer);
```
## Scenario: Company Documents And OA Collaboration