546 行
18 KiB
JavaScript
546 行
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) => {
|
|
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("<html>tail</html>", "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''");
|
|
});
|
|
});
|