feat(tasks): implement strict UUID validation, server-side existence checks, and secure authorization for task updates

This commit is contained in:
Chneemann 2026-08-13 11:45:19 +02:00
parent cf72de43d9
commit 60a40b74b8
No known key found for this signature in database
3 changed files with 106 additions and 77 deletions

View file

@ -5,10 +5,11 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/db"; import { db } from "@/db";
import { usersTable, tasksTable, taskAssigneesTable } from "@/db/schema"; import { usersTable } from "@/db/schema";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import TaskForm from "./TaskForm"; import TaskForm from "./TaskForm";
import { not, eq, isNull, and } from "drizzle-orm"; import { not, eq } from "drizzle-orm";
import { TaskService } from "@/services/task.service";
/** /**
* Properties for the TaskPage component. * Properties for the TaskPage component.
@ -24,64 +25,43 @@ interface TaskPageProps {
} }
/** /**
* Renders the task creation or edit page, verifying user authentication, * Renders the task creation or editing page after checking user session authentication,
* fetching existing task data and assignees if in edit mode, loading available users, * validating edit mode parameters and UUID formats, fetching initial task data and assignable users,
* and passing the context down to the task form component. * and loading the task form component.
* *
* @async * @async
* @param {TaskPageProps} props - The component props. * @param {TaskPageProps} props - The component props containing search parameters.
* @returns {Promise<JSX.Element>} The rendered task page component. * @returns {Promise<JSX.Element>} The rendered task page component.
*/ */
export default async function TaskPage({ searchParams }: TaskPageProps) { export default async function TaskPage({ searchParams }: TaskPageProps) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) redirect("/login");
redirect("/login");
}
const params = await searchParams; const params = await searchParams;
const mode = params.task; const UUID_REGEX =
const taskId = params.id; /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
let initialData = undefined; let initialData = undefined;
if (mode === "edit" && taskId) { if (params.task === "edit" && params.id) {
const [task] = await db if (!UUID_REGEX.test(params.id)) redirect("/dashboard");
.select()
.from(tasksTable)
.where(
and(
eq(tasksTable.id, taskId),
eq(tasksTable.userId, session.user.id),
isNull(tasksTable.deletedAt),
),
);
const assignedRows = await db initialData = await TaskService.getEditableTask(params.id, session.user.id);
.select({ id: taskAssigneesTable.userId })
.from(taskAssigneesTable)
.where(eq(taskAssigneesTable.taskId, taskId));
initialData = { if (!initialData) redirect("/dashboard");
...task,
assignees: assignedRows,
};
} }
const users = await db const users = await db
.select({ .select({ id: usersTable.id, email: usersTable.email })
id: usersTable.id,
email: usersTable.email,
})
.from(usersTable) .from(usersTable)
.where(not(eq(usersTable.id, session.user.id))); .where(not(eq(usersTable.id, session.user.id)));
return ( return (
<div className="mx-auto pb-12"> <div className="mx-auto pb-12">
<TaskForm <TaskForm
key={`${mode}-${taskId ?? "new"}`} key={`${params.task}-${params.id ?? "new"}`}
users={users} users={users}
initialData={initialData} initialData={initialData}
mode={mode} mode={params.task}
/> />
</div> </div>
); );

View file

