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:
adilallo
2026-09-10 15:50:32 -06:00
co-authored by Cursor
parent 6ccc1e8c8e
commit 234f3998ad
18 changed files with 1072 additions and 112 deletions
+136 -2
View File
@@ -1,9 +1,71 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
renderWithProviders as render,
screen,
fireEvent,
cleanup,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile,
} from "../../lib/create/pendingCommunityAvatarUpload";
import { fetchAuthSession } from "../../lib/create/api";
import messages from "../../messages/en/index";
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
storePendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../lib/create/api", () => ({
fetchAuthSession: vi.fn().mockResolvedValue({ user: null }),
}));
const pngBytes = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
),
);
const copy = messages.create.community.communityUpload;
const uploadErrors = messages.create.upload.errors;
function fileInput(): HTMLInputElement {
const input = document.querySelector('input[type="file"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
describe("CommunityUploadScreen", () => {
beforeEach(() => {
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null);
vi.mocked(storePendingCommunityAvatarFile).mockResolvedValue(undefined);
vi.mocked(clearPendingCommunityAvatarFile).mockResolvedValue(undefined);
vi.mocked(fetchAuthSession).mockResolvedValue({ user: null });
Object.defineProperty(URL, "createObjectURL", {
value: vi.fn(() => "blob:pending-avatar"),
writable: true,
configurable: true,
});
Object.defineProperty(URL, "revokeObjectURL", {
value: vi.fn(),
writable: true,
configurable: true,
});
});
afterEach(() => {
cleanup();
Reflect.deleteProperty(URL, "createObjectURL");
Reflect.deleteProperty(URL, "revokeObjectURL");
});
it("renders HeaderLockup", () => {
render(<CommunityUploadScreen />);
expect(
@@ -22,4 +84,76 @@ describe("CommunityUploadScreen", () => {
),
).toBeInTheDocument();
});
it("restores a pending IndexedDB photo and lets the user remove it", async () => {
const user = userEvent.setup();
const pending = new File([pngBytes], "avatar.png", { type: "image/png" });
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending);
render(<CommunityUploadScreen />);
const preview = await screen.findByRole("img", { name: copy.previewAlt });
expect(preview).toHaveAttribute("src", "blob:pending-avatar");
const remove = screen.getByRole("button", {
name: copy.clearPendingUploadAriaLabel,
});
await user.click(remove);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
expect(
screen.queryByRole("img", { name: copy.previewAlt }),
).not.toBeInTheDocument();
});
it("rejects an empty file with a clear error and does not stage it", async () => {
render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: { files: [new File([], "empty.png", { type: "image/png" })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
uploadErrors.empty,
);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
it("rejects a text file renamed as PNG", async () => {
render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: {
files: [new File(["hello"], "photo.png", { type: "image/png" })],
},
});
expect(await screen.findByRole("alert")).toHaveTextContent(
uploadErrors.invalidType,
);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
it("rejects SVG uploads", async () => {
render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: {
files: [
new File(
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
"photo.png",
{ type: "image/png" },
),
],
},
});
expect(await screen.findByRole("alert")).toHaveTextContent(uploadErrors.svg);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
});
@@ -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();
});
});
+57
View File
@@ -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([]);
});
});
+56 -3
View File
@@ -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");
});
});