open atlas
↑ К треку
Next.js с нуля до senior NEXT · 04 · 02

Границы аутентификации: почему middleware и layout не защищают, и Data Access Layer, который защищает

Middleware — оптимистичный редирект, а не защита: CVE-2025-29927 пропускал его одним заголовком. Layout рендерится независимо и не перезапускается при навигации — его проверка ничего не охраняет. Авторитетная граница — Data Access Layer: сессия проверяется при каждом чтении.

NEXT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Март 2025-го. Исследователи публикуют CVE-2025-29927, серьёзность 9.1: пошлите Next.js-приложению заголовок x-middleware-subrequest с правильным значением — и middleware просто не выполнится. Заголовок был внутренней меткой, которой Next останавливал рекурсию middleware в самого себя; с внешних запросов его никто не срезал, так что надеть его мог кто угодно. Для каждого self-hosted-приложения, чья аутентификация целиком жила в middleware.ts — проверить сессионный cookie, средиректить на /login, — каждый защищённый маршрут стал публичным: админки, дашборды, биллинг — на расстоянии одного флага curl. Платформенный инженер читает постмортем Vercel с отстранённым интересом — и холодеет: их собственная админка проверяет аутентификацию ровно в одном месте. В middleware. Патч (15.2.3 / 14.2.25) вышел быстро, но глубокий урок не про один заголовок. Они построили хранилище, единственный замок которого висел на двери вестибюля — а у вестибюля был служебный вход.

Middleware — швейцар, а не сейф

Задайте себе вопрос: если бы кто-то мог не атаковать вашу проверку, а просто пропустить её целиком — что откроется? Этот вопрос не гипотетический: CVE-2025-29927 ответил на него для каждого приложения, чья единственная проверка жила в middleware.

Middleware выполняется до разрешения маршрута, на каждом подходящем запросе, и потому кажется естественным местом для аутентификации: один файл, все маршруты накрыты. Но посмотрите, что он умеет делать хорошо: он работает в ограниченном рантайме, где не стоит ходить в базу на каждом запросе, так что практичный паттерн — оптимистичная проверка: прочитать сессионный cookie, возможно, локально сверить подпись и средиректить неаутентифицированных на /login до любой работы рендеринга. Это по-настоящему ценно: быстро (доли миллисекунды, без IO), разлогиненные никогда не видят вспышку защищённого UI, и политика редиректов централизована. Чем это не должно быть никогда — единственной проверкой.

CVE-2025-29927 — каноническое доказательство, и его механизм стоит знать точно. Next.js использовал заголовок x-middleware-subrequest внутри — чтобы предотвратить бесконечную рекурсию: если middleware инициировал fetch, снова попадающий в middleware, заголовок говорил Next «уже было, пропусти». Провал границы доверия: внешние запросы с этим заголовком были неотличимы от внутренних, и атакующий, знающий ожидаемое значение (выводимое из пути файла middleware и повторённое до нужной глубины рекурсии), мог велеть фреймворку пропустить вашу аутентификацию целиком. Хостинг Vercel срезал заголовок на edge; self-hosted-деплои были открыты. Патч починил заголовок, но класс выжил: любую одиночную проверку у парадной двери можно обойти — контрабандой заголовков, ошибкой маршрутизации прокси, паттерном matcher, забывшим маршрут, или следующим CVE. Defense-in-depth здесь не фраза для комплаенса — это вывод, к которому инцидент принуждает архитектуру.

Викторина

Единственная проверка аутентификации в приложении — middleware, сверяющий сессионный cookie и редиректящий на /login. Какой правильный урок из CVE-2025-29927 (обход через x-middleware-subrequest)?

Layout не защищает своих детей

Второе соблазнительное место для проверки — layout.tsx: он оборачивает каждую страницу под собой, и проверка сессии в нём выглядит периметром. Это не периметр — по двум архитектурным причинам, не имеющим отношения к багам. Первая: layout не перерендеривается при клиентской навигации — частичный рендеринг сохраняет общие layout-ы, и навигация с /dashboard/a на /dashboard/b перерендеривает только сегмент страницы. Проверка в layout выполнилась один раз, при первой загрузке; если сессию с тех пор отозвали или пользователь вышел в другой вкладке, навигация внутри раздела вопрос больше не задаёт. Вторая: layout и страница рендерятся независимо. Это отдельные записи в RSC-payload, рендеримые в параллельных серверных проходах; layout не может передать пропсы странице, и — главное — запросы данных страницы не ждут вердикта проверки в layout. Защищённые данные выбираются из базы, пока layout ещё решает, редиректить ли.

Есть и более острая версия того же факта: клиент может запросить RSC-payload конкретного сегмента напрямую. Частичный пререндеринг и посегментные запросы означают, что единица доставки у фреймворка — сегмент, а не обёрнутое дерево, так что рассуждение «моя страница в безопасности, потому что что-то выше проверяет» опирается на гарантию вложенности, которой модель рендеринга не даёт. redirect() в layout — любезность для UX, которая обычно срабатывает; это не граница безопасности, которая срабатывает всегда. Доки Next.js по аутентификации говорят это одной строкой — проверяйте близко к источнику данных, не в layout-ах, — и команды проскальзывают мимо, потому что проверка в layout кажется работающей в каждом ручном тесте. Она отказывает ровно там, где никто не тестирует руками: отозванные сессии посреди навигации, прямые запросы сегментов, гонки параллельных рендеров.

Data Access Layer: проверка при каждом чтении

