修复采购附件同源 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);
},
);
}
@@ -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,
};