Валидирането на входящите данни е от изключително значение за сигурността. Нека проучим защо и как да се справим с този проблем с „NestJS“.

NestJS е една от най-популярните бек-енд рамки, базирани на възли. В момента е на ранг #198 на най-популярните хранилища на Github (базирано на звезди); доста постижение. И е заслужено; тази рамка върши страхотна работа при структурирането на по-големи бек-енд системи; точно както прави Spring в екосистемата на Java.

В тази статия ще проучим как да се справяме с валидирането на въвеждане с NestJS. Ще разгледам най-честия случай на употреба: RESTful API, но повечето от това, което ще обясня, се отнася и за GraphQL API и WebSockets.

Да се ​​потопим направо!

Валидиране? Защо?

Инжекционните атаки все още са част от OWASP Топ 10; списък на най-критичните заплахи за сигурността на уеб приложенията.

Както се споменава в „доклада за 2019 г.“, пропуските при инжектиране са много разпространени и могат да доведат до загуба на данни, повреда или разкриване на данни на неоторизирани страни или дори пълно поглъщане на хоста (известен още като oopsie doopsie).

Ако данните, които вашето приложение обработва, не са важни, тогава вероятно не ви пука, но най-често това е вярно само за проекти за играчки. За повечето приложения от реалния свят данните са критични и трябва да бъдат защитени по подходящ начин. Ако не го направите, това може да доведе до пагубни последици за бизнеса.

Но какво общо има това с валидирането? Валидирането на кладенци е един от основните начини за заобикаляне на атаки чрез инжектиране. Всички данни, предоставени от клиента/потребителя, ТРЯБВА да бъдат валидирани, филтрирани и правилно дезинфекцирани от приложенията.

Чрез валидиране на входящите данни можете да гарантирате, че:

  • Данните имат очакваната форма
  • Данните имат очакваните типове/формати
  • Данните са валидни от бизнес гледна точка
  • Данните са валидни за предназначението

По този начин, ако нападател се опита да манипулира входове, така че да замърси данните ви или да проникне в системите ви, тогава ще поне опитате да се борите срещу него.

Само валидирането за съжаление не е достатъчно, но ще се съсредоточим единствено върху него в тази статия. Горещо ви препоръчвам да прочетете пълния доклад тук, за да научите повече за това как да се предпазите от най-разпространените заплахи за сигурността на приложенията (дори ако има много повече тези критични).

Между другото, OWASP също така поддържа „хубава измамна таблица за валидиране на въвеждане“, която трябва да разгледате.

В следващите раздели ще обясня какво може да предложи NestJS по отношение на валидирането и ще се потопим в пример заедно, демонстрирайки как да конфигурирате поддръжка за валидиране и как да наложите валидации.

Поддръжка на NestJS за валидиране

Като рамка, NestJS върши страхотна работа, като ни предоставя много инструменти, звънци и свирки извън кутията. Два от тези инструменти са поддръжката на „Pipes“ и „Validation“.

Както вероятно знаете, NestJS е силно вдъхновен от Angular, така че не е чудно, че има поддръжка за тръби като Angular.

Тръбите NestJS имат два основни случая на използване:

  • трансформация на входните данни
  • валидиране на входните данни

Когато се използват за валидиране, каналите или пропускат данните непроменени, или хвърлят изключение, ако данните са неправилни/невалидни. Но, разбира се, също така е възможно да комбинирате валидиране и трансформация в една тръба.

Извън кутията NestJS включва редица канали; някои от които са посветени на валидиране:

  • ValidationPipe (който ще разгледаме в тази статия)
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe

Има множество начини за обвързване на канали към методи за манипулиране на маршрути (отново, тук се фокусираме върху RESTful API, но по-късно ще спомена други типове API).

Ето първия, взет от официалната документация:

@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

В примера по-горе вграденият ParseIntPipe е свързан с метода за обработка на маршрута findOne. Преди методът да бъде извикан, каналът ще бъде извикан, за да провери дали параметърът id наистина е цяло число. Лесно като пай, нали?

Хубавото на каналите за валидиране на NestJS е, че когато методът се извика, тогава можете да считате, че данните, които сте получили, са структурно валидни (форма и форма); в противен случай би било хвърлено изключение (и автоматично преобразувано в HTTP 4xx грешка за клиента). Това, разбира се, не е достатъчно за валидиране на въвеждане, но вече минава дълъг път.

Ако въведените данни не успеят да преминат проверките за валидиране, тогава клиентът ще получи грешка като тази:

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

За мен това е достатъчно добра настройка по подразбиране за клиентски грешки, дори ако предпочитам структурата, предложена от RFC 7807 (подробности за проблема за HTTP API), но това е друга тема.

