Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extensionForMime,
|
||||
isAllowedMime,
|
||||
isValidUploadFileId,
|
||||
maxBytesForPurpose,
|
||||
} from "../../lib/server/uploads/uploadConstants";
|
||||
@@ -12,18 +11,6 @@ describe("createFlow upload constants", () => {
|
||||
expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("isAllowedMime allows images for both purposes", () => {
|
||||
expect(isAllowedMime("communityAvatar", "image/png")).toBe(true);
|
||||
expect(isAllowedMime("customMethodAttachment", "image/jpeg")).toBe(true);
|
||||
});
|
||||
|
||||
it("isAllowedMime allows pdf only for customMethodAttachment", () => {
|
||||
expect(isAllowedMime("communityAvatar", "application/pdf")).toBe(false);
|
||||
expect(isAllowedMime("customMethodAttachment", "application/pdf")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("extensionForMime maps common types", () => {
|
||||
expect(extensionForMime("image/png")).toBe(".png");
|
||||
expect(extensionForMime("image/jpeg")).toBe(".jpg");
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CreateFlowUploadValidationError,
|
||||
maxBytesForPurpose,
|
||||
validateCreateFlowUploadBytes,
|
||||
validateCreateFlowUploadFile,
|
||||
} from "../../lib/create/createFlowUploadValidation";
|
||||
import { uploadCreateFlowFile } from "../../lib/create/uploadToServer";
|
||||
|
||||
/** 1×1 PNG (valid IHDR). */
|
||||
const PNG_1X1 = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
|
||||
/** SOF0 1×1 JPEG (header-decodable; not necessarily a complete scan). */
|
||||
const JPEG_1X1_SOF = Uint8Array.from([
|
||||
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
|
||||
0x11, 0x00, 0xff, 0xd9,
|
||||
]);
|
||||
|
||||
const GIF_1X1 = Uint8Array.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x3b,
|
||||
]);
|
||||
|
||||
/** VP8X 1×1 WebP. */
|
||||
const WEBP_1X1 = Uint8Array.from([
|
||||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56,
|
||||
0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
const PDF_STUB = new TextEncoder().encode("%PDF-1.4\n%%EOF\n");
|
||||
|
||||
const SVG_MARKUP = new TextEncoder().encode(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
|
||||
);
|
||||
|
||||
const XML_SVG = new TextEncoder().encode(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
);
|
||||
|
||||
describe("validateCreateFlowUploadBytes", () => {
|
||||
it("accepts a real PNG as a community avatar", () => {
|
||||
const result = validateCreateFlowUploadBytes("communityAvatar", PNG_1X1);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
kind: "png",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts JPEG, GIF, and WebP headers", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", JPEG_1X1_SOF).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", GIF_1X1).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", WEBP_1X1).ok).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty buffer", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", new Uint8Array())).toEqual(
|
||||
{ ok: false, reason: "empty" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a file larger than the purpose cap", () => {
|
||||
const oversized = new Uint8Array(maxBytesForPurpose("communityAvatar") + 1);
|
||||
oversized.set(PNG_1X1.subarray(0, 8), 0);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", oversized)).toEqual({
|
||||
ok: false,
|
||||
reason: "tooLarge",
|
||||
});
|
||||
expect(maxBytesForPurpose("customMethodAttachment")).toBeGreaterThan(
|
||||
maxBytesForPurpose("communityAvatar"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects text spoofed as PNG", () => {
|
||||
expect(
|
||||
validateCreateFlowUploadBytes(
|
||||
"communityAvatar",
|
||||
new TextEncoder().encode("hello world"),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "invalidType" });
|
||||
});
|
||||
|
||||
it("rejects SVG markup, including xml-prefixed SVG", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", SVG_MARKUP)).toEqual(
|
||||
{ ok: false, reason: "svg" },
|
||||
);
|
||||
expect(validateCreateFlowUploadBytes("customMethodAttachment", XML_SVG)).toEqual(
|
||||
{ ok: false, reason: "svg" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a PNG signature that cannot be decoded", () => {
|
||||
const truncated = PNG_1X1.subarray(0, 8);
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", truncated)).toEqual({
|
||||
ok: false,
|
||||
reason: "undecodable",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects PDF for community avatars and allows it for attachments", () => {
|
||||
expect(validateCreateFlowUploadBytes("communityAvatar", PDF_STUB)).toEqual({
|
||||
ok: false,
|
||||
reason: "invalidType",
|
||||
});
|
||||
expect(validateCreateFlowUploadBytes("customMethodAttachment", PDF_STUB)).toEqual(
|
||||
{
|
||||
ok: true,
|
||||
kind: "pdf",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCreateFlowUploadFile", () => {
|
||||
it("rejects empty files before reading bytes", async () => {
|
||||
const file = new File([], "empty.png", { type: "image/png" });
|
||||
await expect(
|
||||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||||
).resolves.toEqual({ ok: false, reason: "empty" });
|
||||
});
|
||||
|
||||
it("rejects SVG by name even when the MIME is spoofed", async () => {
|
||||
const file = new File([PNG_1X1], "logo.svg", { type: "image/png" });
|
||||
await expect(
|
||||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||||
).resolves.toEqual({ ok: false, reason: "svg" });
|
||||
});
|
||||
|
||||
it("rejects a 17MB file by size without treating it as a type error", async () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "huge.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
Object.defineProperty(file, "size", { value: 17 * 1024 * 1024 });
|
||||
await expect(
|
||||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||||
).resolves.toEqual({ ok: false, reason: "tooLarge" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadCreateFlowFile", () => {
|
||||
it("does not POST when client validation fails", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const file = new File(["not an image"], "photo.png", { type: "image/png" });
|
||||
await expect(
|
||||
uploadCreateFlowFile(file, "communityAvatar"),
|
||||
).rejects.toBeInstanceOf(CreateFlowUploadValidationError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { mkdtemp, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getUploadRootFromEnvMock = vi.fn();
|
||||
let uploadRoot: string | null = null;
|
||||
|
||||
vi.mock("../../lib/server/uploads/uploadRoot", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../lib/server/uploads/uploadRoot")
|
||||
>("../../lib/server/uploads/uploadRoot");
|
||||
return {
|
||||
...actual,
|
||||
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
|
||||
};
|
||||
});
|
||||
|
||||
import { saveCreateFlowUpload } from "../../lib/server/uploads/saveCreateFlowUpload";
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
describe("saveCreateFlowUpload", () => {
|
||||
beforeEach(async () => {
|
||||
uploadRoot = await mkdtemp(path.join(tmpdir(), "cr-upload-save-"));
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
getUploadRootFromEnvMock.mockImplementation(() => uploadRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uploadRoot = null;
|
||||
});
|
||||
|
||||
it("writes a sniffed PNG even when the client declared the wrong MIME", async () => {
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: "communityAvatar",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
expect("error" in saved).toBe(false);
|
||||
if ("error" in saved) return;
|
||||
expect(saved.mimeType).toBe("image/png");
|
||||
const files = await readdir(uploadRoot!);
|
||||
expect(files).toEqual([`${saved.id}.png`]);
|
||||
});
|
||||
|
||||
it("rejects spoofed bytes without writing", async () => {
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: "communityAvatar",
|
||||
buffer: Buffer.from("not an image"),
|
||||
});
|
||||
expect(saved).toEqual({ error: "validation", reason: "invalidType" });
|
||||
expect(await readdir(uploadRoot!)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ function multipartRequest(opts: {
|
||||
purpose?: string;
|
||||
fileName?: string;
|
||||
fileContent?: string;
|
||||
contentType?: string;
|
||||
}): NextRequest {
|
||||
const boundary = "----VitestBoundary";
|
||||
const parts: string[] = [];
|
||||
@@ -36,8 +37,9 @@ function multipartRequest(opts: {
|
||||
);
|
||||
}
|
||||
if (opts.fileName && opts.fileContent !== undefined) {
|
||||
const contentType = opts.contentType ?? "image/png";
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: image/png\r\n\r\n${opts.fileContent}\r\n`,
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: ${contentType}\r\n\r\n${opts.fileContent}\r\n`,
|
||||
);
|
||||
}
|
||||
parts.push(`--${boundary}--\r\n`);
|
||||
@@ -56,6 +58,7 @@ beforeEach(() => {
|
||||
getUploadRootFromEnvMock.mockReset();
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads");
|
||||
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
|
||||
});
|
||||
|
||||
describe("POST /api/uploads", () => {
|
||||
@@ -78,7 +81,6 @@ describe("POST /api/uploads", () => {
|
||||
});
|
||||
|
||||
it("returns 500 when UPLOAD_ROOT is unset", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
getUploadRootFromEnvMock.mockReturnValueOnce(null);
|
||||
const res = await POST(
|
||||
new NextRequest("https://x.test/api/uploads", { method: "POST" }),
|
||||
@@ -90,7 +92,6 @@ describe("POST /api/uploads", () => {
|
||||
});
|
||||
|
||||
it("returns 400 when purpose is missing", async () => {
|
||||
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
|
||||
const res = await POST(
|
||||
multipartRequest({ fileName: "avatar.png", fileContent: "x" }),
|
||||
undefined,
|
||||
@@ -99,4 +100,56 @@ describe("POST /api/uploads", () => {
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("returns 400 for an empty file", async () => {
|
||||
const res = await POST(
|
||||
multipartRequest({
|
||||
purpose: "communityAvatar",
|
||||
fileName: "empty.png",
|
||||
fileContent: "",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
error: { code: string };
|
||||
details?: { reason?: string };
|
||||
};
|
||||
expect(body.error.code).toBe("validation_error");
|
||||
expect(body.details?.reason).toBe("empty");
|
||||
});
|
||||
|
||||
it("returns 400 for SVG even when named .png", async () => {
|
||||
const res = await POST(
|
||||
multipartRequest({
|
||||
purpose: "communityAvatar",
|
||||
fileName: "photo.png",
|
||||
fileContent: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||
contentType: "image/png",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
details?: { reason?: string };
|
||||
};
|
||||
expect(body.details?.reason).toBe("svg");
|
||||
});
|
||||
|
||||
it("returns 400 for a text file spoofed as PNG", async () => {
|
||||
const res = await POST(
|
||||
multipartRequest({
|
||||
purpose: "communityAvatar",
|
||||
fileName: "photo.png",
|
||||
fileContent: "not an image",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
details?: { reason?: string };
|
||||
};
|
||||
expect(body.details?.reason).toBe("invalidType");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user