Исторически разработчики Next.js использовали файл middleware.ts как единую точку входа для проверки аутентификации и авторизации: чтение сессионного cookie, валидация JWT и последующий редирект на страницу логина при отсутствии прав. Однако архитектура распределенного рендеринга App Router и React Server Components (RSC) показала критическую уязвимость такого подхода.
Сетевой периметр (Network Perimeter), работающий на уровне Edge Runtime или Node.js HTTP-прокси, оперирует исключительно заголовками, путями URL и куками. Он принципиально изолирован от контекста данных приложения. Попытка возложить функции контроля доступа (Access Control) исключительно на сетевой фильтр привела к серии критических уязвимостей (включая класс атак обхода маршрутизации CVE-2025-29927), когда специфические RSC-запросы, внутренние перезаписи путей (rewrites) или подмена заголовков позволяли обойти проверку в Middleware и получить прямой доступ к серверным компонентам или Server Actions.
В Next.js 16 концептуальная модель разделена на два изолированных уровня:
proxy.ts) — компонент сетевого периметра. Он отвечает за оптимизацию маршрутизации, интернационализацию, подстановку криптографических nonces и грубый отсев неаутентифицированных запросов для разгрузки вычислительных мощностей.Сетевой периметр и слой данных выполняют не взаимозаменяемые, а строго ортогональные задачи. Сетевой прокси (proxy.ts) защищает серверные ресурсы от нецелевого расхода CPU, блокируя явно невалидный трафик на ранней стадии жизненного цикла HTTP-запроса. Слой доступа к данным (DAL) гарантирует целостность бизнес-логики и защиту на уровне строк (Row-Level Security), независимо от того, через какой сетевой интерфейс или протокол был инициирован вызов.
Входящий HTTP-запрос (Edge / Network Boundary)
│
▼
┌────────────────────────────────────────────────────────┐
│ proxy.ts (Сетевой периметр) │
│ - Проверка структуры JWT / Session Cookie │
│ - Установка заголовков x-user-id, x-nonce │
│ - Оптимизация роутинга / Перенаправление на /login │
└────────────────────────────────┬───────────────────────┘
│
(Запрос передан в Node.js Core)
│
▼
┌────────────────────────────────────────────────────────┐
│ Server Component / Server Action │
│ - НЕ выполняет прямых SQL-запросов │
│ - НЕ доверяет данным из searchParams или headers │
└────────────────────────────────┬───────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Data Access Layer (DAL) │
│ - verifySession() через React cache() │
│ - Проверка ролей (RBAC) и прав на запись (ABAC) │
│ - Выборка из БД + фильтрация по tenantId/userId │
│ - Преобразование в DTO (очистка хэшей, токенов) │
└────────────────────────────────┬───────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ База данных (PostgreSQL / Redis / Prisma / Drizzle) │
└────────────────────────────────────────────────────────┘Почему нельзя полагаться только на сетевой прокси? Рассмотрим вектор атаки:
/admin/*.$ACTION_ID_4f89b, либо использует специальный заголовок Next-Url: /public при запросе внутреннего RSC Flight payload./public, считает запрос публичным и пропускает его.Единственная архитектурно надежная защита — перенос проверок авторизации непосредственно в точки входа данных.
proxy.ts перехватывает входящий запрос, валидирует базовую криптографическую подпись cookie сессии и устанавливает заголовки безопасности.
Next.js передает управление в серверное дерево компонентов. Контекст сессии не извлекается повторно из заголовков, а инициализируется безопасно.
Серверный компонент или Action вызывает метод DAL. Функция verifySession(), мемоизированная через React cache(), получает профиль пользователя.
DAL сопоставляет роль субъекта с запрашиваемой операцией и принудительно внедряет tenantId и userId в условия SQL-запроса.
Сырые данные из ORM фильтруются: служебные поля, хэши паролей и внутренние метаданные удаляются перед возвратом в компонент.
Почему реализация авторизации (проверка прав на просмотр финансового отчета) исключительно в proxy.ts (middleware) считается критическим дефектом безопасности?
В архитектуре B2B SaaS компании делят общую базу данных. На уровне proxy.ts извлекается поддомен (tenant.app.com) и валидируется базовый статус аккаунта компании. Однако выборка данных в DAL жестко параметризуется:
WHERE tenant_id = verifiedSession.tenantId. Если разработчик забудет фильтр в компоненте, DAL отклонит запрос, исключая межпользовательскую утечку данных.
При проведении финансового перевода через Server Action сетевой прокси не может знать сумму транзакции и лимиты счета. DAL проверяет вторичный одноразовый токен авторизации (Step-up authentication / 2FA) и сверяет баланс в рамках изолированной ACID-транзакции.
В крупных организациях маршруты /admin часто скрыты за корпоративным VPN. Сетевой прокси отсекает запросы с публичных IP-адресов. Но если злоумышленник скомпрометирует внутренний хост, DAL на уровне сущностей проверяет RBAC-права (Role-Based Access Control) конкретного инженера, блокируя несанкционированные действия.
Шлюз принимает входящие запросы от внешних интеграторов. Сетевой прокси проверяет API-Key и реализует Rate Limiting. DAL сопоставляет ключ с набором гранулярных областей видимости (scopes) и генерирует DTO, скрывая внутренние служебные поля схемы данных.
proxy.ts извлекает сессионную куку, проверяет HMAC-подпись и перенаправляет неавторизованные запросы.
Не выполняет тяжелых запросов в базу данных во избежание деградации TTFB.
Серверный компонент верхнего уровня инициирует загрузку данных через безопасный фасад репозитория.
Компонент не имеет доступа к объекту входящего HTTP-запроса напрямую.
Метод verifySession() извлекает сессию через cookies() и кэширует результат в рамках текущего прохода рендера.
Повторные вызовы в дочерних компонентах разрешаются с нулевым оверхендом.
Репозиторий связывает идентификатор пользователя с SQL-запросом и преобразует результат в иммутабельный DTO.
Удаляются поля passwordHash, internalFlags и salt.
proxy.ts#В Next.js 16 сетевой прокси объявляется в корне проекта (proxy.ts) и замещает функционал устаревшего монолитного middleware.ts. Его зона ответственности — только сетевые инварианты.
import { NextRequest, NextResponse } from 'next/server';
// Публичные маршруты, не требующие наличия сессии
const PUBLIC_ROUTES = new Set(['/login', '/register', '/api/health']);
export default async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Пропускаем статические ассеты и внутренние системные вызовы
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon.ico') ||
PUBLIC_ROUTES.has(pathname)
) {
return NextResponse.next();
}
// 2. Быстрая проверка наличия сессионного токена на периметре
const sessionToken = request.cookies.get('app_session')?.value;
if (!sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// 3. Формирование безопасных заголовков для внутренних сервисов
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-forwarded-perimeter', 'verified');
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};lib/dal/auth.ts#Слой доступа к данным обязан быть изолирован от клиентских бандлов с помощью директивы import 'server-only'.
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export interface UserSession {
userId: string;
role: 'admin' | 'manager' | 'viewer';
tenantId: string;
email: string;
}
/**
* verifySession мемоизируется на уровне одного HTTP-запроса через React cache().
* Независимо от количества вызовов в 10 компонентах одного поддерева,
* валидация и запрос сессии происходят строго один раз.
*/
export const verifySession = cache(async (): Promise<UserSession> => {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('app_session')?.value;
if (!sessionToken) {
redirect('/login');
}
// Декодирование и верификация криптографического токена
const sessionRecord = await db.sessions.findUnique({
where: { token: sessionToken },
include: {
user: {
select: {
id: true,
role: true,
tenantId: true,
email: true,
isBlocked: true,
},
},
},
});
if (!sessionRecord || !sessionRecord.user || sessionRecord.user.isBlocked) {
redirect('/login');
}
return {
userId: sessionRecord.user.id,
role: sessionRecord.user.role as UserSession['role'],
tenantId: sessionRecord.user.tenantId,
email: sessionRecord.user.email,
};
});lib/dal/repositories/billing.ts#Компоненты никогда не должны обращаться к ORM (Prisma, Drizzle) напрямую. Репозиторий гарантирует принудительное ограничение области выборки и маппинг в DTO.
import 'server-only';
import { verifySession } from '@/lib/dal/auth';
import { db } from '@/lib/db';
// Безопасный контракт данных (DTO): не содержит приватных ключей провайдера
export interface InvoiceDTO {
id: string;
amountCents: number;
status: 'pending' | 'paid' | 'void';
createdAt: string;
customerEmail: string;
}
export async function getInvoicesForCurrentTenant(): Promise<InvoiceDTO[]> {
// 1. Авторизационный барьер: получаем гарантированно проверенную сессию
const session = await verifySession();
// 2. Изоляция данных: tenantId берется ИСКЛЮЧИТЕЛЬНО из сессии, а не из аргументов
const rawInvoices = await db.invoices.findMany({
where: {
tenantId: session.tenantId,
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
amountCents: true,
status: true,
createdAt: true,
customerEmail: true,
// paymentGatewaySecretKey: НЕ запрашивается из базы данных
},
});
// 3. Формирование DTO
return rawInvoices.map((inv) => ({
id: inv.id,
amountCents: inv.amountCents,
status: inv.status as InvoiceDTO['status'],
createdAt: inv.createdAt.toISOString(),
customerEmail: inv.customerEmail,
}));
}app/(dashboard)/invoices/page.tsx#Компонент остается декларативным, чистым от рутинного кода проверок сессии и полностью безопасным.
import { Suspense } from 'react';
import { getInvoicesForCurrentTenant, InvoiceDTO } from '@/lib/dal/repositories/billing';
export default async function InvoicesPage() {
// Компонент просто запрашивает репозиторий. Если прав нет — DAL выбросит redirect или ошибку
const invoices = await getInvoicesForCurrentTenant();
return (
<main className="p-6">
<h1 className="text-xl font-bold mb-4">Журнал финансовых счетов</h1>
<Suspense fallback={<div>Загрузка реестра...</div>}>
<InvoiceList invoices={invoices} />
</Suspense>
</main>
);
}
function InvoiceList({ invoices }: { invoices: InvoiceDTO[] }) {
if (invoices.length === 0) {
return <p className="text-slate-400">Счета отсутствуют</p>;
}
return (
<ul className="divide-y divide-slate-800">
{invoices.map((inv) => (
<li key={inv.id} className="py-3 flex justify-between">
<span>{inv.customerEmail}</span>
<span className="font-mono font-bold">
{(inv.amountCents / 100).toFixed(2)} USD
</span>
</li>
))}
</ul>
);
}Инженер передает tenantId в качестве параметра вызова функции: getInvoices(tenantId: string). В чем заключается критический риск безопасности такого контракта?
Возврат объектов Prisma или Drizzle напрямую в дерево серверных компонентов без трансформации в DTO приводит к неконтролируемой передаче приватных полей (хеши паролей, токены сброса, флаги внутренних ролей) через протокол RSC Flight в браузер.
Как избежать: Всегда проектируйте слой DTO в репозиториях DAL с явной проекцией select: { id: true, name: true } и возвращайте только публичные структуры данных.
Если модуль с логикой DAL не содержит директиву import 'server-only', случайный импорт вспомогательной функции в Client Component приведет к тому, что сборщик Turbopack упакует код доступа к БД в клиентский бандл, обнажив ключи или вызвав фатальный сбой сборки.
Как избежать: Добавляйте import 'server-only' первой строкой во все файлы директории lib/dal/*.
Вызов проверки сессии в десятке серверных компонентов на одной странице без мемоизации через cache() создает каскад из 10 одинаковых SQL-запросов к таблице sessions (Request Waterfall), перегружая пул базы данных.
Как избежать: Оборачивайте функцию verifySession в утилиту cache() из пакета 'react'.
Разработчик считывает заголовок request.headers.get('x-user-id') внутри Server Action, полагая, что он установлен сетевым прокси. Однако в ряде конфигураций обратного прокси злоумышленник может напрямую инжектировать заголовок x-user-id: admin во входящий HTTP-запрос.
Как избежать: Считывайте сессию из криптографически подписанной cookie непосредственно в Node.js рантайме внутри DAL.