修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)

这个提交包含在:
2026-07-17 20:32:05 +08:00
父节点 4c5eb7e22e
当前提交 f424492751
共修改 16 个文件,包含 2999 行新增和 211 行删除
@@ -0,0 +1,814 @@
const crypto = require("node:crypto");
const { Readable } = require("node:stream");
const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const purchaseRequestRoutes = require("../../../src/routes/purchaseRequests");
const { AppError } = require("../../../src/utils/AppError");
const PURCHASE_REQUEST_ID = "11111111-1111-4111-8111-111111111111";
const ATTACHMENT_ID = "22222222-2222-4222-8222-222222222222";
const EMPLOYEE_ID = "33333333-3333-4333-8333-333333333333";
const OTHER_EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444";
const NOW = new Date("2026-07-17T08:00:00.000Z");
const VALID_PDF = Buffer.from(
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\nstartxref\n0\n%%EOF\n",
"ascii",
);
class TestStorageNotFoundError extends Error {
constructor() {
super("not_found");
this.name = "StorageNotFoundError";
}
}
function buildMultipartRequest(parts) {
const boundary = "----codex-purchase-attachment-boundary";
const segments = [];
for (const part of parts) {
if (part.type === "field") {
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"\r\n\r\n${part.value}\r\n`,
),
);
continue;
}
segments.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${part.fieldname ?? "file"}"; filename="${part.filename}"\r\nContent-Type: ${part.contentType}\r\n\r\n`,
),
Buffer.isBuffer(part.content) ? part.content : Buffer.from(part.content),
Buffer.from("\r\n"),
);
}
segments.push(Buffer.from(`--${boundary}--\r\n`));
return {
payload: Buffer.concat(segments),
headers: {
authorization: "Bearer unit-test",
"content-type": `multipart/form-data; boundary=${boundary}`,
},
};
}
function buildPurchaseRequest(overrides = {}) {
return {
id: PURCHASE_REQUEST_ID,
applicant_id: EMPLOYEE_ID,
submitter_id: null,
status: "draft",
...overrides,
};
}
function buildAttachment(overrides = {}) {
return {
id: ATTACHMENT_ID,
purchase_request_id: PURCHASE_REQUEST_ID,
storage_path: `purchase-requests/${PURCHASE_REQUEST_ID}/${ATTACHMENT_ID}.pdf`,
original_name: "采购报价.pdf",
mime_type: "application/pdf",
file_size: VALID_PDF.length,
governance_status: "approved",
sensitivity_level: "internal",
attachment_type: "quote",
download_policy: "standard",
...overrides,
};
}
async function buildApp({
actor = null,
purchaseRequest = undefined,
attachment = undefined,
maxUploadSizeBytes = 1024 * 1024,
storageOverrides = {},
auditCreate = null,
} = {}) {
const effectiveActor = actor ?? {
id: EMPLOYEE_ID,
roleCodes: ["employee"],
permissionCodes: ["procurement.create", "procurement.view_own"],
};
const state = {
purchaseRequest: purchaseRequest === undefined ? buildPurchaseRequest() : purchaseRequest,
attachment: attachment === undefined ? buildAttachment() : attachment,
createdAttachmentData: null,
};
const uploadAuditCreate = vi.fn().mockResolvedValue({ id: "upload-audit" });
const downloadAuditCreate = auditCreate ?? vi.fn().mockResolvedValue({ id: "download-audit" });
const attachmentCreate = vi.fn(async ({ data }) => {
state.createdAttachmentData = data;
return {
id: ATTACHMENT_ID,
original_name: data.original_name,
mime_type: data.mime_type,
file_size: data.file_size,
file_sha256: data.file_sha256,
governance_status: data.governance_status,
sensitivity_level: data.sensitivity_level,
attachment_type: data.attachment_type,
reviewed_by: null,
reviewed_at: null,
review_note: null,
archived_by: null,
archived_at: null,
archive_note: null,
download_policy: data.download_policy,
uploaded_by: data.uploaded_by,
created_at: NOW,
uploader: {
id: effectiveActor.id,
employee_no: "E001",
name: "测试员工",
},
};
});
const tx = {
purchaseAttachment: { create: attachmentCreate },
auditLog: { create: uploadAuditCreate },
};
const prisma = {
purchaseRequest: {
findUnique: vi.fn(async () => state.purchaseRequest),
},
purchaseAttachment: {
findFirst: vi.fn(async ({ where }) => {
if (!state.attachment) return null;
return where.id === state.attachment.id &&
where.purchase_request_id === state.attachment.purchase_request_id
? state.attachment
: null;
}),
},
auditLog: { create: downloadAuditCreate },
$transaction: vi.fn(async (callback) => callback(tx)),
};
const storage = {
StorageNotFoundError: TestStorageNotFoundError,
writeBuffer: vi.fn(async ({ key }) => key),
openReadStream: vi.fn(async () => ({ stream: Readable.from([VALID_PDF]) })),
...storageOverrides,
};
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
maxUploadSizeBytes,
});
app.decorate("hasPermission", (employee, permissionCode) => {
const permissionCodes = employee?.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("authenticate", async (request) => {
if (typeof request.headers.authorization !== "string") {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = effectiveActor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 1, fileSize: maxUploadSizeBytes },
});
await app.register(purchaseRequestRoutes, {
prefix: "/api",
attachmentStorage: storage,
});
return {
app,
attachmentCreate,
downloadAuditCreate,
prisma,
state,
storage,
uploadAuditCreate,
};
}
describe("purchase attachment upload", () => {
it("consumes the file immediately, accepts trailing governance fields, and persists canonical bytes", async () => {
const { app, attachmentCreate, state, storage } = await buildApp();
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{
type: "file",
filename: "采购报价.v2.pdf",
contentType: "application/octet-stream",
content: VALID_PDF,
},
{ type: "field", name: "sensitivityLevel", value: "confidential" },
]),
});
expect(response.statusCode).toBe(200);
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
const writeInput = storage.writeBuffer.mock.calls[0][0];
expect(writeInput.key).toMatch(
new RegExp(`^purchase-requests/${PURCHASE_REQUEST_ID}/[0-9a-f-]{36}\\.pdf$`),
);
expect(writeInput.key).not.toContain("采购报价");
expect(writeInput.contentType).toBe("application/pdf");
expect(writeInput.buffer.equals(VALID_PDF)).toBe(true);
expect(state.createdAttachmentData).toEqual(
expect.objectContaining({
original_name: "采购报价.v2.pdf",
mime_type: "application/pdf",
file_size: VALID_PDF.length,
sensitivity_level: "confidential",
}),
);
expect(state.createdAttachmentData.file_sha256).toBe(
crypto.createHash("sha256").update(writeInput.buffer).digest("hex"),
);
expect(attachmentCreate).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it("accepts governance fields before the file without hanging", async () => {
const { app, storage } = await buildApp();
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{ type: "field", name: "attachmentType", value: "acceptance" },
{
type: "file",
filename: "验收.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
});
expect(response.statusCode).toBe(200);
expect(storage.writeBuffer).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it.each([
["non multipart", null, { file: "multipart_required" }],
[
"wrong file field",
buildMultipartRequest([
{
type: "file",
fieldname: "payload",
filename: "报价.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
{ file: "invalid_field" },
],
[
"path-shaped filename",
buildMultipartRequest([
{
type: "file",
filename: "../报价.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
{ file: "dangerous_filename" },
],
[
"active content",
buildMultipartRequest([
{
type: "file",
filename: "报价.pdf",
contentType: "application/pdf",
content: "<!doctype html><script>void 0</script>",
},
]),
null,
],
[
"empty content",
buildMultipartRequest([
{
type: "file",
filename: "空附件.pdf",
contentType: "application/pdf",
content: Buffer.alloc(0),
},
]),
{ file: "empty_file" },
],
])("rejects %s before storage or database writes", async (_label, multipartRequest, expectedDetails) => {
const { app, attachmentCreate, storage, uploadAuditCreate } = await buildApp();
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
headers: multipartRequest?.headers ?? { authorization: "Bearer unit-test" },
payload: multipartRequest?.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("VALID_001");
if (expectedDetails) {
expect(response.json().error.details).toEqual(expectedDetails);
}
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
expect(uploadAuditCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("maps the production single-file plugin limit to a stable validation error", async () => {
const { app, attachmentCreate, storage } = await buildApp();
const request = buildMultipartRequest([
{
type: "file",
filename: "报价一.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
{
type: "file",
filename: "报价二.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]);
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...request,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ file: "only_single_file_supported" });
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("maps the multipart byte limit to a stable validation error", async () => {
const { app, attachmentCreate, storage } = await buildApp({ maxUploadSizeBytes: 32 });
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{
type: "file",
filename: "超限.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ file: "file_too_large" });
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
["missing permission", { permissionCodes: ["procurement.view_own"] }, buildPurchaseRequest(), 403],
[
"another applicant",
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
buildPurchaseRequest({ applicant_id: OTHER_EMPLOYEE_ID }),
403,
],
[
"non-draft request",
{ permissionCodes: ["procurement.create", "procurement.view_own"] },
buildPurchaseRequest({ status: "reviewing" }),
409,
],
])("does not write upload data for %s", async (_label, actorInput, purchaseRequest, statusCode) => {
const { app, attachmentCreate, storage } = await buildApp({
actor: {
id: EMPLOYEE_ID,
roleCodes: ["employee"],
...actorInput,
},
purchaseRequest,
});
try {
const response = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments`,
...buildMultipartRequest([
{
type: "file",
filename: "报价.pdf",
contentType: "application/pdf",
content: VALID_PDF,
},
]),
});
expect(response.statusCode).toBe(statusCode);
expect(storage.writeBuffer).not.toHaveBeenCalled();
expect(attachmentCreate).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
});
describe("purchase attachment authenticated download", () => {
const downloadUrl = `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/download`;
it("requires current authentication before reading storage", async () => {
const { app, storage } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: downloadUrl, payload: {} });
expect(response.statusCode).toBe(401);
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
[
"missing purchase read permission",
{
actor: {
id: EMPLOYEE_ID,
roleCodes: ["employee"],
permissionCodes: ["procurement.create"],
},
},
403,
],
[
"another employee's purchase request",
{
purchaseRequest: buildPurchaseRequest({
applicant_id: OTHER_EMPLOYEE_ID,
submitter_id: OTHER_EMPLOYEE_ID,
}),
},
403,
],
["an attachment outside the purchase request", { attachment: null }, 404],
])("rejects %s before opening storage", async (_label, options, statusCode) => {
const { app, storage } = await buildApp(options);
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(statusCode);
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("requires a JSON-body reason for sensitive attachments", async () => {
const { app, storage } = await buildApp({
attachment: buildAttachment({
sensitivity_level: "confidential",
download_policy: "reason_required",
}),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ reason: "required" });
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("truncates an overlong download reason before writing audit metadata", async () => {
const { app, downloadAuditCreate } = await buildApp({
attachment: buildAttachment({
sensitivity_level: "confidential",
download_policy: "reason_required",
}),
});
const reason = "复".repeat(201);
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: {
authorization: "Bearer unit-test",
"content-type": "application/json",
},
payload: JSON.stringify({ reason }),
});
expect(response.statusCode).toBe(200);
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
extra: expect.objectContaining({ reason: "复".repeat(200) }),
}),
}),
);
} finally {
await app.close();
}
});
it("blocks quarantined attachments before storage and records a fixed reason", async () => {
const { app, downloadAuditCreate, storage } = await buildApp({
attachment: buildAttachment({ governance_status: "quarantined" }),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(storage.openReadStream).not.toHaveBeenCalled();
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
extra: expect.objectContaining({ reasonCode: "quarantined" }),
}),
}),
);
} finally {
await app.close();
}
});
it("blocks a recorded object above the hard limit before opening storage", async () => {
const { app, storage } = await buildApp({
maxUploadSizeBytes: 64,
attachment: buildAttachment({ file_size: 65 }),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("size_limit");
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("returns a safe not-found response and blocked audit when the stored object is missing", async () => {
const { app, downloadAuditCreate } = await buildApp({
storageOverrides: {
openReadStream: vi.fn(async () => {
throw new TestStorageNotFoundError();
}),
},
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(404);
expect(response.json().error.message).toBe("附件不存在");
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
extra: expect.objectContaining({ reasonCode: "missing_object" }),
}),
}),
);
} finally {
await app.close();
}
});
it("rejects an actual object larger than the configured limit and destroys its stream", async () => {
const oversizedStream = {
destroyed: false,
destroy: vi.fn(function destroy() {
this.destroyed = true;
}),
async *[Symbol.asyncIterator]() {
yield Buffer.alloc(65, 0x41);
},
};
const { app, storage } = await buildApp({
maxUploadSizeBytes: 64,
attachment: buildAttachment({ file_size: 1 }),
storageOverrides: {
openReadStream: vi.fn(async () => ({ stream: oversizedStream })),
},
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reason).toBe("size_limit");
expect(oversizedStream.destroy).toHaveBeenCalledTimes(1);
expect(storage.openReadStream).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it("blocks historical active content after byte validation and sends no attachment headers", async () => {
const { app, downloadAuditCreate } = await buildApp({
storageOverrides: {
openReadStream: vi.fn(async () => ({
stream: Readable.from([Buffer.from("<!doctype html><script>void 0</script>")]),
})),
},
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.headers["content-disposition"]).toBeUndefined();
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOAD_BLOCKED",
}),
}),
);
} finally {
await app.close();
}
});
it("returns a validated historical attachment with the complete forced-download header contract", async () => {
const { app, downloadAuditCreate } = await buildApp({
attachment: buildAttachment({
mime_type: "application/octet-stream",
sensitivity_level: "confidential",
download_policy: "reason_required",
}),
});
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: {
authorization: "Bearer unit-test",
"content-type": "application/json",
},
payload: JSON.stringify({ reason: "审批复核" }),
});
expect(response.statusCode).toBe(200);
expect(response.rawPayload.equals(VALID_PDF)).toBe(true);
expect(response.headers["content-type"]).toContain("application/octet-stream");
expect(response.headers["content-disposition"]).toContain("attachment;");
expect(response.headers["content-disposition"]).toContain("filename*=");
expect(response.headers["content-disposition"]).not.toContain("inline");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["cache-control"]).toBe("private, no-store, max-age=0");
expect(response.headers.pragma).toBe("no-cache");
expect(response.headers["referrer-policy"]).toBe("no-referrer");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.headers["cross-origin-resource-policy"]).toBe("same-origin");
expect(downloadAuditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PURCHASE_ATTACHMENT_DOWNLOADED",
extra: expect.objectContaining({ reason: "审批复核" }),
}),
}),
);
} finally {
await app.close();
}
});
it("fails closed before setting attachment headers when success auditing fails", async () => {
const auditCreate = vi.fn().mockRejectedValue(new Error("database unavailable"));
const { app } = await buildApp({ auditCreate });
try {
const response = await app.inject({
method: "POST",
url: downloadUrl,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
expect(response.statusCode).toBe(500);
expect(response.headers["content-disposition"]).toBeUndefined();
expect(response.json().error.details).toEqual({ attachment: "audit_failed" });
} finally {
await app.close();
}
});
it("retires both legacy signed and query-token routes", async () => {
const { app, storage } = await buildApp();
try {
const signedResponse = await app.inject({
method: "POST",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/signed-url`,
headers: { authorization: "Bearer unit-test" },
payload: {},
});
const fileResponse = await app.inject({
method: "GET",
url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/attachments/${ATTACHMENT_ID}/file?token=legacy`,
});
expect(signedResponse.statusCode).toBe(404);
expect(fileResponse.statusCode).toBe(404);
expect(storage.openReadStream).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
});
@@ -0,0 +1,545 @@
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''");
});
});