Своё действие: реестр функций и открытый эндпоинт
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Обе прошлые модели действия начинались строкой одного вида — registry.set("7f3a91", addTask) в первой статье главы и registry.set("60c1a4f2", deleteProject) в пятой: идентификатор вписан руками, а откуда он берётся и кто спрашивает у запроса «кто ты», оставалось за кадром. Соберём эти места сами: регистрацию, которая выдаёт идентификатор, эндпоинт, который по нему зовёт функцию, и транспорт, который приносит личность отдельно от полей. Сети у песочницы нет, поэтому «отправка формы» здесь — вызов функции, которая лезет в реестр, а не POST по проводу.
В статье про Server Actions модель была такой: registry.set("7f3a91", addTask), и про строку было сказано одно — её считает сборка. Посмотрим, откуда она берётся на самом деле: её выдаёт регистрация, и собрать её недолго.
Директиву "use server" легко прочитать как «перенеси код на сервер». Она делает другое: в серверном компоненте код и так на сервере. Директива регистрирует функцию — заводит ей идентификатор, кладёт её в реестр под этим идентификатором, а наружу, в разметку, отдаёт только строку. Смоделируем это функцией registerAction.
Прогноз перед запуском: что вернёт registerAction — саму функцию, обёртку над ней или что-то третье? И сколько функций окажется в реестре?
В разметку уехала строка "a1" — и больше ничего: ни тела deleteProject, ни ссылки на него. Тело осело в реестре на «сервере», а «отправка формы» свелась к двум действиям — найти функцию по идентификатору и позвать её. Никакого POST в этом нет, и в песочнице не будет: запрос мы изображаем прямым вызовом, потому что по сути это он и есть.
В настоящем Next идентификатор длиннее и лежит в скрытом поле $ACTION_ID_… или в заголовке next-action, а роль реестра играет манифест действий, собранный сборщиком. Механика та же: строка снаружи, функция внутри.
Соблазн положить в реестр исходник функции и слать его в браузер разбивается о то, ради чего действие вообще существует. Внутри него — запрос к базе, чтение ключа из окружения, запись файла. Уедь этот код к читателю — уедут и ключи, и строение базы.
Поэтому наружу отдаётся только идентификатор. Он бесполезен без сервера, у которого есть реестр: строка "a1" сама по себе ничего не делает, пока кто-то не сопоставит её с функцией. Это и есть граница «клиент — сервер», записанная одной строкой.
Реестр из первого раздела уехал в подложку примера — вместе с зарегистрированным deleteProject и его идентификатором. Добавим серверную сторону: dispatch находит функцию по идентификатору и зовёт её. Это и есть эндпоинт — точка, куда приходит запрос.
Теперь главное. Кнопку удаления компонент рисует только администратору, а перед нами — обычный посетитель, и isAdmin у него false. Прогноз: если кнопку никто не нарисовал, сработает ли удаление?
Кнопки нет — null, — а проект удалён. Идентификатор лежал в разметке той страницы, где кнопку когда-то показали, и достать его оттуда ничего не стоит; dispatch же спрашивает только одно — есть ли функция под таким идентификатором. Ни про кнопку, ни про форму, ни про isAdmin он не знает вовсе: это разные места и разное время. Условие {isAdmin && …} решает, что увидит человек, а не что примет сервер.
Вывод тот же, что в прошлой статье, только теперь видно, почему он неизбежен: dispatch знает об идентификаторе и функции — и больше ни о чём. Не отрисовать кнопку — защита от случайного нажатия, и только.
Заметьте и то, чего dispatch сейчас не делает совсем: он не спрашивает, кто пришёл и можно ли ему. Любой запрос с верным идентификатором доходит до тела функции. Значит, спросить обязана сама функция — этим и займёмся дальше.
У действия два вопроса: кто пришёл и что просит сделать. Поля fields отвечают на «что», и их пишет отправитель — любые. Ошибка в том, чтобы дать ему ответить и на «кто»: положить рядом userId и сравнить с владельцем.
Соберём канал для «кто» отдельно. Куку к запросу прикладывает браузер, но расшифровывает её сервер — и request достаёт userId из сессии сам, отдавая действию уже готовую личность. В полях места для неё нет. Прогноз: злоумышленник прислал чужую задачу t2 и дописал в поля userId: "u2" под её владельца — пройдёт ли удаление?
Первый вызов — «отказано», хотя поля выглядели согласованными. Всё дело в том, что userId в них никто не читал: request получил его из сессии по куке, а кука посетителя расшифровывается в u1. Дописанное u2 осталось мёртвым грузом — для него в действии просто нет двери. Второй вызов, где посетитель трогает свою же задачу, проходит честно.
Одна мелочь здесь ещё не исправлена: на несуществующую задачу действие отвечает «нет такой задачи», а на чужую — «отказано». По разнице двух ответов перебором видно, какие задачи в хранилище существуют, — даже без права их менять. В разделе «Собираем эндпоинт целиком» оба случая получат один ответ.
В модели «кто» и «что» разведены самим устройством транспорта: у поддельного userId нет пути внутрь, потому что личность приезжает кукой, а сессию за ней заводит сервер. В настоящем Next транспорт личность в действие не подаёт — действие достаёт её само, через cookies() или вашу requireUser(), и то, что оно идёт туда, а не в formData, держится уже на дисциплине. Модель показывает, к чему эта дисциплина сводится: у личности один канал, и отправитель в него не пишет. Стоит завести запасной вход из полей — и дверь появляется.
Привязанные через bind аргументы из статьи про Server Actions — это тоже поля: они уезжают в браузер и возвращаются, их можно переписать. Поэтому в них кладут «что», а не «кто», и действие всё равно проверяет право само.
Осталось разобрать последнюю надежду на разметку: идентификатор ведь не угадаешь. Он и правда непредсказуем — но собрать его модель полезно, чтобы увидеть, где кончается эта защита.
Настоящий идентификатор — не счётчик, как у нас в первом разделе, а хеш от пути к модулю, имени функции и секрета сборки. Секрет свой на каждую сборку, поэтому и хеш меняется. Прогноз: две сборки одного и того же исходника дадут один идентификатор или разные? И можно ли будет прочитать его со страницы?
Сборки дали разные строки — совпали: false, — и попытка собрать идентификатор без секрета кончилась «нет такого действия»: угадать его вслепую и правда нельзя. Но последняя строка вывода закрывает вопрос. Код, который нашёл идентификатор в разметке, не знал ни секрета, ни пути, ни имени — он просто прочитал страницу, и удаление прошло.
Оттого, что секрет меняется на каждой сборке, старые идентификаторы после выката перестают работать: страница загрузилась до деплоя, а запрос ушёл уже на новый сервер — где под этой строкой функции нет. Знакомая по разошедшимся сборкам история, только с другой стороны.
Итог по разметке простой: она хранит идентификатор, а не бережёт его. Непредсказуемость мешает угадать чужой идентификатор наугад — и не мешает вовсе прочитать свой. На защиту это не тянет по определению: то, что доступно каждому читателю страницы, секретом не бывает.
Части собраны — сложим их в один эндпоинт. Порядок шагов не случаен: сначала дешёвые проверки, база — потом, работа — последней. Не представился — незачем разбирать поля; поля не разобрались — незачем ходить в базу.
Реестр и хранилища уехали в подложку. В примере — само действие и request, который кладёт ему личность из сессии. Прогноз: три вызова — хозяин своей задачи, он же на чужую, и он же без куки. Что ответит каждый?
Хозяин переименовал свою задачу; на чужую получил «задача не найдена» — тот же ответ, что и на несуществующую, чтобы по разнице ответов нельзя было перебором вычислить чужой список; без куки дело не дошло даже до разбора полей. Заметьте, что request ни одного из этих решений не принимал: он нашёл функцию и подал ей личность — и всё. Решал код самого действия.
Порядок «сессия → разбор → право → работа» уже стоял в конце прошлой статьи — но там он был утверждением: так надо. Здесь тот же каркас — вывод из собранного механизма: dispatch зовёт любую функцию по идентификатору, «кто» приходит отдельным каналом, а хранилище — единственный источник, которому можно верить. Каждая строка каркаса — ответ на одну из этих трёх вещей.
Разница между «запомнить каркас» и «понимать, откуда он» видна в первом же нестандартном действии: загрузка файла, действие без формы, вызов из клиентского компонента. Правила там те же, но применять их придётся к новой форме — а это получается только когда знаешь, из чего они следуют.
"use server"не переносит код на сервер, а регистрирует функцию: заводит ей идентификатор, кладёт в реестр, а в разметку отдаёт только строку.- Сети у песочницы нет: «отправка» — это поиск функции по идентификатору и вызов, то есть тот самый эндпоинт, только без POST.
- Эндпоинт открыт: вызов доходит до тела функции по одному идентификатору, мимо кнопки, формы и условной отрисовки.
- Личность приходит отдельным каналом — кукой, за которой сервер держит сессию; у поддельного
userIdиз полей не должно быть пути внутрь — ни в действии, ни в общей функции, которая достаёт пользователя. - Идентификатор непредсказуем и меняется между сборками, но со своей страницы читается открытым текстом — это не секрет.
- Единственная защита — проверка внутри действия: сессия, разбор полей, право на записи из хранилища, и только потом работа.
1/4Что делает директива "use server" с функцией?
2/4Что проверяет эндпоинт dispatch из статьи, прежде чем вызвать функцию?
3/4Откуда действие должно брать личность того, кто его вызвал?
4/4Вызов приходит с идентификатором, которого нет в реестре. Что вернёт эндпоинт?
Позвать действие мимо кнопки
Действие banUser банит пользователя, а проверки роли внутри нет: автор понадеялся, что кнопку рисуют только администратору. Гость кнопки не видел — но идентификатор со страницы взял. Запустите пример, убедитесь, что бан проходит, и почините: право должно проверяться внутри действия.
Наличие кнопки — не право. Действие само поднимает роль из сессии и решает, пускать ли: гостю отказывает, администратора пропускает.
Личность и роль request достаёт из сессии по куке и передаёт первым аргументом — из полей их взять нельзя, а значит, и подделать. Кнопка осталась там, где ей и место: она подсказывает человеку, а доступ решает код.
Дверь в транспорте
Действие removeComment написано честно: личность приходит параметром, право проверяется на записи из хранилища. Но в request кто-то «для удобства отладки» дописал возможность представиться полем. Запустите, убедитесь, что чужой комментарий удаляется, и найдите единственную строку, которую нужно исправить.
Дверь не в действии, а в транспорте: одна строка открыла подделку личности для всех действий реестра разом. Личность берётся только из сессии.
Теперь первый вызов отвечает «не найдено», второй по-прежнему «удалено: мой». В настоящем проекте такой «транспорт» — ваша общая функция вроде requireUser(): запасной вариант из полей, дописанный в неё однажды, открывает дверь во всех действиях, где её зовут, — и ни одно из них по отдельности не выглядит сломанным.
Предсказать идентификаторы
Перед вами реестр из первого раздела и две регистрации подряд. Скажите, какой идентификатор получит каждое действие и что вернёт dispatch для строки "a9", которую никто не регистрировал, — и только потом запускайте.
Счётчик выдаёт идентификаторы по порядку регистрации: первое действие получает "a1", второе — "a2". Строки "a9" в реестре нет, и dispatch возвращает «нет такого действия».
Отсюда и берётся ответ на незнакомый идентификатор: эндпоинт не выполняет ничего похожего, он ищет точное совпадение строки. А в настоящем Next вместо счётчика стоит хеш — но принцип поиска по реестру ровно этот.