Това означава, че вашите RESTful методи за обработка на маршрути могат да спазват Принципа на единичната отговорност (SRP) и да се концентрират върху препращането на заявките към някои други места в системата, където те могат да бъдат допълнително валидирани/обработени/обработени.

Обърнете внимание, че също така е възможно да създадете и използвате конкретен екземпляр на канал NestJS:

@Get(':id')
async findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
  id: number,
) {
  return this.catsService.findOne(id);
}

Тук потребителски екземпляр на ParseIntPipe със специфична конфигурация беше свързан с параметъра injectedid. Това е интересно, защото означава, че можете да разчитате на поведението по подразбиране, но и да го персонализирате, когато е необходимо.

Разбира се, можете да създадете свои собствени канали, за да извършите валидиране, което желаете; ще разгледаме това малко по-късно в тази статия.

Друг начин за дефиниране на тръби е да добавите декоратора UsePipes():

@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
} 

С горното, JoiValidationPipe (което не е вграден канал) ще потвърди входните данни спрямо предоставената Joi схема (повече за това в следващия раздел).

Поддържани типове валидиране

NestJS поддържа различни средства за „проверка на данни“. В тази статия ще обсъдя само решението с помощта на „клас-валидатор“, тъй като то покрива идеално нуждите ми, но все пак ще спомена и другите за пълнота.

Базирано на схема валидиране с Joi

Първият подход, обсъден в документацията на Pipes, използва валидиране, базирано на схема, например използване на библиотеката Joi:

Горното е персонализиран NestJS канал. Както можете да видите, много е лесно да се създаде такъв: това е просто клас, изпълняващ PipeTransform и неговия transform метод, който получава необработената стойност, изпратена до сървъра, както и метаданни.

В този случай персонализираният канал използва Joi, за да потвърди, че входните данни спазват конфигурираната Joi схема. Всеки екземпляр на този канал очаква схема да бъде предадена в конструктора.

Ето как схемата се предава от методите на манипулатора на маршрута:

@UsePipes(new JoiValidationPipe(createCatSchema))

В текущия си проект избрах да не използвам този подход, защото, за съжаление, не можах да намеря лесен/поддържан начин да поддържам схемите си за валидиране силно съгласувани с интерфейсите ми на TypeScript. Направих намерих библиотеки, които биха могли да помогнат: typesafe-joi и joi-extract-type, но и двете бяха или неподдържани/изоставени, или съвместими само с наследени версии на Joi, така че изглеждаше като рисковано залагане.

Базирано на схема валидиране с JSON схема

Друг подход за базирано на схема валидиране е да се използват JSON схеми и да се валидират данни спрямо тези, използващи библиотеки като ajv валидатора на JSON схема.

Това също изисква създаване на персонализирани тръби за валидиране за NestJS, тъй като няма вградена поддръжка.

Отхвърлих тази опция за моя проект поради сложността/режийните разходи; отново свързано с поддържането на модела на данни и схемите за валидиране в синхрон.

Намерих следната библиотека за генериране на JSON схеми от TypeScript код, но не я разгледах подробно: https://github.com/YousefED/typescript-json-schema

Ако се интересувате от този подход, тогава разгледайте следните статии:

клас-валидатор

Последният подход, който е и този, който (IMO) в момента се поддържа най-добре от NestJS, използва валидатор на клас.

class-validator е много популярна библиотека за валидиране на TypeScript, която предоставя много декоратори за валидиране.

За да конфигурирате съвместно валидиране с class-validator, просто трябва да добавите декоратори към полета, които се нуждаят от валидиране. Ето пример, взет от официалните документи:

Както можете да видите, изразяването на правилата за валидиране е доста лесно.

Имайте предвид, че докато клас-валидаторът очаква да използвате класове. Той „всъщност също поддържа валидиране, базирано на схема“, но не бях убеден от зрелостта на този подход. Типът ValidationSchema, осигурен от кутията, е слабо въведен (сбогом рефакторинг!) и има непоправени грешки около него, така че не бих го препоръчал.

В моя проект беше малко разочароващо, защото можех да се справя без класове досега (и се чувствах доста щастлив от това!), но валидирането ме принуди все пак да създам класове. Все пак се чувствах като най-сигурният залог в този момент, като се има предвид ниската зрялост на валидирането на JS/TS (само мое мнение, разбира се!), в сравнение с други езици/платформи (понякога ми липсва валидирането на bean :p).

Освен това този подход има страхотна поддръжка от NestJS, чрез неговия ValidationPipe, който ще разгледаме по-нататък.

Други възможности

