Fix username case-collision vulnerability across write and read paths
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user