修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)
这个提交包含在:
+372
-184
@@ -1,13 +1,9 @@
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { compactDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { parseDateOnlyString } = require("../utils/date");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
@@ -32,7 +28,13 @@ const {
|
||||
buildWorkflowEntityMeta,
|
||||
} = require("../services/notifications/workflowCopy");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const defaultStorage = require("../services/storage");
|
||||
const {
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
} = require("../services/purchaseAttachmentPolicy");
|
||||
const { dispatchNotificationBatch } = require("../services/notificationDispatcher");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
@@ -107,6 +109,12 @@ const PURCHASE_ARCHIVE_BODY_SCHEMA = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z
|
||||
.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PURCHASE_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
request_no: true,
|
||||
@@ -972,53 +980,156 @@ async function recomputePurchaseRequestTotal(tx, purchaseRequestId, actorId) {
|
||||
return nextTotal;
|
||||
}
|
||||
|
||||
async function persistAttachment({ config, purchaseRequestId, filePart }) {
|
||||
const chunks = [];
|
||||
let fileSize = 0;
|
||||
|
||||
for await (const chunk of filePart.file) {
|
||||
fileSize += chunk.length;
|
||||
if (fileSize > config.maxUploadSizeBytes) {
|
||||
throw buildValidationError({ file: "file_too_large" });
|
||||
}
|
||||
chunks.push(chunk);
|
||||
class PurchaseAttachmentSizeLimitError extends Error {
|
||||
constructor() {
|
||||
super("purchase_attachment_size_limit");
|
||||
this.name = "PurchaseAttachmentSizeLimitError";
|
||||
this.reasonCode = "size_limit";
|
||||
}
|
||||
|
||||
if (fileSize <= 0) {
|
||||
throw buildValidationError({ file: "empty" });
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const safeName = (filePart.filename || "attachment")
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "attachment";
|
||||
const extension = path.extname(safeName);
|
||||
const basename = extension ? safeName.slice(0, -extension.length) : safeName;
|
||||
const key = path.posix.join(
|
||||
"purchase-requests",
|
||||
purchaseRequestId,
|
||||
`${compactDateString(config.appTimezone)}-${basename}-${crypto.randomUUID()}${extension}`,
|
||||
);
|
||||
|
||||
const storagePath = await writeBuffer({
|
||||
config,
|
||||
key,
|
||||
buffer,
|
||||
contentType: filePart.mimetype,
|
||||
});
|
||||
|
||||
return {
|
||||
storagePath,
|
||||
originalName: filePart.filename || safeName,
|
||||
mimeType: filePart.mimetype || "application/octet-stream",
|
||||
fileSize,
|
||||
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
function resolveAttachmentMaxBytes(config) {
|
||||
const configured = Number(config?.maxUploadSizeBytes);
|
||||
return Number.isSafeInteger(configured) && configured > 0 ? configured : 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
function destroyStream(stream) {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof stream.destroy === "function" && !stream.destroyed) {
|
||||
try {
|
||||
stream.destroy();
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to a Web Stream cancellation attempt.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof stream.cancel === "function") {
|
||||
try {
|
||||
Promise.resolve(stream.cancel()).catch(() => {});
|
||||
} catch {
|
||||
// The caller is already failing closed; cleanup must not replace the security error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedStream(stream, maxBytes) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += bufferChunk.length;
|
||||
if (size > maxBytes) {
|
||||
destroyStream(stream);
|
||||
throw new PurchaseAttachmentSizeLimitError();
|
||||
}
|
||||
chunks.push(bufferChunk);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PurchaseAttachmentSizeLimitError) {
|
||||
throw error;
|
||||
}
|
||||
destroyStream(stream);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stream?.truncated) {
|
||||
destroyStream(stream);
|
||||
throw new PurchaseAttachmentSizeLimitError();
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks, size);
|
||||
}
|
||||
|
||||
async function discardMultipartFile(stream, maxBytes) {
|
||||
try {
|
||||
await readBoundedStream(stream, maxBytes);
|
||||
} catch {
|
||||
destroyStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePurchaseAttachmentDownloadBody(body) {
|
||||
const parsed = PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message,
|
||||
statusCode,
|
||||
details: {
|
||||
attachment: "security_validation_failed",
|
||||
reason: reasonCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode,
|
||||
downloadReason,
|
||||
}) {
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
targetType: "purchase_attachment",
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
attachmentType: attachment?.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment?.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment?.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
reasonCode,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
errorCode: "purchase_attachment_block_audit_failed",
|
||||
reasonCode,
|
||||
},
|
||||
"Purchase attachment block audit failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId }) {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
errorCode: "purchase_attachment_storage_read_failed",
|
||||
},
|
||||
"Purchase attachment storage read failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "附件暂时无法读取,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "storage_read_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
|
||||
@@ -1361,7 +1472,8 @@ async function handlePurchaseArchiveErpSync({
|
||||
};
|
||||
}
|
||||
|
||||
async function purchaseRequestRoutes(fastify) {
|
||||
async function purchaseRequestRoutes(fastify, options = {}) {
|
||||
const attachmentStorage = options.attachmentStorage ?? defaultStorage;
|
||||
fastify.get(
|
||||
"/purchase-requests",
|
||||
{
|
||||
@@ -2227,37 +2339,93 @@ async function purchaseRequestRoutes(fastify) {
|
||||
throw buildValidationError({ status: "only_draft_editable" }, 409);
|
||||
}
|
||||
|
||||
let filePart = null;
|
||||
if (typeof request.isMultipart !== "function" || !request.isMultipart()) {
|
||||
throw buildValidationError({ file: "multipart_required" });
|
||||
}
|
||||
|
||||
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
|
||||
let normalizedFile = null;
|
||||
let governanceFields = {};
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
for await (const part of request.parts({ preservePath: true })) {
|
||||
if (part.type === "field") {
|
||||
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
||||
continue;
|
||||
}
|
||||
if (part.type === "file") {
|
||||
if (filePart) {
|
||||
if (normalizedFile) {
|
||||
await discardMultipartFile(part.file, maxBytes);
|
||||
throw buildValidationError({ file: "only_single_file_supported" });
|
||||
}
|
||||
filePart = part;
|
||||
|
||||
const fileBuffer = await readBoundedStream(part.file, maxBytes);
|
||||
if (part.fieldname !== "file") {
|
||||
throw buildValidationError({ file: "invalid_field" });
|
||||
}
|
||||
|
||||
normalizedFile = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: fileBuffer,
|
||||
fileName: part.filename,
|
||||
declaredMimeType: part.mimetype,
|
||||
maxBytes,
|
||||
normalizeImages: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
if (
|
||||
error instanceof PurchaseAttachmentSizeLimitError ||
|
||||
error?.code === "FST_REQ_FILE_TOO_LARGE"
|
||||
) {
|
||||
throw buildValidationError({ file: "file_too_large" });
|
||||
}
|
||||
if (error?.code === "FST_FILES_LIMIT") {
|
||||
throw buildValidationError({ file: "only_single_file_supported" });
|
||||
}
|
||||
if (error?.code === "FST_FIELDS_LIMIT" || error?.code === "FST_PARTS_LIMIT") {
|
||||
throw buildValidationError({ file: "multipart_limit_exceeded" });
|
||||
}
|
||||
if (error instanceof PurchaseAttachmentPolicyError) {
|
||||
throw buildValidationError(error.details ?? { file: error.reasonCode });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!filePart) {
|
||||
if (!normalizedFile) {
|
||||
throw buildValidationError({ file: "required" });
|
||||
}
|
||||
|
||||
const stored = await persistAttachment({
|
||||
config: fastify.config,
|
||||
purchaseRequestId,
|
||||
filePart,
|
||||
});
|
||||
let storagePath;
|
||||
try {
|
||||
storagePath = await attachmentStorage.writeBuffer({
|
||||
config: fastify.config,
|
||||
key: buildAttachmentStorageKey(purchaseRequestId, normalizedFile.extension),
|
||||
buffer: normalizedFile.buffer,
|
||||
contentType: normalizedFile.mimeType,
|
||||
});
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
errorCode: "purchase_attachment_storage_write_failed",
|
||||
},
|
||||
"Purchase attachment storage write failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "附件暂时无法保存,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "storage_write_failed" },
|
||||
});
|
||||
}
|
||||
const stored = {
|
||||
storagePath,
|
||||
originalName: normalizedFile.originalName,
|
||||
mimeType: normalizedFile.mimeType,
|
||||
fileSize: normalizedFile.fileSize,
|
||||
fileSha256: normalizedFile.fileSha256,
|
||||
};
|
||||
|
||||
const attachment = await fastify.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.purchaseAttachment.create({
|
||||
@@ -2415,15 +2583,18 @@ async function purchaseRequestRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/purchase-requests/:id/attachments/:attachmentId/signed-url",
|
||||
"/purchase-requests/:id/attachments/:attachmentId/download",
|
||||
{
|
||||
schema: { tags: ["Purchase Requests"], summary: "Issue Purchase Attachment Signed URL" },
|
||||
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
ensurePurchaseReadPermission(fastify, request);
|
||||
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
||||
const attachmentId = parseAttachmentId(request.params?.attachmentId);
|
||||
const body = parsePurchaseAttachmentDownloadBody(request.body);
|
||||
const downloadReason = sanitizeDownloadReason(body.reason);
|
||||
const maxBytes = resolveAttachmentMaxBytes(fastify.config);
|
||||
|
||||
await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request);
|
||||
|
||||
@@ -2434,116 +2605,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.body?.reason ?? request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
|
||||
const token = signPayload(
|
||||
{
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
actorId: request.employee.id,
|
||||
reason: downloadReason,
|
||||
exp: expiresAt,
|
||||
},
|
||||
fastify.config.jwtSecret,
|
||||
);
|
||||
const origin =
|
||||
fastify.config.appOrigin ||
|
||||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
|
||||
const signedUrl = `${origin}/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/file?token=${encodeURIComponent(token)}`;
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PURCHASE_ATTACHMENT_SIGNED_URL_ISSUED",
|
||||
targetType: "purchase_attachment",
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
expiresAt,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
signedUrl,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/purchase-requests/:id/attachments/:attachmentId/file",
|
||||
{
|
||||
schema: { tags: ["Purchase Requests"], summary: "Download Purchase Attachment By Token" },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
||||
const attachmentId = parseAttachmentId(request.params?.attachmentId);
|
||||
const token = request.query?.token;
|
||||
|
||||
if (typeof token !== "string") {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const verified = verifyToken(token, fastify.config.jwtSecret);
|
||||
if (!verified.ok) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const actorId = UUID_SCHEMA.safeParse(verified.payload?.actorId);
|
||||
if (
|
||||
verified.payload?.purchaseRequestId !== purchaseRequestId ||
|
||||
verified.payload?.attachmentId !== attachmentId ||
|
||||
typeof verified.payload?.exp !== "number" ||
|
||||
verified.payload.exp <= Date.now() ||
|
||||
!actorId.success
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const attachment = await fastify.prisma.purchaseAttachment.findFirst({
|
||||
where: {
|
||||
id: attachmentId,
|
||||
purchase_request_id: purchaseRequestId,
|
||||
},
|
||||
select: {
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
original_name: true,
|
||||
file_size: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -2558,38 +2623,138 @@ async function purchaseRequestRoutes(fastify) {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
let opened = null;
|
||||
if (attachment.governance_status === "quarantined") {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "quarantined",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError("quarantined", "附件已隔离,无法下载");
|
||||
}
|
||||
|
||||
const recordedSize = Number(attachment.file_size);
|
||||
if (Number.isFinite(recordedSize) && recordedSize > maxBytes) {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "size_limit",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError("size_limit");
|
||||
}
|
||||
|
||||
let opened;
|
||||
try {
|
||||
opened = await openReadStream({
|
||||
opened = await attachmentStorage.openReadStream({
|
||||
config: fastify.config,
|
||||
storagePath: attachment.storage_path,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
if (
|
||||
(typeof attachmentStorage.StorageNotFoundError === "function" &&
|
||||
error instanceof attachmentStorage.StorageNotFoundError) ||
|
||||
error?.name === "StorageNotFoundError"
|
||||
) {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "missing_object",
|
||||
downloadReason,
|
||||
});
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
message: "附件不存在",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "storage_error",
|
||||
downloadReason,
|
||||
});
|
||||
throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId });
|
||||
}
|
||||
|
||||
let storedBuffer;
|
||||
try {
|
||||
storedBuffer = await readBoundedStream(opened.stream, maxBytes);
|
||||
} catch (error) {
|
||||
if (error instanceof PurchaseAttachmentSizeLimitError) {
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "size_limit",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError("size_limit");
|
||||
}
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: "storage_error",
|
||||
downloadReason,
|
||||
});
|
||||
throwSafeStorageReadError({ fastify, request, purchaseRequestId, attachmentId });
|
||||
}
|
||||
|
||||
let validated;
|
||||
try {
|
||||
validated = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: storedBuffer,
|
||||
fileName: attachment.original_name,
|
||||
declaredMimeType: attachment.mime_type,
|
||||
maxBytes,
|
||||
normalizeImages: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof PurchaseAttachmentPolicyError)) {
|
||||
throw error;
|
||||
}
|
||||
await auditBlockedAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
attachment,
|
||||
reasonCode: error.reasonCode,
|
||||
downloadReason,
|
||||
});
|
||||
throw buildAttachmentBlockedError(error.reasonCode);
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: actorId.data,
|
||||
actorId: request.employee.id,
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
||||
targetType: "purchase_attachment",
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
exp: verified.payload.exp,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
@@ -2597,16 +2762,39 @@ async function purchaseRequestRoutes(fastify) {
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, attachmentId }, "Audit log write failed");
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
errorCode: "purchase_attachment_download_audit_failed",
|
||||
},
|
||||
"Purchase attachment download audit failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "附件下载留痕失败,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "audit_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
reply.type(attachment.mime_type || "application/octet-stream");
|
||||
reply.type("application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeFilename(attachment.original_name)}\"`,
|
||||
buildAttachmentContentDisposition(validated.originalName),
|
||||
);
|
||||
return reply.send(opened.stream);
|
||||
reply.header("x-content-type-options", "nosniff");
|
||||
reply.header("cache-control", "private, no-store, max-age=0");
|
||||
reply.header("pragma", "no-cache");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header(
|
||||
"content-security-policy",
|
||||
"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
);
|
||||
reply.header("cross-origin-resource-policy", "same-origin");
|
||||
return reply.send(validated.buffer);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sharp = require("sharp");
|
||||
|
||||
const PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS = 40_000_000;
|
||||
const PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS = 180;
|
||||
|
||||
const PDF_HEADER_PREFIX = Buffer.from("%PDF-", "ascii");
|
||||
const PDF_EOF_MARKER = Buffer.from("%%EOF", "ascii");
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const PNG_IEND_CHUNK = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x49, 0x45, 0x4e, 0x44,
|
||||
0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
pdf: Object.freeze({
|
||||
extension: ".pdf",
|
||||
mimeType: "application/pdf",
|
||||
declaredMimeTypes: new Set(["application/pdf"]),
|
||||
}),
|
||||
jpeg: Object.freeze({
|
||||
extension: ".jpg",
|
||||
mimeType: "image/jpeg",
|
||||
declaredMimeTypes: new Set(["image/jpeg", "image/jpg"]),
|
||||
}),
|
||||
png: Object.freeze({
|
||||
extension: ".png",
|
||||
mimeType: "image/png",
|
||||
declaredMimeTypes: new Set(["image/png"]),
|
||||
}),
|
||||
webp: Object.freeze({
|
||||
extension: ".webp",
|
||||
mimeType: "image/webp",
|
||||
declaredMimeTypes: new Set(["image/webp"]),
|
||||
}),
|
||||
});
|
||||
|
||||
const TYPE_BY_EXTENSION = new Map([
|
||||
[".pdf", "pdf"],
|
||||
[".jpg", "jpeg"],
|
||||
[".jpeg", "jpeg"],
|
||||
[".png", "png"],
|
||||
[".webp", "webp"],
|
||||
]);
|
||||
const TYPE_BY_DECLARED_MIME = new Map(
|
||||
Object.entries(CONTENT_TYPES).flatMap(([type, descriptor]) =>
|
||||
[...descriptor.declaredMimeTypes].map((mimeType) => [mimeType, type]),
|
||||
),
|
||||
);
|
||||
const CANONICAL_EXTENSIONS = new Set(
|
||||
Object.values(CONTENT_TYPES).map((descriptor) => descriptor.extension),
|
||||
);
|
||||
const DANGEROUS_INTERMEDIATE_EXTENSIONS = new Set([
|
||||
"asp",
|
||||
"aspx",
|
||||
"cjs",
|
||||
"css",
|
||||
"htm",
|
||||
"html",
|
||||
"js",
|
||||
"jsp",
|
||||
"mjs",
|
||||
"php",
|
||||
"phtml",
|
||||
"shtml",
|
||||
"svg",
|
||||
"wasm",
|
||||
"xht",
|
||||
"xhtml",
|
||||
"xml",
|
||||
]);
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
||||
const PATH_SEPARATORS = /[\\/]/u;
|
||||
const BIDI_AND_INVISIBLE_CONTROLS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u;
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
||||
|
||||
class PurchaseAttachmentPolicyError extends Error {
|
||||
constructor(reasonCode) {
|
||||
super("purchase_attachment_rejected");
|
||||
this.name = "PurchaseAttachmentPolicyError";
|
||||
this.code = "PURCHASE_ATTACHMENT_POLICY_ERROR";
|
||||
this.reasonCode = reasonCode;
|
||||
this.details = { file: reasonCode };
|
||||
}
|
||||
}
|
||||
|
||||
function reject(reasonCode) {
|
||||
throw new PurchaseAttachmentPolicyError(reasonCode);
|
||||
}
|
||||
|
||||
function assertMaxBytes(maxBytes) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new TypeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateCodePoints(value, maxCodePoints) {
|
||||
const codePoints = Array.from(value);
|
||||
return codePoints.length <= maxCodePoints
|
||||
? value
|
||||
: codePoints.slice(0, maxCodePoints).join("");
|
||||
}
|
||||
|
||||
function normalizeAndValidateFileName(fileName) {
|
||||
if (typeof fileName !== "string") {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const normalized = fileName.normalize("NFC").trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
CONTROL_CHARACTERS.test(normalized) ||
|
||||
PATH_SEPARATORS.test(normalized) ||
|
||||
BIDI_AND_INVISIBLE_CONTROLS.test(normalized)
|
||||
) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const lastDotIndex = normalized.lastIndexOf(".");
|
||||
if (lastDotIndex <= 0 || lastDotIndex === normalized.length - 1) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const suppliedExtension = normalized.slice(lastDotIndex).toLowerCase();
|
||||
const expectedType = TYPE_BY_EXTENSION.get(suppliedExtension);
|
||||
if (!expectedType) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const stem = normalized.slice(0, lastDotIndex);
|
||||
const stemSegments = stem.split(".");
|
||||
if (
|
||||
stemSegments.some((segment) => !segment) ||
|
||||
stemSegments.slice(1).some((segment) =>
|
||||
DANGEROUS_INTERMEDIATE_EXTENSIONS.has(segment.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const canonicalExtension = CONTENT_TYPES[expectedType].extension;
|
||||
const maxStemLength = Math.max(
|
||||
1,
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS - Array.from(canonicalExtension).length,
|
||||
);
|
||||
const truncatedStem = truncateCodePoints(stem, maxStemLength).trim();
|
||||
if (!truncatedStem) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
return {
|
||||
expectedType,
|
||||
originalName: `${truncatedStem}${canonicalExtension}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeclaredMimeType(declaredMimeType) {
|
||||
if (declaredMimeType == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof declaredMimeType !== "string") {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const normalized = declaredMimeType.trim().toLowerCase();
|
||||
if (!normalized || normalized === "application/octet-stream") {
|
||||
return null;
|
||||
}
|
||||
if (!TYPE_BY_DECLARED_MIME.has(normalized)) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function detectSignatureType(buffer) {
|
||||
if (buffer.length >= PDF_HEADER_PREFIX.length && buffer.subarray(0, PDF_HEADER_PREFIX.length).equals(PDF_HEADER_PREFIX)) {
|
||||
return "pdf";
|
||||
}
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
if (buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||
buffer.toString("ascii", 8, 12) === "WEBP"
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePdfBoundaries(buffer) {
|
||||
const header = buffer.toString("ascii", 0, Math.min(buffer.length, 16));
|
||||
if (!/^%PDF-(?:1\.[0-7]|2\.0)(?:\r\n|\r|\n)/u.test(header)) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
|
||||
let lastContentIndex = buffer.length - 1;
|
||||
while (
|
||||
lastContentIndex >= 0 &&
|
||||
[0x09, 0x0a, 0x0c, 0x0d, 0x20].includes(buffer[lastContentIndex])
|
||||
) {
|
||||
lastContentIndex -= 1;
|
||||
}
|
||||
const markerStart = lastContentIndex - PDF_EOF_MARKER.length + 1;
|
||||
if (
|
||||
markerStart < 0 ||
|
||||
!buffer.subarray(markerStart, lastContentIndex + 1).equals(PDF_EOF_MARKER)
|
||||
) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateJpegBoundaries(buffer) {
|
||||
if (buffer.length < 4 || buffer[buffer.length - 2] !== 0xff || buffer[buffer.length - 1] !== 0xd9) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePngBoundaries(buffer) {
|
||||
if (
|
||||
buffer.length < PNG_SIGNATURE.length + PNG_IEND_CHUNK.length ||
|
||||
!buffer.subarray(buffer.length - PNG_IEND_CHUNK.length).equals(PNG_IEND_CHUNK)
|
||||
) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
|
||||
let offset = PNG_SIGNATURE.length;
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 12 > buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
const dataLength = buffer.readUInt32BE(offset);
|
||||
const chunkEnd = offset + 12 + dataLength;
|
||||
if (chunkEnd > buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
if (chunkType === "acTL") {
|
||||
reject("multiple_pages");
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
}
|
||||
|
||||
function validateWebpBoundaries(buffer) {
|
||||
if (buffer.length < 12 || buffer.readUInt32LE(4) + 8 !== buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormatBoundaries(type, buffer) {
|
||||
if (type === "pdf") {
|
||||
validatePdfBoundaries(buffer);
|
||||
} else if (type === "jpeg") {
|
||||
validateJpegBoundaries(buffer);
|
||||
} else if (type === "png") {
|
||||
validatePngBoundaries(buffer);
|
||||
} else if (type === "webp") {
|
||||
validateWebpBoundaries(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function mapSharpFailure(error) {
|
||||
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
||||
reject(message.includes("pixel limit") ? "pixel_limit" : "corrupt_content");
|
||||
}
|
||||
|
||||
async function inspectImage(buffer, expectedType) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(buffer, {
|
||||
animated: true,
|
||||
failOn: "warning",
|
||||
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
sequentialRead: true,
|
||||
}).metadata();
|
||||
} catch (error) {
|
||||
mapSharpFailure(error);
|
||||
}
|
||||
|
||||
if (metadata.format !== expectedType || !metadata.width || !metadata.height) {
|
||||
reject(metadata.format ? "type_mismatch" : "corrupt_content");
|
||||
}
|
||||
|
||||
const pages = metadata.pages ?? 1;
|
||||
if (!Number.isSafeInteger(pages) || pages !== 1) {
|
||||
reject("multiple_pages");
|
||||
}
|
||||
|
||||
if (metadata.width * metadata.height > PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS) {
|
||||
reject("pixel_limit");
|
||||
}
|
||||
|
||||
let normalizedBuffer;
|
||||
try {
|
||||
const pipeline = sharp(buffer, {
|
||||
failOn: "warning",
|
||||
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
pages: 1,
|
||||
sequentialRead: true,
|
||||
}).rotate();
|
||||
|
||||
if (expectedType === "jpeg") {
|
||||
normalizedBuffer = await pipeline.jpeg({ quality: 90 }).toBuffer();
|
||||
} else if (expectedType === "png") {
|
||||
normalizedBuffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();
|
||||
} else {
|
||||
normalizedBuffer = await pipeline.webp({ quality: 90, effort: 4 }).toBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
mapSharpFailure(error);
|
||||
}
|
||||
|
||||
validateFormatBoundaries(expectedType, normalizedBuffer);
|
||||
return normalizedBuffer;
|
||||
}
|
||||
|
||||
async function validateAndNormalizePurchaseAttachment({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes,
|
||||
normalizeImages = true,
|
||||
}) {
|
||||
assertMaxBytes(maxBytes);
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
reject("empty_file");
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
reject("size_limit");
|
||||
}
|
||||
if (typeof normalizeImages !== "boolean") {
|
||||
throw new TypeError("normalizeImages must be a boolean");
|
||||
}
|
||||
|
||||
const { expectedType, originalName } = normalizeAndValidateFileName(fileName);
|
||||
const normalizedDeclaredMimeType = normalizeDeclaredMimeType(declaredMimeType);
|
||||
if (
|
||||
normalizedDeclaredMimeType &&
|
||||
TYPE_BY_DECLARED_MIME.get(normalizedDeclaredMimeType) !== expectedType
|
||||
) {
|
||||
reject("type_mismatch");
|
||||
}
|
||||
|
||||
const detectedType = detectSignatureType(buffer);
|
||||
if (!detectedType) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
if (detectedType !== expectedType) {
|
||||
reject("type_mismatch");
|
||||
}
|
||||
|
||||
validateFormatBoundaries(detectedType, buffer);
|
||||
|
||||
let normalizedBuffer = buffer;
|
||||
if (detectedType !== "pdf") {
|
||||
const inspectedBuffer = await inspectImage(buffer, detectedType);
|
||||
if (normalizeImages) {
|
||||
if (inspectedBuffer.length > maxBytes) {
|
||||
reject("size_limit");
|
||||
}
|
||||
normalizedBuffer = inspectedBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = CONTENT_TYPES[detectedType];
|
||||
return {
|
||||
buffer: normalizedBuffer,
|
||||
originalName,
|
||||
extension: descriptor.extension,
|
||||
mimeType: descriptor.mimeType,
|
||||
fileSize: normalizedBuffer.length,
|
||||
fileSha256: crypto.createHash("sha256").update(normalizedBuffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachmentStorageKey(purchaseRequestId, extension) {
|
||||
if (typeof purchaseRequestId !== "string" || !UUID_PATTERN.test(purchaseRequestId)) {
|
||||
throw new TypeError("purchaseRequestId must be a UUID");
|
||||
}
|
||||
if (!CANONICAL_EXTENSIONS.has(extension)) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
return `purchase-requests/${purchaseRequestId.toLowerCase()}/${crypto.randomUUID()}${extension}`;
|
||||
}
|
||||
|
||||
function sanitizeHeaderFileName(fileName) {
|
||||
const source = typeof fileName === "string" && fileName.trim()
|
||||
? fileName.normalize("NFC").trim()
|
||||
: "attachment.bin";
|
||||
const sanitized = truncateCodePoints(
|
||||
source
|
||||
.replace(/[\\/]/gu, "_")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "_")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, "_")
|
||||
.replace(/"+/gu, "_")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
);
|
||||
return sanitized && sanitized !== "." && sanitized !== ".."
|
||||
? sanitized
|
||||
: "attachment.bin";
|
||||
}
|
||||
|
||||
function buildAsciiFallback(fileName) {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
const rawExtension = lastDotIndex > 0 ? fileName.slice(lastDotIndex).toLowerCase() : ".bin";
|
||||
const extension = /^\.[a-z0-9]{1,10}$/u.test(rawExtension) ? rawExtension : ".bin";
|
||||
const rawStem = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
|
||||
const asciiStem = rawStem
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/gu, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/gu, "_")
|
||||
.replace(/_+/gu, "_")
|
||||
.replace(/^[._-]+|[._-]+$/gu, "")
|
||||
.slice(0, 100);
|
||||
return `${asciiStem || "attachment"}${extension}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987Value(value) {
|
||||
return encodeURIComponent(value).replace(/[\u0027\u0028\u0029\u002a]/gu, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentContentDisposition(fileName) {
|
||||
const safeFileName = sanitizeHeaderFileName(fileName);
|
||||
const asciiFallback = buildAsciiFallback(safeFileName);
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeRfc5987Value(safeFileName)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANONICAL_PURCHASE_ATTACHMENT_EXTENSIONS: Object.freeze([...CANONICAL_EXTENSIONS]),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
};
|
||||
@@ -0,0 +1,814 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { Readable } = require("node:stream");
|
||||
|
||||
const Fastify = require("fastify");
|
||||
const multipart = require("@fastify/multipart");
|
||||
|
||||
const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ATTACHMENT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const OTHER_EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const NOW = new Date("2026-07-17T08:00:00.000Z");
|
||||
const VALID_PDF = Buffer.from(
|
||||
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
|
||||
"ascii",
|
||||
);
|
||||
|
||||
class TestStorageNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("not_found");
|
||||
this.name = "StorageNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildMultipartRequest(parts) {
|
||||
const boundary = "----codex-purchase-attachment-boundary";
|
||||
const segments = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "field") {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"\r\n\r\n${part.value}\r\n`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${part.fieldname ?? "file"}"; filename="${part.filename}"\r\nContent-Type: ${part.contentType}\r\n\r\n`,
|
||||
),
|
||||
Buffer.isBuffer(part.content) ? part.content : Buffer.from(part.content),
|
||||
Buffer.from("\r\n"),
|
||||
);
|
||||
}
|
||||
|
||||
segments.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
return {
|
||||
payload: Buffer.concat(segments),
|
||||
headers: {
|
||||
authorization: "Bearer unit-test",
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPurchaseRequest(overrides = {}) {
|
||||
return {
|
||||
id: PURCHASE_REQUEST_ID,
|
||||
applicant_id: EMPLOYEE_ID,
|
||||
submitter_id: null,
|
||||
status: "draft",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachment(overrides = {}) {
|
||||
return {
|
||||
id: ATTACHMENT_ID,
|
||||
purchase_request_id: PURCHASE_REQUEST_ID,
|
||||
storage_path: `purchase-requests/${PURCHASE_REQUEST_ID}/${ATTACHMENT_ID}.pdf`,
|
||||
original_name: "采购报价.pdf",
|
||||
mime_type: "application/pdf",
|
||||
file_size: VALID_PDF.length,
|
||||
governance_status: "approved",
|
||||
sensitivity_level: "internal",
|
||||
attachment_type: "quote",
|
||||
download_policy: "standard",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = null,
|
||||
purchaseRequest = undefined,
|
||||
attachment = undefined,
|
||||
maxUploadSizeBytes = 1024 * 1024,
|
||||
storageOverrides = {},
|
||||
auditCreate = null,
|
||||
} = {}) {
|
||||
const effectiveActor = actor ?? {
|
||||
id: EMPLOYEE_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["procurement.create", "procurement.view_own"],
|
||||
};
|
||||
const state = {
|
||||
purchaseRequest: purchaseRequest === undefined ? buildPurchaseRequest() : purchaseRequest,
|
||||
attachment: attachment === undefined ? buildAttachment() : attachment,
|
||||
createdAttachmentData: null,
|
||||
};
|
||||
|
||||
const uploadAuditCreate = vi.fn().mockResolvedValue({ id: "upload-audit" });
|
||||
const downloadAuditCreate = auditCreate ?? vi.fn().mockResolvedValue({ id: "download-audit" });
|
||||
const attachmentCreate = vi.fn(async ({ data }) => {
|
||||
state.createdAttachmentData = data;
|
||||
return {
|
||||
id: ATTACHMENT_ID,
|
||||
original_name: data.original_name,
|
||||
mime_type: data.mime_type,
|
||||
file_size: data.file_size,
|
||||
file_sha256: data.file_sha256,
|
||||
governance_status: data.governance_status,
|
||||
sensitivity_level: data.sensitivity_level,
|
||||
attachment_type: data.attachment_type,
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
review_note: null,
|
||||
archived_by: null,
|
||||
archived_at: null,
|
||||
archive_note: null,
|
||||
download_policy: data.download_policy,
|
||||
uploaded_by: data.uploaded_by,
|
||||
created_at: NOW,
|
||||
uploader: {
|
||||
id: effectiveActor.id,
|
||||
employee_no: "E001",
|
||||
name: "测试员工",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const tx = {
|
||||
purchaseAttachment: { create: attachmentCreate },
|
||||
auditLog: { create: uploadAuditCreate },
|
||||
};
|
||||
const prisma = {
|
||||
purchaseRequest: {
|
||||
findUnique: vi.fn(async () => state.purchaseRequest),
|
||||
},
|
||||
purchaseAttachment: {
|
||||
findFirst: vi.fn(async ({ where }) => {
|
||||
if (!state.attachment) return null;
|
||||
return where.id === state.attachment.id &&
|
||||
where.purchase_request_id === state.attachment.purchase_request_id
|
||||
? state.attachment
|
||||
: null;
|
||||
}),
|
||||
},
|
||||
auditLog: { create: downloadAuditCreate },
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
};
|
||||
|
||||
const storage = {
|
||||
StorageNotFoundError: TestStorageNotFoundError,
|
||||
writeBuffer: vi.fn(async ({ key }) => key),
|
||||
openReadStream: vi.fn(async () => ({ stream: Readable.from([VALID_PDF]) })),
|
||||
...storageOverrides,
|
||||
};
|
||||
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
maxUploadSizeBytes,
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const permissionCodes = employee?.permissionCodes ?? [];
|
||||
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (typeof request.headers.authorization !== "string") {
|
||||
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
}
|
||||
request.employee = effectiveActor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
if (!app.hasPermission(request.employee, permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(multipart, {
|
||||
attachFieldsToBody: false,
|
||||
limits: { files: 1, fileSize: maxUploadSizeBytes },
|
||||
});
|
||||
await app.register(purchaseRequestRoutes, {
|
||||
prefix: "/api",
|
||||
attachmentStorage: storage,
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
attachmentCreate,
|
||||
downloadAuditCreate,
|
||||
prisma,
|
||||
state,
|
||||
storage,
|
||||
uploadAuditCreate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("purchase attachment upload", () => {
|
||||
it("consumes the file immediately, accepts trailing governance fields, and persists canonical bytes", async () => {
|
||||
const { app, attachmentCreate, state, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "采购报价.v2.pdf",
|
||||
contentType: "application/octet-stream",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
{ type: "field", name: "sensitivityLevel", value: "confidential" },
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
|
||||
const writeInput = storage.writeBuffer.mock.calls[0][0];
|
||||
expect(writeInput.key).toMatch(
|
||||
new RegExp(`^purchase-requests/${PURCHASE_REQUEST_ID}/[0-9a-f-]{36}\\.pdf$`),
|
||||
);
|
||||
expect(writeInput.key).not.toContain("采购报价");
|
||||
expect(writeInput.contentType).toBe("application/pdf");
|
||||
expect(writeInput.buffer.equals(VALID_PDF)).toBe(true);
|
||||
expect(state.createdAttachmentData).toEqual(
|
||||
expect.objectContaining({
|
||||
original_name: "采购报价.v2.pdf",
|
||||
mime_type: "application/pdf",
|
||||
file_size: VALID_PDF.length,
|
||||
sensitivity_level: "confidential",
|
||||
}),
|
||||
);
|
||||
expect(state.createdAttachmentData.file_sha256).toBe(
|
||||
crypto.createHash("sha256").update(writeInput.buffer).digest("hex"),
|
||||
);
|
||||
expect(attachmentCreate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts governance fields before the file without hanging", async () => {
|
||||
const { app, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{ type: "field", name: "attachmentType", value: "acceptance" },
|
||||
{
|
||||
type: "file",
|
||||
filename: "验收.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["non multipart", null, { file: "multipart_required" }],
|
||||
[
|
||||
"wrong file field",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
fieldname: "payload",
|
||||
filename: "报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
{ file: "invalid_field" },
|
||||
],
|
||||
[
|
||||
"path-shaped filename",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "../报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
{ file: "dangerous_filename" },
|
||||
],
|
||||
[
|
||||
"active content",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: "<!doctype html><script>void 0</script>",
|
||||
},
|
||||
]),
|
||||
null,
|
||||
],
|
||||
[
|
||||
"empty content",
|
||||
buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "空附件.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: Buffer.alloc(0),
|
||||
},
|
||||
]),
|
||||
{ file: "empty_file" },
|
||||
],
|
||||
])("rejects %s before storage or database writes", async (_label, multipartRequest, expectedDetails) => {
|
||||
const { app, attachmentCreate, storage, uploadAuditCreate } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
headers: multipartRequest?.headers ?? { authorization: "Bearer unit-test" },
|
||||
payload: multipartRequest?.payload,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.code).toBe("VALID_001");
|
||||
if (expectedDetails) {
|
||||
expect(response.json().error.details).toEqual(expectedDetails);
|
||||
}
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
expect(uploadAuditCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps the production single-file plugin limit to a stable validation error", async () => {
|
||||
const { app, attachmentCreate, storage } = await buildApp();
|
||||
const request = buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价一.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价二.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...request,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ file: "only_single_file_supported" });
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps the multipart byte limit to a stable validation error", async () => {
|
||||
const { app, attachmentCreate, storage } = await buildApp({ maxUploadSizeBytes: 32 });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "超限.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ file: "file_too_large" });
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing permission", { permissionCodes: ["procurement.view_own"] }, buildPurchaseRequest(), 403],
|
||||
[
|
||||
"another applicant",
|
||||
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
|
||||
buildPurchaseRequest({ applicant_id: OTHER_EMPLOYEE_ID }),
|
||||
403,
|
||||
],
|
||||
[
|
||||
"non-draft request",
|
||||
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
|
||||
buildPurchaseRequest({ status: "reviewing" }),
|
||||
409,
|
||||
],
|
||||
])("does not write upload data for %s", async (_label, actorInput, purchaseRequest, statusCode) => {
|
||||
const { app, attachmentCreate, storage } = await buildApp({
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
roleCodes: ["employee"],
|
||||
...actorInput,
|
||||
},
|
||||
purchaseRequest,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
|
||||
...buildMultipartRequest([
|
||||
{
|
||||
type: "file",
|
||||
filename: "报价.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: VALID_PDF,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(statusCode);
|
||||
expect(storage.writeBuffer).not.toHaveBeenCalled();
|
||||
expect(attachmentCreate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchase attachment authenticated download", () => {
|
||||
const downloadUrl = `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/download`;
|
||||
|
||||
it("requires current authentication before reading storage", async () => {
|
||||
const { app, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: downloadUrl, payload: {} });
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"missing purchase read permission",
|
||||
{
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
roleCodes: ["employee"],
|
||||
permissionCodes: ["procurement.create"],
|
||||
},
|
||||
},
|
||||
403,
|
||||
],
|
||||
[
|
||||
"another employee's purchase request",
|
||||
{
|
||||
purchaseRequest: buildPurchaseRequest({
|
||||
applicant_id: OTHER_EMPLOYEE_ID,
|
||||
submitter_id: OTHER_EMPLOYEE_ID,
|
||||
}),
|
||||
},
|
||||
403,
|
||||
],
|
||||
["an attachment outside the purchase request", { attachment: null }, 404],
|
||||
])("rejects %s before opening storage", async (_label, options, statusCode) => {
|
||||
const { app, storage } = await buildApp(options);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(statusCode);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a JSON-body reason for sensitive attachments", async () => {
|
||||
const { app, storage } = await buildApp({
|
||||
attachment: buildAttachment({
|
||||
sensitivity_level: "confidential",
|
||||
download_policy: "reason_required",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ reason: "required" });
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates an overlong download reason before writing audit metadata", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
attachment: buildAttachment({
|
||||
sensitivity_level: "confidential",
|
||||
download_policy: "reason_required",
|
||||
}),
|
||||
});
|
||||
const reason = "复".repeat(201);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: {
|
||||
authorization: "Bearer unit-test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
||||
extra: expect.objectContaining({ reason: "复".repeat(200) }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks quarantined attachments before storage and records a fixed reason", async () => {
|
||||
const { app, downloadAuditCreate, storage } = await buildApp({
|
||||
attachment: buildAttachment({ governance_status: "quarantined" }),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
extra: expect.objectContaining({ reasonCode: "quarantined" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks a recorded object above the hard limit before opening storage", async () => {
|
||||
const { app, storage } = await buildApp({
|
||||
maxUploadSizeBytes: 64,
|
||||
attachment: buildAttachment({ file_size: 65 }),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("size_limit");
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a safe not-found response and blocked audit when the stored object is missing", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
storageOverrides: {
|
||||
openReadStream: vi.fn(async () => {
|
||||
throw new TestStorageNotFoundError();
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error.message).toBe("附件不存在");
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
extra: expect.objectContaining({ reasonCode: "missing_object" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an actual object larger than the configured limit and destroys its stream", async () => {
|
||||
const oversizedStream = {
|
||||
destroyed: false,
|
||||
destroy: vi.fn(function destroy() {
|
||||
this.destroyed = true;
|
||||
}),
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield Buffer.alloc(65, 0x41);
|
||||
},
|
||||
};
|
||||
const { app, storage } = await buildApp({
|
||||
maxUploadSizeBytes: 64,
|
||||
attachment: buildAttachment({ file_size: 1 }),
|
||||
storageOverrides: {
|
||||
openReadStream: vi.fn(async () => ({ stream: oversizedStream })),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("size_limit");
|
||||
expect(oversizedStream.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(storage.openReadStream).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks historical active content after byte validation and sends no attachment headers", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
storageOverrides: {
|
||||
openReadStream: vi.fn(async () => ({
|
||||
stream: Readable.from([Buffer.from("<!doctype html><script>void 0</script>")]),
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.headers["content-disposition"]).toBeUndefined();
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a validated historical attachment with the complete forced-download header contract", async () => {
|
||||
const { app, downloadAuditCreate } = await buildApp({
|
||||
attachment: buildAttachment({
|
||||
mime_type: "application/octet-stream",
|
||||
sensitivity_level: "confidential",
|
||||
download_policy: "reason_required",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: {
|
||||
authorization: "Bearer unit-test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: JSON.stringify({ reason: "审批复核" }),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.rawPayload.equals(VALID_PDF)).toBe(true);
|
||||
expect(response.headers["content-type"]).toContain("application/octet-stream");
|
||||
expect(response.headers["content-disposition"]).toContain("attachment;");
|
||||
expect(response.headers["content-disposition"]).toContain("filename*=");
|
||||
expect(response.headers["content-disposition"]).not.toContain("inline");
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||
expect(response.headers["cache-control"]).toBe("private, no-store, max-age=0");
|
||||
expect(response.headers.pragma).toBe("no-cache");
|
||||
expect(response.headers["referrer-policy"]).toBe("no-referrer");
|
||||
expect(response.headers["content-security-policy"]).toContain("sandbox");
|
||||
expect(response.headers["cross-origin-resource-policy"]).toBe("same-origin");
|
||||
expect(downloadAuditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
|
||||
extra: expect.objectContaining({ reason: "审批复核" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed before setting attachment headers when success auditing fails", async () => {
|
||||
const auditCreate = vi.fn().mockRejectedValue(new Error("database unavailable"));
|
||||
const { app } = await buildApp({ auditCreate });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: downloadUrl,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.headers["content-disposition"]).toBeUndefined();
|
||||
expect(response.json().error.details).toEqual({ attachment: "audit_failed" });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retires both legacy signed and query-token routes", async () => {
|
||||
const { app, storage } = await buildApp();
|
||||
|
||||
try {
|
||||
const signedResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/signed-url`,
|
||||
headers: { authorization: "Bearer unit-test" },
|
||||
payload: {},
|
||||
});
|
||||
const fileResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/file?token=legacy`,
|
||||
});
|
||||
|
||||
expect(signedResponse.statusCode).toBe(404);
|
||||
expect(fileResponse.statusCode).toBe(404);
|
||||
expect(storage.openReadStream).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sharp = require("sharp");
|
||||
|
||||
const {
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
} = require("../../../src/services/purchaseAttachmentPolicy");
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function buildPdf(body = "1 0 obj\n<<>>\nendobj") {
|
||||
return Buffer.from(`%PDF-1.7\n${body}\n%%EOF\n`, "ascii");
|
||||
}
|
||||
|
||||
function expectPolicyError(error, reasonCode) {
|
||||
expect(error).toBeInstanceOf(PurchaseAttachmentPolicyError);
|
||||
expect(error).toMatchObject({
|
||||
code: "PURCHASE_ATTACHMENT_POLICY_ERROR",
|
||||
reasonCode,
|
||||
details: { file: reasonCode },
|
||||
});
|
||||
}
|
||||
|
||||
async function capturePolicyError(input) {
|
||||
try {
|
||||
await validateAndNormalizePurchaseAttachment(input);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("expected purchase attachment policy rejection");
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function patchPngDimensions(buffer, width, height) {
|
||||
const patched = Buffer.from(buffer);
|
||||
patched.writeUInt32BE(width, 16);
|
||||
patched.writeUInt32BE(height, 20);
|
||||
patched.writeUInt32BE(crc32(patched.subarray(12, 29)), 29);
|
||||
return patched;
|
||||
}
|
||||
|
||||
function buildPngChunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const chunk = Buffer.alloc(12 + data.length);
|
||||
chunk.writeUInt32BE(data.length, 0);
|
||||
typeBuffer.copy(chunk, 4);
|
||||
data.copy(chunk, 8);
|
||||
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parsePngChunks(buffer) {
|
||||
const chunks = [];
|
||||
let offset = 8;
|
||||
while (offset < buffer.length) {
|
||||
const dataLength = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
chunks.push({
|
||||
type,
|
||||
data: buffer.subarray(offset + 8, offset + 8 + dataLength),
|
||||
});
|
||||
offset += 12 + dataLength;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildAnimatedPng(firstFrame, secondFrame) {
|
||||
const firstChunks = parsePngChunks(firstFrame);
|
||||
const secondChunks = parsePngChunks(secondFrame);
|
||||
const ihdr = firstChunks.find((chunk) => chunk.type === "IHDR").data;
|
||||
const width = ihdr.readUInt32BE(0);
|
||||
const height = ihdr.readUInt32BE(4);
|
||||
let sequence = 0;
|
||||
|
||||
const animationControl = Buffer.alloc(8);
|
||||
animationControl.writeUInt32BE(2, 0);
|
||||
animationControl.writeUInt32BE(0, 4);
|
||||
|
||||
const frameControl = () => {
|
||||
const data = Buffer.alloc(26);
|
||||
data.writeUInt32BE(sequence, 0);
|
||||
sequence += 1;
|
||||
data.writeUInt32BE(width, 4);
|
||||
data.writeUInt32BE(height, 8);
|
||||
data.writeUInt16BE(1, 20);
|
||||
data.writeUInt16BE(10, 22);
|
||||
return buildPngChunk("fcTL", data);
|
||||
};
|
||||
|
||||
const parts = [
|
||||
firstFrame.subarray(0, 8),
|
||||
buildPngChunk("IHDR", ihdr),
|
||||
buildPngChunk("acTL", animationControl),
|
||||
frameControl(),
|
||||
...firstChunks
|
||||
.filter((chunk) => chunk.type === "IDAT")
|
||||
.map((chunk) => buildPngChunk("IDAT", chunk.data)),
|
||||
frameControl(),
|
||||
];
|
||||
for (const chunk of secondChunks.filter((candidate) => candidate.type === "IDAT")) {
|
||||
const frameData = Buffer.alloc(4 + chunk.data.length);
|
||||
frameData.writeUInt32BE(sequence, 0);
|
||||
sequence += 1;
|
||||
chunk.data.copy(frameData, 4);
|
||||
parts.push(buildPngChunk("fdAT", frameData));
|
||||
}
|
||||
parts.push(buildPngChunk("IEND", Buffer.alloc(0)));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
describe("purchase attachment content policy", () => {
|
||||
let jpegBuffer;
|
||||
let pngBuffer;
|
||||
let webpBuffer;
|
||||
|
||||
beforeAll(async () => {
|
||||
const source = {
|
||||
create: {
|
||||
width: 4,
|
||||
height: 3,
|
||||
channels: 4,
|
||||
background: { r: 30, g: 120, b: 210, alpha: 1 },
|
||||
},
|
||||
};
|
||||
[jpegBuffer, pngBuffer, webpBuffer] = await Promise.all([
|
||||
sharp(source).jpeg().toBuffer(),
|
||||
sharp(source).png().toBuffer(),
|
||||
sharp(source).webp().toBuffer(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a strict PDF boundary and preserves its bytes", async () => {
|
||||
const buffer = buildPdf();
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer,
|
||||
fileName: "报价.v2.PDF",
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
buffer,
|
||||
originalName: "报价.v2.pdf",
|
||||
extension: ".pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: buffer.length,
|
||||
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["photo.jpeg", "image/jpg", "jpeg", ".jpg", "image/jpeg"],
|
||||
["evidence.png", "image/png", "png", ".png", "image/png"],
|
||||
["evidence.webp", "image/webp", "webp", ".webp", "image/webp"],
|
||||
])("decodes and normalizes an allowed %s image", async (fileName, declaredMimeType, fixture, extension, mimeType) => {
|
||||
const source = { jpeg: jpegBuffer, png: pngBuffer, webp: webpBuffer }[fixture];
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: source,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(result.originalName).toBe(`${fileName.slice(0, fileName.lastIndexOf("."))}${extension}`);
|
||||
expect(result.extension).toBe(extension);
|
||||
expect(result.mimeType).toBe(mimeType);
|
||||
expect(result.fileSize).toBe(result.buffer.length);
|
||||
expect(result.fileSha256).toBe(
|
||||
crypto.createHash("sha256").update(result.buffer).digest("hex"),
|
||||
);
|
||||
const metadata = await sharp(result.buffer).metadata();
|
||||
expect(metadata.format).toBe(fixture);
|
||||
expect(metadata.pages ?? 1).toBe(1);
|
||||
});
|
||||
|
||||
it("fully validates but does not rewrite historical image bytes when normalization is disabled", async () => {
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: pngBuffer,
|
||||
fileName: "历史附件.png",
|
||||
declaredMimeType: "application/octet-stream",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expect(result.buffer).toBe(pngBuffer);
|
||||
expect(result.fileSize).toBe(pngBuffer.length);
|
||||
expect(result.mimeType).toBe("image/png");
|
||||
expect(result.fileSha256).toBe(
|
||||
crypto.createHash("sha256").update(pngBuffer).digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the normalized-output size limit only when storing a normalized upload", async () => {
|
||||
const compactPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const historical = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: compactPng,
|
||||
fileName: "历史附件.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: compactPng.length,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expect(historical.buffer).toBe(compactPng);
|
||||
expect(historical.fileSize).toBe(compactPng.length);
|
||||
|
||||
const uploadError = await capturePolicyError({
|
||||
buffer: compactPng,
|
||||
fileName: "新附件.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: compactPng.length,
|
||||
normalizeImages: true,
|
||||
});
|
||||
expectPolicyError(uploadError, "size_limit");
|
||||
});
|
||||
|
||||
it("still fully decodes corrupt historical image bytes when normalization is disabled", async () => {
|
||||
const boundaryOnlyJpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x00, 0xff, 0xd9]);
|
||||
const error = await capturePolicyError({
|
||||
buffer: boundaryOnlyJpeg,
|
||||
fileName: "历史损坏附件.jpg",
|
||||
declaredMimeType: "image/jpeg",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("rejects warning-only corrupt historical JPEG bytes when normalization is disabled", async () => {
|
||||
const validJpeg = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 3,
|
||||
background: { r: 30, g: 120, b: 210 },
|
||||
},
|
||||
}).jpeg().toBuffer();
|
||||
expect(validJpeg.subarray(-2)).toEqual(Buffer.from([0xff, 0xd9]));
|
||||
|
||||
const warningOnlyCorruptJpeg = Buffer.concat([
|
||||
validJpeg.subarray(0, validJpeg.length - 3),
|
||||
Buffer.from([0xff, 0xd9]),
|
||||
]);
|
||||
const error = await capturePolicyError({
|
||||
buffer: warningOnlyCorruptJpeg,
|
||||
fileName: "历史损坏附件.jpg",
|
||||
declaredMimeType: "image/jpeg",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("accepts an empty historical MIME only when extension and bytes agree", async () => {
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: jpegBuffer,
|
||||
fileName: "历史照片.jpg",
|
||||
declaredMimeType: " ",
|
||||
maxBytes: MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
|
||||
expect(result.extension).toBe(".jpg");
|
||||
expect(result.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("strips image metadata when normalizing", async () => {
|
||||
const withMetadata = await sharp({
|
||||
create: {
|
||||
width: 3,
|
||||
height: 2,
|
||||
channels: 3,
|
||||
background: { r: 200, g: 20, b: 10 },
|
||||
},
|
||||
}).withMetadata({ orientation: 6 }).jpeg().toBuffer();
|
||||
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: withMetadata,
|
||||
fileName: "orientation.jpg",
|
||||
declaredMimeType: "image/jpeg",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
const metadata = await sharp(result.buffer).metadata();
|
||||
expect(metadata.orientation).toBeUndefined();
|
||||
expect(metadata.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["quote.html.pdf", "dangerous_filename"],
|
||||
["quote.svg.png", "dangerous_filename"],
|
||||
["../quote.pdf", "dangerous_filename"],
|
||||
["folder\\quote.pdf", "dangerous_filename"],
|
||||
["quote\r\nname.pdf", "dangerous_filename"],
|
||||
["quote\u202Efdp.pdf", "dangerous_filename"],
|
||||
["quote.pdf.html", "unsupported_type"],
|
||||
["quote.txt", "unsupported_type"],
|
||||
])("rejects unsafe or unsupported filename %s", async (fileName, reasonCode) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer: buildPdf(),
|
||||
fileName,
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, reasonCode);
|
||||
});
|
||||
|
||||
it("truncates a long safe display name while preserving its canonical extension", async () => {
|
||||
const result = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: buildPdf(),
|
||||
fileName: `${"报".repeat(300)}.pdf`,
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
|
||||
expect(Array.from(result.originalName)).toHaveLength(
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
);
|
||||
expect(result.originalName.endsWith(".pdf")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["text/html", "unsupported_type"],
|
||||
["image/png", "type_mismatch"],
|
||||
])("rejects invalid declared MIME %s", async (declaredMimeType, reasonCode) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer: buildPdf(),
|
||||
fileName: "quote.pdf",
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, reasonCode);
|
||||
});
|
||||
|
||||
it("rejects allowed bytes whose extension declares another allowed type", async () => {
|
||||
const error = await capturePolicyError({
|
||||
buffer: pngBuffer,
|
||||
fileName: "quote.jpg",
|
||||
declaredMimeType: "application/octet-stream",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "type_mismatch");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Buffer.from("<!doctype html><title>marker</title>", "utf8"), "quote.pdf"],
|
||||
[Buffer.from("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>", "utf8"), "quote.png"],
|
||||
[Buffer.from("plain marker", "utf8"), "quote.webp"],
|
||||
])("rejects active or unknown content before Sharp", async (buffer, fileName) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType: "application/octet-stream",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "unsupported_type");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Buffer.from("%PDF-1.7\ntruncated", "ascii"), "truncated.pdf", "application/pdf"],
|
||||
[Buffer.from("%PDF-1.9\nmarker\n%%EOF\n", "ascii"), "version.pdf", "application/pdf"],
|
||||
[Buffer.concat([buildPdf(), Buffer.from("<html>tail</html>", "ascii")]), "tail.pdf", "application/pdf"],
|
||||
])("rejects truncated or trailing PDF content", async (buffer, fileName, declaredMimeType) => {
|
||||
const error = await capturePolicyError({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["jpeg", "truncated.jpg", "image/jpeg", 2],
|
||||
["png", "truncated.png", "image/png", 4],
|
||||
])("rejects truncated %s content", async (fixture, fileName, declaredMimeType, bytesToRemove) => {
|
||||
const source = { jpeg: jpegBuffer, png: pngBuffer }[fixture];
|
||||
const error = await capturePolicyError({
|
||||
buffer: source.subarray(0, source.length - bytesToRemove),
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("rejects a WEBP whose RIFF length does not match the actual bytes", async () => {
|
||||
const corrupted = Buffer.from(webpBuffer);
|
||||
corrupted.writeUInt32LE(corrupted.readUInt32LE(4) - 1, 4);
|
||||
const error = await capturePolicyError({
|
||||
buffer: corrupted,
|
||||
fileName: "corrupted.webp",
|
||||
declaredMimeType: "image/webp",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "corrupt_content");
|
||||
});
|
||||
|
||||
it("rejects an animated WEBP as multiple pages", async () => {
|
||||
const [firstFrame, secondFrame] = await Promise.all([
|
||||
sharp({
|
||||
create: {
|
||||
width: 2,
|
||||
height: 2,
|
||||
channels: 4,
|
||||
background: { r: 10, g: 200, b: 100, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer(),
|
||||
sharp({
|
||||
create: {
|
||||
width: 2,
|
||||
height: 2,
|
||||
channels: 4,
|
||||
background: { r: 200, g: 10, b: 100, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer(),
|
||||
]);
|
||||
const animated = await sharp(
|
||||
[firstFrame, secondFrame],
|
||||
{ join: { animated: true } },
|
||||
).webp({ loop: 0, delay: [100, 100] }).toBuffer();
|
||||
|
||||
const error = await capturePolicyError({
|
||||
buffer: animated,
|
||||
fileName: "animated.webp",
|
||||
declaredMimeType: "image/webp",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "multiple_pages");
|
||||
});
|
||||
|
||||
it("rejects an APNG animation control stream as multiple pages", async () => {
|
||||
const secondFrame = await sharp({
|
||||
create: {
|
||||
width: 4,
|
||||
height: 3,
|
||||
channels: 4,
|
||||
background: { r: 210, g: 30, b: 120, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer();
|
||||
const animatedPng = buildAnimatedPng(pngBuffer, secondFrame);
|
||||
|
||||
expect(animatedPng.includes(Buffer.from("acTL", "ascii"))).toBe(true);
|
||||
expect(animatedPng.includes(Buffer.from("fdAT", "ascii"))).toBe(true);
|
||||
const error = await capturePolicyError({
|
||||
buffer: animatedPng,
|
||||
fileName: "animated.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "multiple_pages");
|
||||
});
|
||||
|
||||
it("rejects image metadata above the 40MP hard limit", async () => {
|
||||
const oversizedHeader = patchPngDimensions(pngBuffer, 10_000, 4_001);
|
||||
expect(10_000 * 4_001).toBeGreaterThan(PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS);
|
||||
|
||||
const error = await capturePolicyError({
|
||||
buffer: oversizedHeader,
|
||||
fileName: "oversized.png",
|
||||
declaredMimeType: "image/png",
|
||||
maxBytes: MAX_BYTES,
|
||||
});
|
||||
expectPolicyError(error, "pixel_limit");
|
||||
});
|
||||
|
||||
it("rejects empty and oversized buffers with stable reasons", async () => {
|
||||
const emptyError = await capturePolicyError({
|
||||
buffer: Buffer.alloc(0),
|
||||
fileName: "empty.pdf",
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: 10,
|
||||
});
|
||||
expectPolicyError(emptyError, "empty_file");
|
||||
|
||||
const sizeError = await capturePolicyError({
|
||||
buffer: buildPdf(),
|
||||
fileName: "large.pdf",
|
||||
declaredMimeType: "application/pdf",
|
||||
maxBytes: 4,
|
||||
});
|
||||
expectPolicyError(sizeError, "size_limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchase attachment storage and response helpers", () => {
|
||||
it("builds a random canonical storage key without an original basename", () => {
|
||||
const purchaseRequestId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const first = buildAttachmentStorageKey(purchaseRequestId, ".pdf");
|
||||
const second = buildAttachmentStorageKey(purchaseRequestId, ".pdf");
|
||||
|
||||
expect(first).toMatch(
|
||||
/^purchase-requests\/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\/[0-9a-f-]{36}\.pdf$/u,
|
||||
);
|
||||
expect(second).not.toBe(first);
|
||||
expect(first).not.toContain("quote");
|
||||
});
|
||||
|
||||
it("rejects invalid storage key inputs", () => {
|
||||
expect(() => buildAttachmentStorageKey("not-an-id", ".pdf")).toThrow(TypeError);
|
||||
try {
|
||||
buildAttachmentStorageKey("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ".html");
|
||||
throw new Error("expected invalid extension rejection");
|
||||
} catch (error) {
|
||||
expectPolicyError(error, "unsupported_type");
|
||||
}
|
||||
});
|
||||
|
||||
it("builds RFC 5987 Content-Disposition with a safe ASCII fallback", () => {
|
||||
const header = buildAttachmentContentDisposition("采购报价.pdf");
|
||||
|
||||
expect(header).toBe(
|
||||
"attachment; filename=\"attachment.pdf\"; filename*=UTF-8''%E9%87%87%E8%B4%AD%E6%8A%A5%E4%BB%B7.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes header injection and path syntax from Content-Disposition", () => {
|
||||
const header = buildAttachmentContentDisposition("folder/evil\"\r\nX-Test: marker\\quote.pdf");
|
||||
|
||||
expect(header.startsWith("attachment; filename=\"")).toBe(true);
|
||||
expect(header).not.toMatch(/[\r\n]/u);
|
||||
expect(header).not.toContain("/evil");
|
||||
expect(header).not.toContain("\\quote");
|
||||
expect(header).toContain("filename*=UTF-8''");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户