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, };