Когда вы видите кодовую базу, где проверки аутентификации разбросаны по layout-ам, страницам и middleware, вопрос не «верна ли каждая проверка?», а «может ли запрос добраться до запроса в базу, минуя все?» Ответ почти всегда «да» — и DAL исключает этот класс вопросов, делая проверку неотделимой от чтения.

Граница, переживающая всё перечисленное, — та, что прикреплена к самим данным. Data Access Layer (DAL) — это модуль, единственный модуль, через который идут запросы к защищённым данным, и каждая его функция начинается с проверки сессии. Ни один путь к данным не может пропустить проверку, потому что проверка и есть путь. cache() из React делает это доступным по цене: verifySession мемоизируется на запрос, и десять вызовов DAL в одном дереве рендера стоят один поиск сессии, а не десять.

// app/lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect, notFound } from 'next/navigation';
import { kv } from '~/lib/kv';
import { db } from '~/lib/db';

export const verifySession = cache(async () => {
  const token = (await cookies()).get('__Host-session')?.value;
  const session = token ? await kv.get(`session:${token}`) : null;
  if (!session) redirect('/login');   // мемоизировано: 10 вызовов за рендер = 1 поиск
  return session;
});

export async function getInvoice(id: string) {
  const session = await verifySession();        // на КАЖДОМ чтении — это и есть граница
  const invoice = await db.invoice.findUnique({ where: { id } });
  if (invoice?.ownerId !== session.userId) notFound(); // по-экземплярная авторизация, не только аутентификация
  return { id: invoice.id, total: invoice.total };     // DTO: возвращаем поля, не строки
}

Заметьте вторую проверку: ownerId !== session.userId. Аутентификация («кто ты») в начале функции, авторизация («можно ли тебе видеть эту строку») перед возвратом — обе принадлежат DAL, потому что обе суть свойства доступа к данным, а не маршрута. DTO-возврат — часть той же дисциплины: верните поля, нужные UI, и никогда — сырую строку, чтобы будущий клиентский компонент, получивший этот объект, не утёк колонками, которые никто не собирался отгружать. Слоистый итог: middleware даёт быстрые оптимистичные редиректы (UX), страницы и layout-ы рендерят что хотят (презентация), а DAL — единственное место, где утверждение безопасности действительно навязывается: место, куда не дотянутся ни обход middleware, ни забытая запись matcher, ни гонка layout-а, потому что запросу всё равно придётся пройти через verifySession, чтобы коснуться строки.

Почему это работает

Почему «проверка при каждом чтении» не взрывает бюджет задержки? Потому что дорогая часть мемоизирована, а дешёвая — это сравнение. verifySession за React cache() выполняется один раз на запрос, сколько бы DAL-функций ни вызвал рендер, — один KV-поиск, ~1-2 мс. По-строчное сравнение владения — наносекунды на уже выбранных данных. Сравните с ценой альтернативы: одна находка пентеста категории «горизонтальное повышение привилегий через прямую ссылку на объект» — это недели ремедиации, решения о disclosure и ручной аудит каждого похожего эндпоинта. DAL превращает этот аудит из «каждый маршрут, каждый раз» в «есть ли запрос в обход DAL?» — grep, а не расследование.

Викторина

Команда кладёт проверку сессии в app/dashboard/layout.tsx: нет сессии — redirect('/login'). Страницы под ним выбирают биллинговые данные без собственных проверок. Почему это не граница безопасности?

Вспомните перед уходом
  1. 01
    Что именно позволял CVE-2025-29927 и каков архитектурный вывод за пределами патча?
  2. 02
    Почему проверка в layout.tsx не может защитить страницы под ним и что её заменяет?
Итог

App Router предлагает три правдоподобных места для проверки аутентификации, и это не взаимозаменяемые слои — это одна UX-оптимизация, одна ловушка и одна граница. Middleware выполняется до маршрутизации на каждом подходящем запросе, что делает его правильным местом для оптимистичной проверки: прочитать cookie, локально сверить без обращения к базе, средиректить разлогиненных до начала рендеринга. Единственной проверкой он быть не должен, и CVE-2025-29927 — постоянное тому доказательство: внутреннему заголовку x-middleware-subrequest доверяли и на внешних запросах, так что один сконструированный заголовок пропускал middleware целиком на self-hosted-приложениях — каждый «защищённый» маршрут был публичным до 15.2.3/14.2.25. Патч закрыл заголовок; класс — контрабанда заголовков, ошибки прокси, дыры matcher, следующий CVE — остался, и он выносит приговор любой архитектуре, умирающей от пропуска одной парадной двери. Layout — ловушка: проверка сессии в layout.tsx выглядит периметром, но переживает клиентскую навигацию (выполняется при первой загрузке и больше не переспрашивает, пропуская отозванные сессии) и рендерится независимо от страниц — их запросы данных идут параллельно вердикту layout, а сегменты адресуются по отдельности, так что воображаемой гарантии вложенности в модели рендеринга нет. Держится граница у данных: Data Access Layer — единственный модуль, через который текут защищённые запросы, где каждая функция открывается verifySession — мемоизированным через React cache(), так что десять вызовов стоят один поиск за ~1-2 мс, — затем проверяет по-экземплярную авторизацию (владеет ли эта сессия этой строкой) и возвращает DTO из нужных полей вместо сырой строки. Теперь, встречая проверку аутентификации в layout или middleware, вы знаете, какой вопрос задать следующим: есть ли путь к защищённым данным в обход этой проверки?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.