Конвейер запроса: middleware, guards, interceptors, pipes, filters
Запрос проходит middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter. Две ловушки: guard видит сырое тело (pipes идут после него), а enhancer, созданный через new, вне DI — биндь через APP_GUARD, чтобы инжектить.
RolesGuard выглядел идеально на ревью. Он читал @Roles('admin') с хендлера через Reflector, сверял роли пользователя с базой через UsersService и возвращал false для всех остальных. Автор подключил его в main.ts так, как документация показывает для «применить везде»: app.useGlobalGuards(new RolesGuard(reflector, usersService)). Вот только reflector и usersService не были в области видимости в main.ts, поэтому кто-то передал new RolesGuard(new Reflector(), null), чтобы оно скомпилировалось. Это уехало в прод. Три недели каждый аутентифицированный пользователь был админом, потому что usersService у guard был null, проверка роли выбрасывала, а окружающая обработка ошибок по умолчанию открывала доступ. Ничто в тестах это не поймало — тесты тоже конструировали guard руками, со стабом сервиса, который всегда говорил «да». Этот урок — про семистадийный конвейер, через который проходит каждый запрос, и про два места, где сеньоры обжигаются: что видит каждый enhancer и как он получает свои зависимости.
Семь стадий, по порядку
Прежде чем писать первую строку guard или interceptor, нужно знать, на какой стадии конвейера он выполняется: стадия определяет, какие данные доступны, — и именно помещение логики не на ту стадию породило трёхнедельную дыру в безопасности из хука.
Каждый HTTP-запрос, доходящий до хендлера Nest, проходит фиксированную полосу препятствий из enhancer’ов. Порядок не настраивается, и спутать его в голове — это как раз способ положить логику авторизации не туда. Слева направо:
middleware → guard → interceptor (pre) → pipe → handler → interceptor (post) → filter (при throw)
- Middleware идёт первым, на уровне Express/Fastify, биндится в
configure(consumer)модуля. У него есть сырыеreq,res,next— но он работает до того, как Nest определил, какой хендлер обслужит маршрут, поэтому у него нетExecutionContext, нет ссылки на хендлер, нет метаданных маршрута. - Guards идут следующими.
canActivate(ctx)возвращает булево (или Promise/Observable от него);falseкоротит запрос с403. Здесь живёт авторизация. Guard получаетExecutionContext, поэтому может читать метаданные хендлера черезReflector— но тело запроса ещё не валидировано и не трансформировано. - Interceptors (pre-половина) оборачивают хендлер. Код до
next.handle()выполняется здесь. - Pipes работают над аргументами хендлера —
@Body(),@Param(),@Query()— валидируя и трансформируя их (ValidationPipe,ParseIntPipe). Они выполняются после guards и после pre-половины interceptor’а, прямо перед хендлером. - Хендлер маршрута выполняется.
- Interceptors (post-половина) — RxJS-pipe после
next.handle()(map,timeout, кеширование, формирование ответа) выполняется на выходе. Один interceptor поэтому даёт тебе две точки выполнения вокруг хендлера. - Exception filters ловят всё, что выброшено на любой из стадий выше, и маппят ошибку в HTTP-ответ. Filter — последний рубеж.
// Guard работает на стадии 2 — ДО pipes. Видит метаданные хендлера, а не валидированный DTO.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector, // инжектируется — читает метаданные @Roles()
private readonly users: UsersService, // инжектируется — проверяет роли в БД
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const required = this.reflector.get<string[]>('roles', ctx.getHandler());
if (!required) return true; // нет @Roles() → публичный маршрут
const { user } = ctx.switchToHttp().getRequest();
return this.users.hasAnyRole(user.id, required);
}
}// Interceptor — это две точки выполнения: до next.handle() и после, через RxJS.
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now(); // половина ДО handler-а
return next.handle().pipe( // ← здесь выполняется handler
map((data) => ({ data, ms: Date.now() - start })), // половина ПОСЛЕ handler-а
);
}
}Область биндинга: где применяется enhancer
Каждый из четырёх видов enhancer’ов (guards, interceptors, pipes, filters) биндится на одной из четырёх областей: global, controller (@UseGuards() на уровне класса), method (@UseGuards() на хендлере), а для pipes ещё и parameter (@Body(new ValidationPipe())). Внутри вида выполнение идёт global → controller → method, самый внешний первым. Область отвечает на вопрос «где это применяется»; она не меняет порядок стадий выше.
DI-ловушка: new против APP_GUARD
Вот ловушка, которая укусила RolesGuard. Есть два способа зарегистрировать глобальный enhancer, и они не эквивалентны:
// АНТИПАТТЕРН: ты сам делаешь `new`, поэтому guard живёт ВНЕ DI-контейнера.
// Не может инжектить Reflector или UsersService — пришлось бы строить вручную.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard(/* что передать? */));
await app.listen(3000);
}
// ВЕРНО: регистрируй через токен APP_GUARD в модуле. Nest инстанцирует через DI,
// Reflector и UsersService инжектятся нормально.
@Module({
providers: [
UsersService,
{ provide: APP_GUARD, useClass: RolesGuard }, // глобально И DI-aware
],
})
export class AppModule {}app.useGlobalGuards(new X()) нормален для enhancer’а без зависимостей. В тот момент, когда enhancer’у нужно что-то инжектить — Reflector, ConfigService, репозиторий — ты обязан регистрировать его через провайдер-токен: APP_GUARD, APP_INTERCEPTOR, APP_PIPE или APP_FILTER. Они делают enhancer настоящим провайдером, который конструирует Nest, так что зависимости конструктора резолвятся из контейнера, а не оказываются null.
▸Почему это работает
Почему guard просто не может прочитать валидированный DTO? Потому что pipes выполняются на стадии 4, а guards — на стадии 2: guard работает до того, как хоть один pipe тронул аргументы. В момент guard request.body — это то, что выдал парсер тела: простой объект, а не экземпляр твоего DTO-класса, числа всё ещё строки, и ни одна проверка class-validator не применена. class-transformer не отработал, поэтому body instanceof CreatePostDto равно false. Любая авторизация, зависящая от валидированного или приведённого ввода (например, «поле owner должно совпадать с subject из JWT»), не может доверять телу внутри guard — она должна валидировать нужное поле в guard вручную, либо перенести это решение в хендлер или в interceptor, который выполняется после pipe. Смешивание этих двух стадий — это как authZ-проверка молча читает undefined.
Режимы отказа, на которые натыкаются сеньоры
AuthZ в pipe или guard, ждущий валидированное тело. Поскольку guards выполняются до pipes, guard видит сырое тело — DTO-класс не инстанцирован, числа всё ещё строки. Авторизация, зависящая от валидированного ввода, принадлежит хендлеру или interceptor’у после pipe, а не guard, и никогда не pipe (pipe трансформирует данные, он не граница авторизации).
Ожидание, что interceptor смаппит ошибки. Interceptor может ловить через catchError в своём RxJS-pipe, но каноничный маппинг ошибки в ответ — это exception filter (@Catch, catch(exception, host)). Делать и то, и другое ведёт к двойной обработке. И область важна для того, что filter ловит: guard выбрасывает на стадии 2, до того как в игру вступает method-scoped filter, поэтому исключение, выброшенное внутри guard, ловит только filter более высокой области (глобальный).
Middleware, тянущийся к метаданным хендлера. Middleware выполняется до разрешения маршрута, поэтому нет ExecutionContext и нет способа прочитать @Roles() или любой декоратор хендлера. Если тебе нужны метаданные хендлера — нужен guard; именно поэтому guards существуют как отдельная стадия.
Твоему RolesGuard нужно инжектить Reflector (чтобы читать метаданные @Roles()) и UsersService (чтобы проверять роли в базе), и ты хочешь применить его к каждому маршруту. Как зарегистрировать его глобально?
canActivate у guard читает request.body и проверяет `body.ownerId === user.id`. @Body() хендлера типизирован как CreatePostDto с class-validator. Что guard на самом деле видит в request.body?
Почему `app.useGlobalGuards(new RolesGuard())` не может инжектить Reflector, а `{ provide: APP_GUARD, useClass: RolesGuard }` инжектит его правильно?
- 01Перечисли семь стадий конвейера запроса Nest по порядку и для каждой скажи, что она может и не может видеть.
- 02Объясни две сеньорские ловушки: почему guard не может доверять телу и почему созданный руками глобальный enhancer не может инжектить свои зависимости.
Каждый HTTP-запрос, доходящий до хендлера Nest, проходит фиксированную полосу из семи стадий, и порядок не настраивается: middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter(при throw). Middleware идёт первым на уровне Express/Fastify с сырыми req/res/next, но без ExecutionContext, потому что хендлер маршрута ещё не разрешён. Guards идут следующими — canActivate решает авторизацию и может читать метаданные хендлера через Reflector, но тело ещё сырое. Pre-половина interceptor’а выполняется до next.handle(); затем pipes валидируют и трансформируют аргументы хендлера; затем выполняется хендлер; затем post-половина interceptor’а прогоняет свой RxJS-pipe на выходе (так что один interceptor — это две точки выполнения); а exception filter ловит всё выброшенное где угодно и маппит в ответ. Каждый enhancer биндится на области global, controller или method (pipes ещё и parameter), выполняясь global → controller → method, но область никогда не меняет порядок стадий. Две ловушки жгут сеньоров. Первая: поскольку guards выполняются до pipes, guard видит сырое невалидированное тело — не экземпляр DTO, числа всё ещё строки — поэтому authZ, зависящая от валидированного ввода, принадлежит хендлеру или interceptor’у после pipe, никогда guard’у, читающему «валидированное» тело, и никогда pipe. Вторая: забиндить глобальный enhancer двумя способами не эквивалентно: app.useGlobalGuards(new RolesGuard()) живёт вне DI-контейнера и ничего не инжектит (Reflector и сервисы — null, тот самый инцидент, где каждый пользователь стал админом), тогда как { provide: APP_GUARD, useClass: RolesGuard } (или APP_INTERCEPTOR/APP_PIPE/APP_FILTER) делает его DI-aware провайдером, чьи Reflector и UsersService резолвятся из контейнера. useGlobalGuards безопасен только для enhancer’а без зависимостей; всё, что с зависимостями, обязано биндиться через токен APP_*. Теперь, когда увидишь guard, который всегда пропускает, или дыру в авторизации после рефакторинга, — задай два вопроса: на какой из семи стадий выполнялась логика, и был ли enhancer сконструирован внутри или вне DI-контейнера?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.