文件
hyb-oa/server/tests/unit/services/purchaseAttachmentPolicy.test.js
T

555 行
18 KiB
JavaScript

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("<!doctype html><title>marker</title>", "utf8"), "quote.pdf"],
[Buffer.from("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>", "utf8"), "quote.png"],
[Buffer.from("plain marker", "utf8"), "quote.webp"],
])("rejects active or unknown content before Sharp", async (buffer, fileName) => {