Пошук уроків, статей та іншого контенту
Захистите Server Actions від неавторизованих викликів і перевірятимете права перед зміною даних.
Автентифікація відповідає на запитання: «Хто виконує дію?».
Авторизація відповідає на запитання: «Чи має ця особа право виконувати дію?».
Server Action потрібно захищати на обох рівнях. Перевірка лише в інтерфейсі недостатня, оскільки Server Action можна викликати безпосередньо HTTP-запитом, минаючи форму або кнопку.
"use server";
export async function deleteProject(projectId: string) {
// Перевірка користувача
const session = await getSession();
if (!session?.user) {
throw new Error("Потрібна автентифікація");
}
// Перевірка права доступу
const project = await getProject(projectId);
if (project.ownerId !== session.user.id) {
throw new Error("Недостатньо прав");
}
await removeProject(projectId);
}Перевірки мають виконуватися безпосередньо всередині кожної Server Action, яка змінює або повертає захищені дані.
Спосіб отримання поточного користувача залежить від системи автентифікації. Наприклад, у застосунку з Auth.js функція auth() може повертати поточну сесію на сервері.
Приклад Server Action для перейменування проєкту:
"use server";
import { z } from "zod";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
const renameProjectSchema = z.object({
projectId: z.string().min(1),
name: z
.string()
.trim()
.min(3, "Назва має містити щонайменше 3 символи")
.max(100, "Назва не може містити понад 100 символів"),
});
type RenameProjectResult =
| { ok: true }
| { ok: false; error: string };
export async function renameProject(
formData: FormData,
): Promise<RenameProjectResult> {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return {
ok: false,
error: "Увійдіть у свій обліковий запис",
};
}
const parsedData = renameProjectSchema.safeParse({
projectId: formData.get("projectId"),
name: formData.get("name"),
});
if (!parsedData.success) {
return {
ok: false,
error: parsedData.error.issues[0]?.message ?? "Некоректні дані",
};
}
const { projectId, name } = parsedData.data;
// Пошук лише серед проєктів поточного користувача
const project = await prisma.project.findFirst({
where: {
id: projectId,
ownerId: userId,
},
select: {
id: true,
},
});
if (!project) {
return {
ok: false,
error: "Проєкт не знайдено або у вас немає доступу",
};
}
await prisma.project.update({
where: {
id: project.id,
},
data: {
name,
},
});
return { ok: true };
}У цьому прикладі є кілька важливих рівнів захисту:
auth() визначає поточного користувача.
Ідентифікатор користувача береться із сесії, а не з FormData.
Вхідні дані проходять валідацію.
Проєкт шукається одночасно за id і ownerId.
Оновлення виконується лише після успішної перевірки доступу.
Усі значення, які надходять до Server Action, контролює клієнт. Це стосується:
FormData;
аргументів функції;
ідентифікаторів ресурсів;
ролей;
ідентифікаторів користувачів;
прихованих полів форми.
Наприклад, так робити небезпечно:
"use server";
import { prisma } from "@/lib/prisma";
export async function updateUserRole(
userId: string,
role: string,
) {
await prisma.user.update({
where: { id: userId },
data: { role },
});
}Користувач може передати будь-який userId і встановити будь-яку роль. Сам факт, що функція оголошена як Server Action, не означає, що вона автоматично перевіряє права.
Ідентифікатор користувача потрібно отримувати із серверної сесії, а роль — перевіряти перед виконанням операції:
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
const allowedRoles = new Set(["admin", "manager"]);
export async function updateUserRole(
targetUserId: string,
role: string,
) {
const session = await auth();
const currentUser = session?.user;
if (!currentUser?.id) {
throw new Error("Потрібна автентифікація");
}
if (!currentUser.role || !allowedRoles.has(currentUser.role)) {
throw new Error("Недостатньо прав");
}
if (!["user", "manager"].includes(role)) {
throw new Error("Некоректна роль");
}
await prisma.user.update({
where: {
id: targetUserId,
},
data: {
role,
},
});
}Навіть якщо роль користувача зберігається в сесії, сервер має коректно створювати та підписувати цю сесію. Не приймайте роль із форми або з параметрів URL як доказ прав.
Найпоширеніший сценарій — користувач може змінювати лише власні ресурси.
Ненадійний варіант:
const project = await prisma.project.findUnique({
where: { id: projectId },
});
if (project) {
await prisma.project.update({
where: { id: projectId },
data: { name },
});
}Цей код перевіряє лише існування проєкту. Будь-який автентифікований користувач, знаючи projectId, зможе змінити чужий проєкт.
Надійніший варіант — додати власника до умови пошуку:
const project = await prisma.project.findFirst({
where: {
id: projectId,
ownerId: currentUserId,
},
});
if (!project) {
throw new Error("Проєкт не знайдено або доступ заборонено");
}
await prisma.project.update({
where: {
id: project.id,
},
data: {
name: newName,
},
});Так перевірка належності ресурсу є частиною запиту до бази даних. Навіть якщо користувач передасть ідентифікатор чужого проєкту, запис не буде знайдено.
Для видалення використовується та сама ідея:
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function deleteProject(projectId: string) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
throw new Error("Потрібна автентифікація");
}
const deletedProject = await prisma.project.deleteMany({
where: {
id: projectId,
ownerId: userId,
},
});
if (deletedProject.count === 0) {
throw new Error("Проєкт не знайдено або доступ заборонено");
}
return { ok: true };
}deleteMany з умовою id та ownerId дозволяє виконати перевірку і видалення як одну операцію. У результаті count показує, чи був видалений ресурс.
Для простих систем достатньо ролей:
user;
manager;
admin.
Server Action має перевіряти роль перед операцією:
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function approveInvoice(invoiceId: string) {
const session = await auth();
const user = session?.user;
if (!user?.id) {
throw new Error("Потрібна автентифікація");
}
if (user.role !== "admin" && user.role !== "manager") {
throw new Error("Лише менеджер або адміністратор може погодити рахунок");
}
const invoice = await prisma.invoice.findUnique({
where: {
id: invoiceId,
},
select: {
id: true,
status: true,
},
});
if (!invoice) {
throw new Error("Рахунок не знайдено");
}
if (invoice.status !== "pending") {
throw new Error("Можна погодити лише рахунок зі статусом pending");
}
await prisma.invoice.update({
where: {
id: invoice.id,
},
data: {
status: "approved",
approvedById: user.id,
},
});
return { ok: true };
}Перевірка ролі має відбуватися до зміни даних. Додатково потрібно перевіряти стан ресурсу: наприклад, не дозволяти повторно погоджувати вже погоджений рахунок.
Якщо однакові перевірки повторюються в багатьох actions, їх можна винести в серверний модуль:
// lib/authorization.ts
import { auth } from "@/auth";
export async function requireUser() {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Потрібна автентифікація");
}
return session.user;
}
export async function requireAdmin() {
const user = await requireUser();
if (user.role !== "admin") {
throw new Error("Потрібні права адміністратора");
}
return user;
}Після цього Server Action залишається коротшою:
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/authorization";
export async function blockUser(userId: string) {
const admin = await requireAdmin();
await prisma.user.update({
where: {
id: userId,
},
data: {
blocked: true,
blockedById: admin.id,
},
});
return { ok: true };
}Це зменшує дублювання, але не скасовує потребу викликати перевірку в кожній захищеній Server Action.
Не варто показувати користувачу внутрішні деталі бази даних, SQL-запити або stack trace. Для інтерфейсу краще повертати загальне повідомлення:
return {
ok: false,
error: "Не вдалося виконати операцію",
};Водночас причина може бути записана в серверний журнал:
try {
await prisma.project.update({
where: { id: projectId },
data: { name },
});
} catch (error) {
console.error("Помилка оновлення проєкту", error);
return {
ok: false,
error: "Не вдалося оновити проєкт",
};
}Повідомлення про відсутність ресурсу та відсутність доступу іноді навмисно об’єднують:
«Проєкт не знайдено або у вас немає доступу».
Так користувач не отримує зайвої інформації про існування чужих ресурсів.
Приховування кнопки для неавторизованого користувача не захищає Server Action. Користувач все одно може викликати action безпосередньо.
Правильно: перевіряти права в самій Server Action.
userId із форми<input type="hidden" name="userId" value={userId} />Таке поле можна змінити перед відправленням. Ідентифікатор поточного користувача потрібно брати із серверної сесії.
id ресурсуПеревірка projectId без ownerId дозволяє працювати з чужими ресурсами. Умова доступу має бути частиною запиту до бази даних.
Роль у прихованому полі або аргументі функції не є доказом прав. Роль повинна походити з надійного серверного джерела.
Автентифікований користувач також може надіслати некоректні або шкідливі дані. Перевіряйте всі вхідні значення перед зверненням до бази даних.
Захист сторінки або одного action не захищає інші actions. Кожна Server Action, що працює із захищеними даними, повинна виконувати власні перевірки.
Для кожної Server Action, яка читає або змінює дані:
Отримайте поточну сесію на сервері.
Переконайтеся, що користувач автентифікований.
Візьміть userId і роль із сесії або іншого серверного джерела.
Провалідуйте всі аргументи та поля форми.
Перевірте власника ресурсу або необхідний дозвіл.
Перевірте поточний стан ресурсу, якщо операція залежить від нього.
Лише після цього виконуйте запит на зміну даних.
Повертайте клієнту безпечне повідомлення про результат.
Server Actions є серверною точкою входу, тому їх не можна вважати захищеними автоматично.
Автентифікація визначає користувача, а авторизація — його права.
Перевірки потрібно виконувати всередині кожної захищеної Server Action.
Не довіряйте userId, ролям та іншим дозволам, отриманим від клієнта.
Для ресурсів користувача включайте ownerId до умови запиту.
Валідуйте всі вхідні дані перед зміною бази даних.
Не розкривайте клієнту внутрішні помилки та зайві відомості про ресурси.