Fix username case-collision vulnerability across write and read paths

This commit is contained in:
Mehmet Ince
2026-03-24 16:00:26 +00:00
parent 0bd2b10706
commit 1464475df2
6 changed files with 33 additions and 9 deletions
+25 -4
View File
@@ -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",
+1
View File
@@ -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,
+2
View File
@@ -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,
+1 -1
View File
@@ -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 },
],
},
+2 -2
View File
@@ -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 },
});
+2 -2
View File
@@ -26,7 +26,7 @@ async function generateUsername(email: string, name?: string | null): Promise<st
// Check if username exists and append number if needed
let username = baseUsername;
let counter = 1;
while (await db.user.findUnique({ where: { username } })) {
while (await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } } })) {
username = `${baseUsername}${counter}`;
counter++;
}
@@ -81,7 +81,7 @@ function CustomPrismaAdapter(): Adapter {
const baseUsername = username;
let finalUsername = baseUsername;
let counter = 1;
while (await db.user.findUnique({ where: { username: finalUsername } })) {
while (await db.user.findFirst({ where: { username: { equals: finalUsername, mode: "insensitive" } } })) {
finalUsername = `${baseUsername}${counter}`;
counter++;
}