В този раздел разгледах различните решения, споменати в документацията на NestJS, но това далеч не е всичко, което можете да направите.

Има безброй опции за проверка/валидиране на тип по време на изпълнение, като например io-ts, за които писах в my TS book, zod, runtypes, vest и много други. Можете да намерите „хубава статия по тази тема тук“.

Като странична бележка, поддържащ NestJS също ми препоръча Marshall като алтернатива на валидатора на класове, тъй като очевидно е много по-производителен и предоставя персонализиран NestJS канал за лесна употреба. Все пак избрах class-validator засега, тъй като се поддържа веднага от кутията и е тестван в битка. Ако NestJS превключи, вероятно ще го последвам. Както и да е, за мен поддръжката и поддръжката имат предимство пред производителността... докато производителността наистина не се превърне в тясно място ;-)

ValidationPipe

Както споменах, NestJS предоставя вграден ValidationPipe. Тази тръба всъщност използва клас-валидатор и клас-трансформатор (две библиотеки, които вървят ръка за ръка) под капака.

Интересното при този вграден канал е, че той обработва валидирането, както и трансформирането на входните данни в екземпляри на клас. По този начин, ако го използвате, тогава вашите манипулатори на заявки ще получат инстанции на клас с правилно валидирани данни.

За да започне проверката, трябва да се обърнете към класовете в аргументите на манипулатора на маршрута. Ако използвате „всеки“ или интерфейс, каналът за валидиране няма да може да помогне! В моя проект това беше досадно, защото ме принуди да въведа класове само в името на валидирането, но знаех, че в крайна сметка ще се възползвам от тези за други цели с NestJS.

ValidationPipe на NestJS може да бъде променен според вашите нужди; можете да го направите повече или по-малко строго, можете да деактивирате или активирате трансформацията на данни и т.н.

За да използвате тази тръба, имате няколко опции. Точно както показах по-рано, можете да го декларирате локално в манипулатор на маршрут (напр. чрез @UsePipes), но можете също да го активирате глобално за вашето приложение. Предимството на декларирането му като глобален канал е, че той винаги ще бъде там (поне за манипулатори на HTTP маршрути); няма нужда да мислите за това, така че това е по-безопасен маршрут (игра на думи). По-малко за мислене = по-малко потенциални грешки.

За да конфигурирате този канал глобално, отворете вашия файл „main.ts“ и добавете следното:

app.useGlobalPipes(new ValidationPipe({ ... }));

Ето как съм конфигурирал тази тръба за текущия си проект:

Както можете да видите, промених малко конфигурацията; Нека обясня:

  • whitelist: true казва на NestJS да премахне валидирания (т.е. върнат) обект от всички свойства, които не използват никакви декоратори за валидиране. Това разбира се означава, че всички полета във вашия клас трябва да бъдат анотирани с декоратори за валидиране на класове; в противен случай полетата ще бъдат премахнати. Добрата новина е, че гарантира, че никога няма да получите неочаквани полета. Това е особено важно, за да се избегне инжектирането на данни безшумно
  • forbidNonWhitelisted: true инструктира NestJS да хвърли изключение, ако има неочаквани полета. Това е по-строго, но още по-ясно. Поне клиентът ще знае дали/кога предоставя невалидни/неочаквани данни
  • forbitUnknownValues неизвестни обекти се отхвърлят незабавно
  • validationError: { value: false } казва на NestJS да не отразява грешката в отговорите за грешка. Това облекчава отговорите и може да ограничи излагането на чувствителни данни в някои случаи
  • transform: true казва на NestJS да върне валидирания екземпляр на класа

С тази глобална конфигурация на място, всички манипулатори на маршрути, които зависят от класове, ще имат валидиране на място; ако приемем, че всички тези класове са коректно анотирани с декоратори на валидатори на класове. Чисто!

Пример за ValidationPipe

Ето конкретен пример с използване на ValidationPipe на NestJS.

Това е администраторът, който трябва да получи валидирани данни. Ето, нека първо се концентрираме върху @Body(). Както можете да видите, ние инжектираме тялото на POST заявката и очакваме то да бъде екземпляр на класа MeetingPartialUpdate.

Ако ValidationPipe е активиран/конфигуриран глобално (вж. последния раздел) и ако MeetingPartialUpdate е клас, анотиран с декоратори за валидиране на класове, тогава ValidationPipe ще обработва валидациите вместо нас. Същото важи и за инжектираните параметри (ще се върна към това по-късно).

Ето как изглежда класът на тялото:

Това е стандартно използване на клас-валидатор, така че нищо специално за споменаване тук, ако вече сте запознати с клас-валидатор. Ако не сте, забележете, че тук използваме доста валидатори, някои от които са маркирани като незадължителни. Ние също използваме поддръжка за вложено валидиране, съвпадение на регулярни изрази и т.н.

