Пошук уроків, статей та іншого контенту
Захистите Server Actions за допомогою автентифікації, авторизації, перевірки власника та безпечної обробки вводу.
Server Action не є приватною функцією лише тому, що вона виконується на сервері. Після компіляції Next.js створює для неї мережеву точку входу. Її можна викликати:
із компонента з директивою use server;
через HTML-форму;
із клієнтського коду;
безпосередньо сформованим HTTP-запитом.
Тому Server Action потрібно захищати так само, як звичайний API endpoint:
перевіряти автентифікацію;
перевіряти права доступу;
перевіряти власника ресурсу;
валідовувати всі вхідні дані;
не довіряти значенням із клієнта;
не повертати внутрішні помилки та чутливі дані.
Перевірка доступу в layout або на сторінці не захищає саму Server Action. Користувач може викликати дію напряму, не відкриваючи сторінку.
Перевірку потрібно виконувати на початку кожної дії:
"use server";
import { auth } from "@/auth";
export async function createProject(input: unknown) {
const session = await auth();
if (!session?.user?.id) {
return {
ok: false,
error: "Потрібно увійти до системи",
};
}
// Подальша логіка виконується лише для автентифікованого користувача
}У цьому прикладі auth — функція автентифікації вашого застосунку. Наприклад, у проєкті з Auth.js вона може бути експортована з @/auth.
Важливо, щоб ідентифікатор користувача надходив із перевіреної серверної сесії, а не з аргументів Server Action:
// Небезпечно
export async function deleteProject(projectId: string, userId: string) {
// userId контролюється клієнтом
}
// Безпечніше
export async function deleteProject(projectId: string) {
const session = await auth();
const userId = session?.user?.id;
// userId отримано із серверної сесії
}Клієнт може змінити будь-яке поле форми або аргумент функції. Значення userId, role, isAdmin чи ownerId з клієнта не можна використовувати для прийняття рішень без додаткової перевірки.
Автентифікація відповідає на питання: «Хто це?».
Авторизація відповідає на питання: «Що цій людині дозволено робити?».
Наприклад, автентифікований користувач не обов’язково має право редагувати будь-який проєкт. Потрібно перевірити, що проєкт належить саме цьому користувачу або що користувач має відповідну роль.
Небезпечний варіант:
const project = await prisma.project.findUnique({
where: { id: projectId },
});
if (project) {
await prisma.project.update({
where: { id: projectId },
data: { name },
});
}Тут перевірки власника немає. Знаючи ідентифікатор чужого проєкту, користувач може змінити його.
Безпечніший підхід — включити ownerId до умови запиту:
const result = await prisma.project.updateMany({
where: {
id: projectId,
ownerId: userId,
},
data: {
name,
},
});
if (result.count === 0) {
// Проєкт не існує або не належить користувачу
}Така умова перевіряється безпосередньо під час операції зміни даних. Не варто покладатися лише на попередній запит findUnique, оскільки між перевіркою та зміною стан може змінитися.
Дані з форми або клієнтського коду завжди потрібно вважати ненадійними:
перевіряйте типи;
перевіряйте обов’язкові поля;
обмежуйте довжину рядків;
нормалізуйте значення;
перевіряйте допустимі значення enum;
не передавайте в базу даних весь об’єкт, отриманий від клієнта.
Для простих даних можна виконати явну перевірку без додаткової бібліотеки:
function parseProjectInput(input: unknown) {
if (typeof input !== "object" || input === null) {
return { ok: false as const };
}
const value = input as Record<string, unknown>;
if (typeof value.projectId !== "string") {
return { ok: false as const };
}
if (typeof value.name !== "string") {
return { ok: false as const };
}
const projectId = value.projectId.trim();
const name = value.name.trim();
if (projectId.length === 0 || projectId.length > 100) {
return { ok: false as const };
}
if (name.length < 2 || name.length > 80) {
return { ok: false as const };
}
return {
ok: true as const,
data: {
projectId,
name,
},
};
}Перевірка типу TypeScript недостатня для захисту. TypeScript перевіряє код під час розробки, але не перевіряє фактичне значення, яке надійшло в HTTP-запиті під час виконання.
Нижче наведено приклад для Next.js App Router із Prisma та Auth.js. Він передбачає, що:
auth() повертає серверну сесію;
у сесії є user.id;
модель Project має поля id, ownerId і name;
prisma — налаштований Prisma Client.
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
type RenameProjectResult =
| { ok: true }
| { ok: false; error: string };
function parseRenameInput(input: unknown) {
if (typeof input !== "object" || input === null) {
return { ok: false as const };
}
const value = input as Record<string, unknown>;
if (typeof value.projectId !== "string") {
return { ok: false as const };
}
if (typeof value.name !== "string") {
return { ok: false as const };
}
const projectId = value.projectId.trim();
const name = value.name.trim();
if (projectId.length === 0 || projectId.length > 100) {
return { ok: false as const };
}
if (name.length < 2 || name.length > 80) {
return { ok: false as const };
}
return {
ok: true as const,
data: {
projectId,
name,
},
};
}
export async function renameProject(
input: unknown,
): Promise<RenameProjectResult> {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return {
ok: false,
error: "Потрібно увійти до системи",
};
}
const parsed = parseRenameInput(input);
if (!parsed.ok) {
return {
ok: false,
error: "Некоректні дані",
};
}
const result = await prisma.project.updateMany({
where: {
id: parsed.data.projectId,
ownerId: userId,
},
data: {
name: parsed.data.name,
},
});
if (result.count !== 1) {
// Не розкриваємо, чи існує проєкт і кому він належить
return {
ok: false,
error: "Проєкт не знайдено",
};
}
revalidatePath("/projects");
return { ok: true };
}У цьому прикладі захищено всі основні межі:
сесія перевіряється на сервері;
userId не приймається від клієнта;
вхідний аргумент перевіряється під час виконання;
довжина назви обмежена;
оновлення можливе лише за одночасного збігу id і ownerId;
внутрішня помилка бази даних не повертається клієнту;
після успішної зміни інвалідовується кеш списку проєктів.
Перевірка власника потрібна не в усіх моделях доступу. Іноді дію можуть виконувати адміністратори або редактори.
У такому разі правило доступу потрібно описати явно:
const session = await auth();
const user = session?.user;
if (!user?.id) {
return {
ok: false,
error: "Потрібно увійти до системи",
};
}
const canEdit =
user.role === "admin" ||
user.role === "editor";
if (!canEdit) {
return {
ok: false,
error: "Недостатньо прав",
};
}Але значення role також має надходити з перевіреної сесії або серверної бази даних. Не можна використовувати роль, надіслану у формі:
// Небезпечно: роль контролюється клієнтом
export async function updateProject(input: {
projectId: string;
role: string;
}) {
if (input.role === "admin") {
// Несанкціонований доступ
}
}Для складніших правил доступу зручно винести політику в окрему серверну функцію:
function canEditProject(params: {
userId: string;
role: string | undefined;
ownerId: string;
}) {
return (
params.role === "admin" ||
params.ownerId === params.userId
);
}У самій Server Action все одно потрібно отримати ресурс із бази даних і перевірити політику на сервері.
Клієнту не слід повертати:
текст SQL-помилки;
stack trace;
назви таблиць і колонок;
внутрішні ідентифікатори;
дані про те, чи існує чужий ресурс;
секрети конфігурації.
Замість цього повертайте стабільні повідомлення, придатні для інтерфейсу:
return {
ok: false,
error: "Не вдалося зберегти зміни",
};Деталі помилки потрібно записувати на сервері через логер, але не включати до відповіді. Для помилок доступу корисно використовувати однакове повідомлення для випадків «ресурс не існує» та «ресурс належить іншому користувачу». Це зменшує можливість визначити існування чужих ресурсів.
Якщо дія змінює лише назву, вона повинна приймати лише ідентифікатор і назву:
type RenameInput = {
projectId: string;
name: string;
};Не варто передавати в базу даних весь об’єкт:
// Небезпечно: клієнт може додати ownerId, role або інші поля
await prisma.project.update({
where: { id: input.projectId },
data: input,
});Краще явно сформувати об’єкт оновлення:
await prisma.project.update({
where: { id: projectId },
data: {
name,
},
});Це захищає від масового присвоєння, коли клієнт намагається змінити поля, які не повинна змінювати ця операція.
Server Actions часто викликаються через HTML-форми. Автентифікація на основі cookie означає, що браузер автоматично додає cookie до запиту, тому захист дії не можна обмежувати лише перевіркою наявності cookie.
Потрібно:
використовувати актуальну версію Next.js;
не вимикати вбудовані перевірки походження запитів без необхідності;
коректно налаштовувати дозволені origins за наявності reverse proxy або окремих доменів;
усе одно виконувати автентифікацію та авторизацію всередині дії.
Перевірка походження запиту не замінює перевірку прав. Навіть коректний запит із вашого домену може бути виконаний автентифікованим користувачем, який не має доступу до конкретного ресурсу.
Перевірка в інтерфейсі потрібна для зручності користувача:
if (name.length < 2) {
setError("Назва надто коротка");
}Але це не є захистом. Користувач може обійти клієнтську перевірку, вимкнувши JavaScript або відправивши власний запит.
Тому:
клієнтська перевірка покращує UX;
серверна перевірка забезпечує безпеку;
правила на сервері повинні бути повними та незалежними від UI.
// Сторінка захищена, але дія може бути викликана напряму
export async function updateSettings(input: unknown) {
// Тут також потрібна перевірка сесії
}Захист сторінки не поширюється автоматично на Server Action.
userId із форми// Користувач може підмінити userId
export async function updateProfile(userId: string, name: string) {
// Помилка авторизації
}Ідентифікатор користувача потрібно отримувати із серверної сесії.
const project = await prisma.project.findUnique({
where: { id: projectId },
});
if (project?.ownerId === userId) {
await prisma.project.update({
where: { id: projectId },
data: { name },
});
}Умова власника має бути частиною запиту зміни, а не лише попередньої перевірки.
// name може бути числом, об'єктом або дуже довгим рядком
await prisma.project.update({
where: { id: projectId },
data: { name: input.name },
});Сервер повинен перевіряти значення незалежно від типів TypeScript.
try {
// операція з базою даних
} catch (error) {
return {
ok: false,
error: String(error),
};
}Так можна розкрити структуру бази даних або інші внутрішні деталі. Користувачеві слід повертати загальне повідомлення, а деталі записувати в серверний лог.
Безпечна Server Action повинна:
перевіряти сесію на сервері під час кожного виклику;
отримувати userId із перевіреної сесії;
явно перевіряти ролі та права;
обмежувати доступ до ресурсу через ownerId або інше правило авторизації;
валідовувати unknown-ввід під час виконання;
явно вибирати поля для оновлення;
не повертати внутрішні помилки;
не покладатися на перевірки в UI;
не вважати захист сторінки або перевірку походження заміною авторизації.