Если вам нужно дать постороннему человеку войти в админку работающего интернет-магазина на Next.js, а данные покупателей не показывать, копия базы не единственный путь. Можно открыть живую панель с ролью только для чтения и закрыть утечки на трёх уровнях: запросы, поля и разделы. Расскажу, как это устроено у нас и где оно уже протекало.

Почему не копия базы

Отдельный стенд с правом записи нам не по памяти сервера: на машине помещается один билд за раз. А показать хотелось ровно ту панель, в которой работают каждый день, с настоящими товарами и историей заказов. Поэтому демо-доступ это вход в боевую админку под отдельной учётной записью demo, и вся защита живёт в коде.

Пароль у такого входа публичный по замыслу: он напечатан рядом со ссылкой. Значит, сам вход ничего не защищает, и защита должна быть в том, что роль может сделать и что она видит.

Первый уровень: запрет на входе, а не на кнопках

Роль определяется прямо в middleware по Basic-авторизации: две пары учётных данных лежат в окружении, поэтому проверка не ходит в базу и работает в edge-среде. Самый дешёвый способ сделать роль read-only: не проверять каждое действие, а отбивать на входе любой запрос, который не читает. В Next.js Server Actions ходят методом POST, поэтому одно правило в middleware закрывает все изменения сразу:

const READ_ONLY = new Set(["GET", "HEAD", "OPTIONS"]);
if (role === "demo") {  if (!READ_ONLY.has(request.method)) return forbidden(); // 403  if (DEMO_BLOCKED_GET.test(request.nextUrl.pathname)) return forbidden();}

if (role === "demo") {

if (!READ_ONLY.has(request.method)) return forbidden(); // 403

if (DEMO_BLOCKED_GET.test(request.nextUrl.pathname)) return forbidden();

}

Новая серверная функция в админке автоматически оказывается за этой стеной: её не нужно помнить и не нужно оборачивать.

Второй уровень: GET, который ничего не меняет, но выносит данные

Read-only это ещё не безопасно. CSV-выгрузки заказов, покупателей, платежей и переписок отвечают на GET и ничего не меняют, но выносят персональные данные пачкой. Их мы закрыли отдельным списком маршрутов, тоже на входе:

const DEMO_BLOCKED_GET =  /^\/api\/admin\/(?:orders|customers|chat|payments|sales)\/export\.csv/i;

/^\/api\/admin\/(?:orders|customers|chat|payments|sales)\/export\.csv/i;

Выгрузку переписок отбивает и проверка права внутри самого маршрута. Но полагаться на неё нельзя: право зависит от роли, а роль демо-входа мы однажды уже расширяли. Запрет на входе переживёт правку матрицы прав, внутренний нет.

Третий уровень: маски на сервере

Остальное демо видит, но с замаскированными полями: имя становится А••• П•••, почта a•••@•••, телефон +7 ••• ••• •• ••, суммы заказов и бонусы ••• ₽. Код промокода и подарочной карты скрыт целиком и фиксированной длиной: по длине и первой букве его не подобрать, а код подарочной карты это деньги на предъявителя.

Роль определяет сервер. Серверные компоненты заново читают заголовок Authorization, который уже принял middleware, а не доверяют заголовку, который можно подделать: клиент не может прикинуться полным доступом, не предъявив учётные данные. Сами маски лежат в отдельном файле чистых функций без server-only, потому что прятать значения нужно и в клиентских компонентах, например в таблице позиций заказа. Вторая копия строки «••• ₽» в клиентском файле разошлась бы с первой на первой же правке.

Чего маскировать нельзя: свободный текст

Свободный текст (комментарий, причина, текст ошибки) заменяется целиком: имя или телефон внутри предложения маской по полям не поймать. Переписки покупателей с ИИ-консультантом мы закрыли для демо целиком. Человек называет в такой переписке рост, вес, повод, иногда город и имя, то есть персональные данные лежат в самих предложениях, и маскировать по полям нечего. Настройки консультанта демо показывать можно, переписки нет, и одним разделом это не разделить. Поэтому гейт стоит в layout.tsx подпути переписок: карточка отдельной переписки лежит внутри и закрывается тем же файлом.

Намеренно видны две вещи: цены каталога (они публичны на витрине, и без них не показать, как меняется цена) и тексты отзывов (они опубликованы на карточке товара). Скрыты только авторы.

Как это не сломать со временем

Первые версии протекали. Демо-вход это роль «наблюдатель», которой по умолчанию открыты все разделы, и раздел, написанный после неё, о маскировании не знает. Так журнал действий однажды начал показывать почту сотрудника. Поэтому у нас три страховки:

  1. VIEWER_SECTIONS это Record<AdminSection, boolean> по всем разделам: завести раздел и не решить, открыт ли он демо, не даст собрать проект. Это не «закрыто по умолчанию», а принудительное решение при сборке.

  2. Скрипт-сканер проходит под демо-учёткой по всем страницам админки и ищет в видимом тексте почты, телефоны и суммы. Смотрит он именно видимый текст, без скриптов: next-intl кладёт в payload весь каталог переводов, и любой поиск по HTML тонет в ложных срабатываниях. Отдельный детектор ловит «сырые» поля: слова в camelCase вроде customerName в видимом тексте почти всегда означают, что на экран вывалили объект как есть. Почту и телефон регулярками поймать легко, а имя и адрес без этих шаблонов нет, и без детектора такая утечка была бы невидима.

  3. Четыре запроса, которые нужно перепроверять руками: без входа 401, под демо GET 200, POST 403, CSV 403.

Сегодняшний прогон скана по 76 страницам: 73 чистые, две не отдали страницу (404), одна находка оказалась названием триггера письма в настройках, то есть подписью интерфейса, а не данными.

Что это не закрывает

Это защита от случайного показа, а не от целенаправленной атаки на сервер: пароль публичный, так что вход сам ничего не доказывает. Скан работает по шаблонам и по форме, поэтому может пропустить утечку, которая не похожа ни на почту, ни на телефон, ни на сумму, ни на объект. И карточки с динамическими адресами он проверяет выборочно, по три из каждого списка, а не все.

Если вы делаете такой вход у себя, я бы начал с запрета на входе и сразу закрыл выгрузки отдельным списком.