Има много повече валидатори, включени в библиотеката, така че е доста мощна. Можете също така лесно да създадете персонализирани валидатори (класове и декоратори), ако е необходимо.

С горното, ValidationPipe ще гарантира, че получените данни отговарят на нашите очаквания (форма и съдържание).

Това е силна първа стъпка напред, добро валидиране на въведените данни. Но можем и по-добре!

Валидиране на параметрите на манипулатора на ресурси

В предишния раздел се съсредоточих върху валидирането на тялото. Но, както споменах, другият входен параметър, който е инжектиран, е също валидиран.

Мисля, че е важно да се спомене, че ValidationPipe валидира всичко, което е базирано на клас (и правилно анотирано). Въз основа на това наистина ви препоръчвам да създадете класове за всички входове (тяло, параметри на заявка, URL параметри и т.н.) и да ги използвате, така че да получите стриктно валидиране на входа за всичко. Имам предвид това и за единични параметри на низ; дори ако това означава въвеждане на клас с едно поле. Очевидно си заслужава.

В предишния пример класът MeetingsControllerInputParams съдържа само две полета, съответстващи на двата URL параметъра:

По този начин URL параметрите също се валидират. Много готино, ако питате мен!

Една крачка напред

Както казах, валидирането е по-сложно от това. Полетата трябва да са валидни (структура/форма), но също така трябва да съвпадат. Например, когато актуализирате ресурс, идентификаторът на ресурса се споменава веднъж в URL адреса, но присъства и в тялото на заявката. И двете стойности трябва да са еднакви, за да се счита заявката за актуализиране за валидна и валидаторът на клас не може да помогне при такива сценарии, тъй като данните са отделени.

Същото важи и за други неща, но е нещо, което трябва да имате предвид: не смятайте, че простото/базово валидиране на въвеждане ще го намали, що се отнася до сигурността. След това, разбира се, има и оптимистична обработка на едновременност, удостоверяване, оторизация и доста допълнителни неща, за които трябва да се погрижите.. ;-)

За да продължа по-нататък с валидирането на входа в моя проект, използвах предпазител, защото отговаряше на изискванията ми, но в зависимост от валидациите, които трябва да извършите, тръба може да е по-адекватна. Чрез този общ предпазител бих могъл да прилагам проверки като:

  • съвпадение между URL параметри и основни стойности
  • наличие на заглавката If-Match и съвпадение със стойностите на тялото
  • и т.н

Имайте предвид, че бизнес услугите, които стоят зад REST контролерите, по-ниско в архитектурата, извършват допълнителни валидации (бизнес мъдри!) и оторизация. Ще пиша за това друг ден.

GraphQL и WebSockets

Както при RESTful API, GraphQL API и „тунелите“ на WebSocket също се нуждаят от подходящо валидиране.

За GraphQL добрата новина е, че очевидно е възможно да се декларира тръбата за валидиране заедно с декоратора @Args, както е споделено тук от John Niomair: https://github.com/nestjs/nest/issues/819#issuecomment-480247274

И накрая, вграденият канал за валидиране трябва да работи по същия начин за шлюзовете на WebSocket, така че трябва да можете да добавите декоратора UsePipes към методите на шлюза и да продължите оттам (не мисля, че работи с глобалните канали) . Въпреки че не съм тествал това сам досега...

Заключение

В тази статия обясних защо проверката на въвеждане е толкова важна за сигурността. След това описах вградената поддръжка за валидиране на NestJS.

Накрая ви запознах с пример и споделих някои мисли за това как правилно да валидирате въведените данни.

Благодарение на цялата настройка, манипулаторите на маршрути на нашите контролери NestJS могат да се съсредоточат единствено върху предаването на команди/заявки и не трябва да се притесняват за валидирането на входа, което е чудесно за разделяне на проблемите.

Още веднъж, това е само върхът на айсберга. Изисква се валидиране на много слоеве, в различни форми и форми. Освен това валидирането е само малка част от общата картина; много други аспекти трябва да бъдат взети предвид, за да се осигури добра позиция на сигурност (напр. удостоверяване, оторизация, бизнес валидации, обработка на грешки, регистриране на одит, наблюдение и т.н.).

Това е за днес!

Хареса ли ви тази статия?

Ако искате да научите много други страхотни неща за софтуер/уеб разработка, TypeScript, Angular, React, Vue, Kotlin, Java, Docker/Kubernetes и други страхотни теми, тогава не се колебайте да вземете копие от моята книга и да се абонирате за моя бюлетин!