修复采购附件同源 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
|
||||
|
||||
在新工单中引用
屏蔽一个用户