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("marker", "utf8"), "quote.pdf"], [Buffer.from("", "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("tail", "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''"); }); it("supports safe inline Content-Disposition without changing the default", () => { const header = buildAttachmentContentDisposition("铁路电子客票.pdf", { disposition: "inline" }); expect(header).toBe( "inline; filename=\"attachment.pdf\"; filename*=UTF-8''%E9%93%81%E8%B7%AF%E7%94%B5%E5%AD%90%E5%AE%A2%E7%A5%A8.pdf", ); expect(() => buildAttachmentContentDisposition("file.pdf", { disposition: "preview" })).toThrow(TypeError); }); });