From 1464475df2698fb7ccd0cdbc382b0750466f891d Mon Sep 17 00:00:00 2001 From: Mehmet Ince Date: Tue, 24 Mar 2026 16:00:26 +0000 Subject: [PATCH] Fix username case-collision vulnerability across write and read paths --- src/__tests__/api/user-profile.test.ts | 29 +++++++++++++++++++---- src/app/[username]/opengraph-image.tsx | 1 + src/app/[username]/page.tsx | 2 ++ src/app/api/admin/import-prompts/route.ts | 2 +- src/app/api/user/profile/route.ts | 4 ++-- src/lib/auth/index.ts | 4 ++-- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/__tests__/api/user-profile.test.ts b/src/__tests__/api/user-profile.test.ts index ec186338..87ca0fd1 100644 --- a/src/__tests__/api/user-profile.test.ts +++ b/src/__tests__/api/user-profile.test.ts @@ -8,6 +8,7 @@ vi.mock("@/lib/db", () => ({ db: { user: { findUnique: vi.fn(), + findFirst: vi.fn(), update: vi.fn(), }, }, @@ -162,7 +163,7 @@ describe("PATCH /api/user/profile", () => { it("should return 400 if username is taken", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never); - vi.mocked(db.user.findUnique).mockResolvedValue({ id: "other-user" } as never); + vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never); const request = new Request("http://localhost:3000/api/user/profile", { method: "PATCH", @@ -176,6 +177,26 @@ describe("PATCH /api/user/profile", () => { expect(data.error).toBe("username_taken"); }); + it("should check username case-insensitively", async () => { + vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never); + vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never); + + const request = new Request("http://localhost:3000/api/user/profile", { + method: "PATCH", + body: JSON.stringify({ name: "Test", username: "TakenUser" }), + }); + + const response = await PATCH(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("username_taken"); + expect(db.user.findFirst).toHaveBeenCalledWith({ + where: { username: { equals: "TakenUser", mode: "insensitive" } }, + select: { id: true }, + }); + }); + it("should allow keeping the same username", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "sameuser" } } as never); vi.mocked(db.user.update).mockResolvedValue({ @@ -197,12 +218,12 @@ describe("PATCH /api/user/profile", () => { expect(response.status).toBe(200); expect(data.name).toBe("Updated Name"); // Should NOT check for existing username when keeping the same one - expect(db.user.findUnique).not.toHaveBeenCalled(); + expect(db.user.findFirst).not.toHaveBeenCalled(); }); it("should update profile successfully", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never); - vi.mocked(db.user.findUnique).mockResolvedValue(null); // Username not taken + vi.mocked(db.user.findFirst).mockResolvedValue(null); // Username not taken vi.mocked(db.user.update).mockResolvedValue({ id: "user1", name: "New Name", @@ -318,7 +339,7 @@ describe("PATCH /api/user/profile", () => { it("should accept valid username with underscores", async () => { vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "old" } } as never); - vi.mocked(db.user.findUnique).mockResolvedValue(null); + vi.mocked(db.user.findFirst).mockResolvedValue(null); vi.mocked(db.user.update).mockResolvedValue({ id: "user1", name: "Test", diff --git a/src/app/[username]/opengraph-image.tsx b/src/app/[username]/opengraph-image.tsx index 65a1b03e..d3633276 100644 --- a/src/app/[username]/opengraph-image.tsx +++ b/src/app/[username]/opengraph-image.tsx @@ -50,6 +50,7 @@ export default async function OGImage({ params }: { params: Promise<{ username: const user = await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } }, + orderBy: { createdAt: "asc" }, select: { id: true, name: true, diff --git a/src/app/[username]/page.tsx b/src/app/[username]/page.tsx index b343b8e9..03231551 100644 --- a/src/app/[username]/page.tsx +++ b/src/app/[username]/page.tsx @@ -38,6 +38,7 @@ export async function generateMetadata({ params }: UserProfilePageProps): Promis const user = await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } }, + orderBy: { createdAt: "asc" }, select: { name: true, username: true }, }); @@ -72,6 +73,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf const user = await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } }, + orderBy: { createdAt: "asc" }, select: { id: true, name: true, diff --git a/src/app/api/admin/import-prompts/route.ts b/src/app/api/admin/import-prompts/route.ts index 0d5ec5cc..b4c7aed6 100644 --- a/src/app/api/admin/import-prompts/route.ts +++ b/src/app/api/admin/import-prompts/route.ts @@ -150,7 +150,7 @@ export async function POST(request: NextRequest) { let user = await db.user.findFirst({ where: { OR: [ - { username: normalizedUsername }, + { username: { equals: normalizedUsername, mode: "insensitive" } }, { email: pseudoEmail }, ], }, diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 3390dc50..de2fe856 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -46,8 +46,8 @@ export async function PATCH(request: NextRequest) { // Check if username is taken by another user if (username !== session.user.username) { - const existingUser = await db.user.findUnique({ - where: { username }, + const existingUser = await db.user.findFirst({ + where: { username: { equals: username, mode: "insensitive" } }, select: { id: true }, }); diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index d7d8c58f..0c13c970 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -26,7 +26,7 @@ async function generateUsername(email: string, name?: string | null): Promise