Middleware и граница доверия: matcher, rewrite и CVE-2025-29927
middleware.ts выполняется перед каждым подходящим запросом: matcher, rewrite/redirect/заголовки. Он для маршрутных решений — локаль, A/B, редиректы, — а не для тяжёлого auth и БД. CVE-2025-29927: один заголовок отключал middleware — главный аргумент за эшелонированную защиту.
Март 2025-го. Исследователи публикуют CVE-2025-29927, severity 9.1: любое Next.js-приложение, проверявшее аутентификацию только в middleware, обходилось добавлением одного заголовка запроса — x-middleware-subrequest со значением вроде middleware:middleware:middleware:middleware:middleware. Этот заголовок был внутренней защитой Next от рекурсии; фреймворк видел его, заключал «этот запрос уже прошёл middleware» — и пропускал его. Ни цепочки эксплойтов, ни timing-атаки — curl с одним лишним заголовком проходил мимо всех ворот /admin и /dashboard на непропатченных self-hosted-инсталляциях. Команды, чьи route handlers и слой данных перепроверяли сессию, пожали плечами и спокойно пропатчились. Команды, чья вся авторизация умещалась в пятнадцать строк middleware.ts, получили инцидент. CVE вскрыл не столько странный баг, сколько категориальную ошибку: middleware — это слой маршрутизации, который выполняется перед каждым подходящим запросом, — полезный, быстрый и по построению обходимый. Этот урок о том, как использовать его по назначению.
Один файл, каждый запрос: что такое middleware на самом деле
Middleware в Next.js — это единственный middleware.ts в корне проекта (или в src/), экспортирующий одну функцию, которая выполняется до того, как роутер разрешит каждый запрос, пропущенный matcher-ом — страницы, route handlers, префетчи, даже статические ассеты, если matcher написан небрежно. Она получает NextRequest и умеет ровно четыре вещи: пропустить запрос дальше (NextResponse.next()), сделать redirect (браузер видит 3xx, URL меняется), сделать rewrite (URL остаётся, но Next внутренне отдаёт другой маршрут — механизм A/B-тестов и локализации) и мутировать заголовки и cookies входящего запроса или исходящего ответа.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// A/B: bucket один раз через cookie, затем rewrite — URL остаётся /pricing
const bucket = request.cookies.get("ab-pricing")?.value ?? assignBucket();
const response =
bucket === "b"
? NextResponse.rewrite(new URL("/pricing-b", request.url))
: NextResponse.next();
response.cookies.set("ab-pricing", bucket, { maxAge: 60 * 60 * 24 * 30 });
return response;
}
export const config = {
// применять ко всему, кроме внутренностей Next и файлов с расширением
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};matcher — это дроссель. Он вычисляется на этапе сборки (значения должны быть статически анализируемыми — никаких шаблонных строк из env-переменных) и поддерживает path-паттерны с regex-группами, включая идиому с негативным просмотром выше, к которой приходит каждое продакшен-приложение. Ошибитесь в разрешающую сторону — и middleware выполняется на каждом запросе картинки и шрифта, а любая добавленная им латентность умножается на самые многочисленные запросы, которые вы обслуживаете. Различие redirect и rewrite стоит держать чётким: redirect стоит round trip и виден пользователю; rewrite невидим и бесплатен по round trip, но создаёт расхождение URL-против-контента, которое придётся держать в голове при отладке («почему /pricing рендерит вариант B?»).
Что место в middleware — и арифметика латентности для того, чему не место
Middleware оправдан для решений дешёвых, живущих в самом запросе и нужных до роутинга: определение локали по Accept-Language и geo-заголовкам, раздача A/B-bucket-ов, редиректы legacy-URL, фильтрация ботов, проставление request ID и оптимистичный ярус auth — «cookie сессии нет вообще? отправить на /login», — где ошибка безвредна, потому что настоящая проверка происходит глубже.
Антипаттерн — заставлять его делать настоящую работу. Middleware стоит перед каждым подходящим запросом, поэтому его латентность — налог на весь сайт: 35–40 мс поиска сессии в базе добавляются к каждой странице, каждому API-вызову, каждому префетчу, который <Link>-компоненты запускают при скролле. Префетчи — тихий множитель: экран ссылок может выстрелить дюжиной выполнений middleware ради навигаций, которые никогда не случатся. К тому же исторически middleware выполнялся только в Edge-runtime (Node.js-middleware стал стабильным в Next 15.5), так что драйверы БД на TCP-сокетах, нативные модули и тяжёлые JWT-библиотеки там просто не работали — команды узнавали это по загадочным ошибкам бандлинга при первом импорте ORM в middleware.ts. Честный паттерн: middleware читает то, что лежит в запросе (наличие cookie, максимум — проверка подписанной cookie через web crypto), а всё, что трогает хранилище, живёт в route handler, серверном компоненте или слое данных, реально обслуживающем запрос.
Команда добавляет поиск сессии (один Postgres-запрос на 35 мс) в middleware.ts с matcher-ом на все страницы. Общесайтовый p50 растёт куда сильнее, чем на ощущаемые 35 мс, а соединения с БД скачут. В чём структурная причина?
CVE-2025-29927: заголовок, выключавший middleware
Механизм почти неловко прост. Когда middleware сам запрашивает маршрут (например, при rewrite), Next должен предотвратить бесконечную рекурсию — middleware, вечно вызывающий middleware. Защитой служил внутренний заголовок x-middleware-subrequest, носивший счётчик вызовов middleware; когда он показывал достигнутую глубину рекурсии, Next пропускал middleware для этого запроса. Изъян: ничто не мешало внешнему клиенту прислать этот заголовок. Запрос с x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware (совпадает с внутренним лимитом глубины 5; старым версиям нужны были варианты вроде src/middleware) считался уже прошедшим строй. Проверка auth в middleware? Пропущена. Geo-блокировка? Пропущена. Инъекция CSP-заголовков? Пропущена.
Пропатченные версии — 15.2.3, 14.2.25 и бэкпорты 13.5.9 / 12.3.5 — чинят это, вырезая заголовок из внешних запросов, а платформа Vercel фильтровала его на CDN ещё до патча (постмортем — в источниках урока). Но долговечный урок архитектурный, и он пережил бы даже фреймворк без багов: middleware выполняется перед вашим приложением, в слое, чьи инварианты вы не контролируете. CDN, реверс-прокси, внутренности фреймворка — у любого может найтись путь, не выполняющий вашу функцию. Паттерн, переживающий этот класс багов, — эшелонированная защита: middleware делает дешёвый UX-ярус (редиректит анонимов, чтобы они не видели вспышку защищённой страницы), а принуждающая проверка — верификация сессии, авторизация по ресурсу — живёт там, где данные реально читаются: route handler, server action, слой доступа к данным. Команды с такой структурой прочли анонс CVE как «пропатчиться на этой неделе»; команды без неё — как «ротировать всё и проверять логи доступа».
▸Почему это работает
Зачем Next вообще механизм пропуска middleware, а не безусловный запуск? Потому что middleware может переписывать на маршруты, которые снова сматчат middleware: /pricing переписывается в /pricing-b, тот матчит тот же matcher, тот переписывает снова. Какая-то защита от рекурсии обязана существовать, и in-band-заголовок — самый дешёвый способ пронести «это субзапрос» через внутреннюю границу fetch. Уязвимостью была не защита, а доверие к присланной клиентом копии внутреннего сигнала — та же ошибка границы доверия, что и принятие X-Forwarded-For из открытого интернета. In-band-сигналы между доверенными компонентами нужно вырезать или аутентифицировать на границе доверия.
После CVE-2025-29927 команда спорит, где должна жить аутентификация. Их middleware редиректит пользователей без cookie сессии на /login. Какое разделение труда защищаемо?
- 01Что middleware умеет, что ему следует делать и почему арифметика латентности беспощадна?
- 02Объясните механизм CVE-2025-29927 и архитектуру, переживающую баги этого класса.
Middleware — одна функция, выполняющаяся перед каждым запросом, который пропускает её matcher, и весь её API — четыре хода: пропустить, redirect (видимый пользователю 3xx ценой round trip), rewrite (невидимая внутренняя пере-маршрутизация — субстрат A/B-тестов и локализации ценой расхождения URL-против-контента при отладке) и мутация заголовков/cookies. Matcher компилируется при сборке, обязан быть статически анализируемым, и почти каждое продакшен-приложение сходится к паттерну с негативным просмотром, исключающему _next-внутренности и файловые запросы, — потому что разрешительный matcher умножает цену middleware на картинки, шрифты и шторм префетчей, который Link-компоненты выпускают ради несостоявшихся навигаций. Это умножение и делает правило содержимого строгим: middleware несёт дешёвые, живущие в запросе, до-роутинговые решения — локаль, A/B-bucket-ы, редиректы, request ID и оптимистичный ярус auth, отправляющий пользователей без сессии на /login, — а всё, что трогает хранилище, живёт в слое, отдающем данные; вдобавок классический middleware работал исключительно в Edge-runtime, где TCP-драйверы БД и нативные модули не загружаются, а Node-middleware стал стабильным лишь в 15.5. CVE-2025-29927 превратил этот совет в доктрину: внутренняя защита Next от рекурсии — заголовок x-middleware-subrequest — принималась и от внешних клиентов, так что один подделанный заголовок полностью пропускал middleware на непропатченных self-hosted-инсталляциях — все auth-ворота только-в-middleware испарились, severity 9.1, исправлено в 15.2.3/14.2.25 вырезанием заголовка на границе доверия. Долговечный урок структурный, а не версионный: middleware выполняется перед приложением в слое, которым вы не владеете, поэтому он несёт UX-ярус защиты, а принуждающие проверки — верификация сессии, авторизация по ресурсу — живут в route handlers, server actions и слое доступа к данным. С такой структурой обход middleware — спокойный патч; без неё — инцидент.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.