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

这个提交包含在:
2026-07-17 20:32:05 +08:00
父节点 4c5eb7e22e
当前提交 f424492751
共修改 16 个文件,包含 2999 行新增和 211 行删除
+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);
},
);
}