@ -75,6 +75,12 @@ export async function PATCH(request: Request) {
const { userId, body, error } = await validateTaskRequest(request, true); const { userId, body, error } = await validateTaskRequest(request, true);
if (error) return error; if (error) return error;
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (body.id && !UUID_REGEX.test(body.id)) {
return new NextResponse("Invalid Task ID format", { status: 400 });
}
const existingTask = await TaskService.verifyAccess(body!.id!, userId!); const existingTask = await TaskService.verifyAccess(body!.id!, userId!);
if (!existingTask) { if (!existingTask) {
return new NextResponse( return new NextResponse(

View file

@ -41,22 +41,28 @@ export class TaskService {
} }
/** /**
* Helper: Synchronizes assignees for a given task (replaces existing ones). * Helper: Synchronizes the assigned users for a specified task within a transaction.
* *
* @private * @private
* @async * @async
* @param {any} tx - The Drizzle transaction instance.
* @param {string} taskId - The unique identifier of the task. * @param {string} taskId - The unique identifier of the task.
* @param {string[]} [assignees] - Optional array of user IDs to assign. * @param {string[]} [assignees] - An optional list of user IDs to assign.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private static async syncAssignees(taskId: string, assignees?: string[]) { private static async syncAssignees(
await db tx: any,
taskId: string,
assignees?: string[],
) {
await tx
.delete(taskAssigneesTable) .delete(taskAssigneesTable)
.where(eq(taskAssigneesTable.taskId, taskId)); .where(eq(taskAssigneesTable.taskId, taskId));
if (assignees && assignees.length > 0) { if (assignees && assignees.length > 0) {
const values = assignees.map((userId) => ({ taskId, userId })); const uniqueAssignees = [...new Set(assignees)];
await db.insert(taskAssigneesTable).values(values); const values = uniqueAssignees.map((userId) => ({ taskId, userId }));
await tx.insert(taskAssigneesTable).values(values);
} }
} }
@ -226,66 +232,103 @@ export class TaskService {
} }
/** /**
* Creates a new task and synchronizes its initial assignees. * Creates a new task and synchronizes its assignees within a database transaction.
* *
* @async * @async
* @param {string} userId - The unique identifier of the user creating the task. * @param {string} userId - The unique identifier of the user creating the task.
* @param {TaskPayload} data - The task creation payload containing title, description, priority, status, due date, and optional assignees. * @param {TaskPayload} data - The payload containing task details.
* @returns {Promise<DbTask>} The newly created task record. * @returns {Promise<DbTask>} The newly created task record.
*/ */
static async createTask(userId: string, data: TaskPayload) { static async createTask(userId: string, data: TaskPayload) {
const [newTask] = await db return await db.transaction(async (tx) => {
.insert(tasksTable) const [newTask] = await tx
.values({ .insert(tasksTable)
title: data.title, .values({
description: data.description ?? "", title: data.title,
priority: data.priority, description: data.description ?? "",
status: data.status, priority: data.priority,
dueDate: new Date(data.dueDate), status: data.status,
userId, dueDate: new Date(data.dueDate),
}) userId,
.returning(); })
.returning();
await this.syncAssignees(newTask.id, data.assignees); await this.syncAssignees(tx, newTask.id, data.assignees);
return newTask as DbTask; return newTask as DbTask;
});
} }
/** /**
* Updates an existing task and synchronizes its assignees if the user is authorized. * Updates an existing task and synchronizes its assignees if the user is authorized.
* *
* @async * @async
* @param {string} taskId - The unique identifier of the task. * @param {string} taskId - The unique identifier of the task to update.
* @param {string} userId - The unique identifier of the user. * @param {string} userId - The unique identifier of the user performing the update.
* @param {TaskPayload} data - The update payload containing new task properties. * @param {TaskPayload} data - The payload containing updated task details.
* @returns {Promise<DbTask | null>} The updated task record or null if unauthorized. * @returns {Promise<DbTask | null>} The updated task record or null if unauthorized/not found.
*/ */
static async updateTaskIfAuthorized( static async updateTaskIfAuthorized(
taskId: string, taskId: string,
userId: string, userId: string,
data: TaskPayload, data: TaskPayload,
) { ) {
const [updatedTask] = await db return await db.transaction(async (tx) => {
.update(tasksTable) const [updatedTask] = await tx
.set({ .update(tasksTable)
title: data.title, .set({
description: data.description, title: data.title,
priority: data.priority, description: data.description,
status: data.status, priority: data.priority,
dueDate: new Date(data.dueDate), status: data.status,
updatedAt: new Date(), dueDate: new Date(data.dueDate),
}) updatedAt: new Date(),
})
.where(
and(
eq(tasksTable.id, taskId),
eq(tasksTable.userId, userId),
isNull(tasksTable.deletedAt),
),
)
.returning();
if (!updatedTask) {
tx.rollback();
return null;
}
await this.syncAssignees(tx, taskId, data.assignees);
return updatedTask as DbTask;
});
}
/**
* Retrieves an editable task along with its assignees if the user owns it and it isn't deleted.
*
* @async
* @param {string} taskId - The unique identifier of the task.
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<any | null>} The task record with assignees or null if not found.
*/
static async getEditableTask(taskId: string, userId: string) {
const [task] = await db
.select()
.from(tasksTable)
.where( .where(
and( and(
eq(tasksTable.id, taskId), eq(tasksTable.id, taskId),
eq(tasksTable.userId, userId), eq(tasksTable.userId, userId),
isNull(tasksTable.deletedAt), isNull(tasksTable.deletedAt),
), ),
) );
.returning();
if (!updatedTask) return null; if (!task) return null;
await this.syncAssignees(taskId, data.assignees); const assignees = await db
return updatedTask as DbTask; .select({ id: taskAssigneesTable.userId })
.from(taskAssigneesTable)
.where(eq(taskAssigneesTable.taskId, taskId));
return { ...task, assignees };
} }
} }