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