Безопасность приложений на Typescript от А до Я: гайд по защите от очевидных и не очень уязвимостей

от автора

Разберем вопросы защиты приложений на базе TypeScript

Разберем вопросы защиты приложений на базе TypeScript

Я часто замечаю, насколько некоторые разработчики халатно относятся к вопросам безопасности своих приложений. И начинают задумываться о методах защиты только тогда, когда уже приходится переписывать большую часть приложения. Сегодня мы пройдемся по классическим и не только методам атаки, посмотрим, где компилятор бессилен, и построим современную защиту, опираясь на лучшие практики и конкретные примеры кода.

В данной статье специально приведены упрощенные методы атак и примеры уязвимостей, чтобы было проще понимать саму механику.

Введение

Безусловно, TypeScript стал одним из лидеров в веб-разработке. На нем пишут мощные React-приложения, сложные микро-сервисы на Nest или fastify. В том числе, разработчики часто ценят безопасность типов, однако, это не классическая безопасность, ведь string в TypeScript — это всё ещё просто строка, которая может содержать и SQL-инъекции и XSS-уязвимости. Компилятор не проверяет бизнес-логику, не фильтрует входные данные и не видит, что вы отдали JWT-секрет в открытый репозиторий.

Эту статью я построил вокруг простого принципа: типы — это не защита, а инструмент дисциплины. Мы рассмотрим атаки и защитные механизмы на двух ключевых платформах:

  • Бэкенд (Node.js, Express/Fastify/NestJS): инъекции, прототипное загрязнение (Prototype Pollution), небезопасная десериализация, утечки данных через ошибки.

  • Фронтенд (React, Next.js, Angular): XSS, CSRF, отравление прототипов через зависимости, утечки чувствительных данных, атаки через SSR.

В каждом разделе я привёл реальные кейсы с кодом, простое объяснение уязвимости и способы устранения. Итак, впереди нас ждет увлекательное путешествие в мир защиты приложений.


Бэкенд: когда запрос приходит до проверки типов

TypeScript на сервере обеспечивает контракты между слоями, но входная точка в виде HTTP-запроса — это всегда сырые данные. Даже если вы используете NestJS с декораторами вроде @Body(), валидация может отсутствовать или быть неполной.

Кейс 1: SQL-инъекция через TypeORM (да, это возможно)

Как работают SQL инъекции

Как работают SQL инъекции

Многие думают, что ORM полностью защищает от инъекций. Но когда разработчик прибегает к сырым запросам или хитрым операторам, TypeScript не спасёт.

Уязвимый код (Базовый кейс с сырыми данными):

// Уязвимый эндпоинт на Express и TypeORMimport { getConnection } from 'typeorm';app.get('/users', async (req, res) => {  const { sortColumn, order } = req.query;    // Ожидаем sortColumn = "name", order = "ASC"  // И здесь уязвимость в небезопасном прямом запросе SQL  const users = await getConnection().query(    `SELECT * FROM users ORDER BY ${sortColumn} ${order}`  );  res.json(users);});

Здесь параметры напрямую подставляются в SQL. Злоумышленник отправляет:

GET /users?sortColumn=name&order=ASC; DROP TABLE users; --

TypeScript видит sortColumn: string, всё хорошо с его точки зрения. Но реляционная база данных получает два запроса.

Решение: валидация разрешённых значений и использование параметризованных запросов или API, не допускающего конкатенации.

import { IsIn, IsString } from 'class-validator';import { validateOrReject } from 'class-validator';class UsersQueryDto {  @IsIn(['name', 'email', 'createdAt'])  sortColumn!: string;  @IsIn(['ASC', 'DESC'])  order!: 'ASC' | 'DESC';}app.get('/users', async (req, res) => {  const dto = new UsersQueryDto();  Object.assign(dto, req.query);  await validateOrReject(dto);  // Дальше можно использовать безопасный метод query builder  const users = await userRepository.find({    order: { [dto.sortColumn]: dto.order },  });});

Так мы гарантируем, что в ORDER BY не попадёт ничего кроме ожидаемых столбцов.

Казалось бы. Илья, что ты несёшь — мы и так пользуемся query builder, это очевидные вещи. Но я видел и такие решения, где разработчик подставлял частично сырые запросы. Например:

app.get('/search', async (req, res) => {  const { q } = req.query;    // Вроде бы используется query builder  const users = await userRepository.find({    where: {      // Но здесь подставляется сырой запрос и создается уязвимость      name: Raw(alias => `${alias} LIKE '%${q}%'`)    }  });  res.json(users);});

И получается, что здесь поисковая строка q напрямую вклеивается в SQL-выражение.

GET /search?q=%25'%3BDROP%20TABLE%20users%3B--

И если же всё таки вам никак не отказаться от Raw-вставок SQL кода — правильным решением будет: использовать параметризованные плейсхолдеры (поддерживаются, например, в TypeORM):

where: {  name: Raw(alias => `${alias} ILIKE :query`, { query: `%${q}%` })}

Другой похожий опасный паттерн: строить запрос через createQueryBuilder, склеивая строки для условий или сортировки.

app.get('/users', async (req, res) => {  const { filter } = req.query; // filter = "admin'; DROP TABLE users; --"  const qb = userRepository.createQueryBuilder('user');  if (filter) {    qb.where(`user.role = '${filter}'`);  }  const users = await qb.getMany();  res.json(users);});

При интерполяции строки внутри .where() открываются такие же возможности для инъекций, как и прямой SQL. Злоумышленник получает полный контроль над запросом.

Безопасная альтернатива: использовать параметры QueryBuilder:

if (filter) {  qb.where('user.role = :role', { role: filter });}

Ключевой урок: любая конкатенация строк при формировании SQL, подозрительна, даже если она спрятана за ORM-методами.


Кейс 2: NoSQL-инъекция в MongoDB с Mongoose

Даже при использовании ODM можно словить инъекцию, если передавать объекты из запроса напрямую.

// Уязвимый кодapp.post('/login', async (req, res) => {  const { username, password } = req.body;  // req.body может содержать: { username: { $ne: null }, password: { $ne: null } }  const user = await UserModel.findOne({ username, password }).exec();  if (user) {    res.json({ token: generateToken(user) });  } else {    res.status(401).send();  }});

Если клиент отправит JSON с операторами MongoDB ($gt$ne), то запрос превратится в { username: { $ne: null }, password: { $ne: null } } и вернёт первого попавшегося пользователя.

Решение: явная типизация и нормализация входных данных с помощью библиотек вроде mongo-sanitize или ручная проверка:

function sanitizeInput(obj: Record<string, unknown>): Record<string, string> {  const clean: Record<string, string> = {};  for (const [key, value] of Object.entries(obj)) {    if (typeof value !== 'string') {      throw new Error('Invalid input type');    }    clean[key] = value;  }  return clean;}

Но лучше использовать проверенные валидаторы, например Zod или class-validator, чтобы на уровне DTO запрещать объекты с подозрительными свойствами.


Повышаем планку. Кейс 3: Прототипное загрязнение (Prototype Pollution)

Пример прототипного загрязнения

Пример прототипного загрязнения

В Node.js объекты наследуют от Object.prototype, и изменение этого прототипа может привести к катастрофическим последствиям: от изменения логики до удалённого выполнения кода.

Пример такого кода — функция глубокого слияния:

// Наша опасная функцияfunction deepMerge(target: any, source: any) {  for (const key in source) {    if (typeof source[key] === 'object' && source[key] !== null) {      if (!target[key]) target[key] = {};      deepMerge(target[key], source[key]);    } else {      target[key] = source[key];    }  }}app.put('/settings', (req, res) => {  const userSettings = JSON.parse(fs.readFileSync('settings.json', 'utf-8'));  // Уязвимость  deepMerge(userSettings, req.body);  fs.writeFileSync('settings.json', JSON.stringify(userSettings));  res.send('ok');});

И если в запросе будет:

{ "__proto__": { "isAdmin": true } }

После такого слияния любой новый объект будет иметь isAdmin === true. Это может обойти проверки авторизации.

Защита: никогда не использовать рекурсивное слияние без проверки свойств. Современные библиотеки (lodash.merge) имеют защиту, но безопаснее будет вообще не использовать их для пользовательских данных. Лучше явно задавать схему:

import { z } from 'zod';const SettingsSchema = z.object({  theme: z.enum(['light', 'dark']),  notifications: z.boolean(),});app.put('/settings', (req, res) => {  const parsed = SettingsSchema.safeParse(req.body);  if (!parsed.success) {    return res.status(400).json({ errors: parsed.error });  }    // Дальнейшая работа только с parsed.data});

Zod автоматически отбросит все неописанные ключи, включая proto и constructor.


Безопасная интеграция JWT и сессий

JWT стал стандартом индустрии, но зачастую, его неправильное использование приводит к краже токенов и повышению привилегий.

Кейс 4: Отсутствие проверки алгоритма

Рассмотрим уязвимый код:

import jwt from 'jsonwebtoken';app.get('/profile', (req, res) => {  const token = req.headers.authorization?.split(' ')[1];  if (!token) return res.status(401).send();  const decoded = jwt.verify(token, config.publicKey);  // Атака: злоумышленник подписывает токен алгоритмом "none" или HS256 с публичным ключом});

Если в библиотеке не зафиксирован допустимый алгоритм, можно использовать алгоритм none или симметричный алгоритм, зная публичный ключ.

Решение: явно указывать допустимые алгоритмы.

const decoded = jwt.verify(token, config.publicKey, {  algorithms: ['RS256'], // или ['ES256']});

В дополнение — никогда не использовать jwt.decode() для проверки. Только verify.


Кейс 5: Секреты в коде и конфигах

Случайно закоммитить .env файл с JWT_SECRET=super-secret в репозиторий — классика. TypeScript не сканирует содержимое строк. Используйте:

  • process.env и инструменты вроде dotenv-vault.

  • Валидацию конфигурации при старте через тот же Zod.

Проверка конфигураций через Zod:

const envSchema = z.object({  JWT_SECRET: z.string().min(32),  DB_URL: z.string().url(),});const env = envSchema.parse(process.env);

При нехватке/неправильной переменной приложение упадёт при запуске с ясной ошибкой.


Защита от SSTI (Server-Side Template Injection) в шаблонизаторах

Если вы отдаете рендеринг HTML на сервер (Nunjucks, EJS, Pug), неосторожная передача пользовательского ввода в шаблон может привести к выполнению кода.

Пример уязвимости:

app.get('/hello', (req, res) => {  const name = req.query.name;  res.render('hello', { name });});// Шаблон EJS: <h1>Hi <%= name %></h1>

Хотя <%= %> экранирует HTML, в некоторых движках можно внедрить исполняемый код через параметры шаблонизатора (как в случае с { constructor: ... }). Лучшая защита: никогда не передавать сырой ввод в шаблон без контекстной обработки и не включать продвинутые функции шаблонизатора (например, eval).

Если вы используете Next.js или React для SSR, аналогичная атака может проявляться через dangerouslySetInnerHTML:

function Profile({ bio }: { bio: string }) {  return <div dangerouslySetInnerHTML={{ __html: bio }} />;}

Здесь TypeScript верит, что biostring, но переменная может содержать XSS.

Очевидное правило, которое видно даже из названия метода: никогда не использовать dangerouslySetInnerHTML с непроверенным пользовательским вводом, а если необходимо, применять DOMPurify.


Фронтенд: безопасность в браузере

На клиенте TypeScript даёт ложное чувство безопасности. Давайте рассмотрим основные векторы атак, где типы не помогут.

Кейс 6: XSS через вставку HTML

Как показано выше, передача неэкранированного текста в innerHTML или в JSX-атрибут dangerouslySetInnerHTML: прямой путь к XSS. Но есть менее очевидные места.

Небезопасный код в React:

function Comment({ text }: { text: string }) {  return (    <a href={`https://example.com/?q=${text}`}>      Search    </a>  );}// Если text = "javascript:alert(1)"

Браузер выполнит JavaScript при клике. TypeScript не знает о контексте использования строки.

Защита: валидация URL и использование encodeURIComponent. Также не помешает Content Security Policy (CSP) со строгими директивами.


Кейс 7: Утечка конфиденциальных данных в сборку

Часто переменные окружения (API-ключи, внутренние URL) утекают в клиентский бандл, потому что разработчик использовал process.env.NEXT_PUBLIC_* или забыл о серверной / клиентской границе. TypeScript не различает, где будет выполняться код.

Защита: Чётко разделять env-переменные. В Next.js, например, только переменные с префиксом NEXT_PUBLIC_ доступны на клиенте. Всё остальное должно читаться только на сервере (getServerSideProps / API Routes).


Кейс 8: CSRF при мутациях

Если ваши куки передаются автоматически, а API принимает POST-запросы без дополнительной проверки, злоумышленник может заставить пользователя отправить нежелательный запрос.

TypeScript не добавит автоматически CSRF-токен. Нужно реализовывать либо синхронный токен, либо SameSite Cookie + проверку Origin/Referer.

Пример простой проверки в Next.js роутах API:

import { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';const allowedOrigins = ['https://myapp.com'];export function middleware(req: NextRequest) {  const origin = req.headers.get('origin');  if (req.method !== 'GET' && (!origin || !allowedOrigins.includes(origin))) {    return new NextResponse(null, { status: 403 });  }  return NextResponse.next();}

Зависимости и supply chain

TypeScript-проекты тянут сотни пакетов. Каждая зависимость может стать точкой входа. Типизация не защищает от вредоносного кода в postinstall-скриптах или обфусцированном пакете.

Конкретный инцидент: event-stream

В 2018 году популярный npm-пакет event-stream был скомпрометирован: в него добавили вредоносный код, который воровал криптовалютные ключи из другого пакета. TypeScript здесь был бессилен: зловред может лежать глубоко в зависимостях и не содержать типов вовсе.

Защитные меры:

  • Использовать npm auditsnyksocket.dev.

  • Проверять лицензии и репутацию пакета.

  • Минимизировать количество зависимостей.

  • В CI/CD добавить этап проверки на известные уязвимости.


Типы как элемент защитной инфраструктуры

Несмотря на всё вышесказанное, TypeScript может существенно усилить безопасность, если его использовать осознанно:

  • Типизированные DTO и строгие интерфейсы. Применять не просто any, а точные типы, перечисления, discriminated unions. Это исключает множество ошибок валидации ещё на этапе написания кода.

  • Branded types (номинальная типизация). Например, мы можем создать тип SafeHtml, который можно получить только через функцию очистки.

  • Exhaustive switch и защита от неполноты. Гарантирует обработку всех возможных состояний (например, при разборе статусов аутентификации).

Пример защищенного типа SafeHtml:

type SafeHtml = string & { readonly __brand: unique symbol };function sanitizeHtml(input: string): SafeHtml {  return DOMPurify.sanitize(input) as SafeHtml;}function render(html: SafeHtml) {  document.getElementById('app')!.innerHTML = html;}

Повышаем уровень. Пять неочевидных современных атак на TypeScript приложения

Теперь переходим к угрозам, которые редко попадают в базовые гайды, но всё чаще встречаются в реальных проектах. Все примеры ориентированы на TypeScript-стек.

Dependency Confusion через типизированные пакеты

Злоумышленник публикует пакет с внутренним именем в публичном npm, но с более высокой версией. TypeScript-проекты особенно уязвимы из-за привычки использовать @types/* или корпоративные нейминги.

Пример: ваша компания использует внутренний пакет @mycompany/auth, который лежит в приватном реестре. Атакующий публикует @mycompany/auth в npm с версией 99.0.0 и вредоносным кодом в postinstall. Если в .npmrc не прописан строгий scope-реестр, npm install подтянет публичную версию.

// Код из вредоносного пакета (index.d.ts и index.js)export function login(login: string, password: string): boolean;// В JS: process.env.JWT_SECRET отправляется на сервер злоумышленника

Защита:

  • Настройте .npmrc с привязкой scope к приватному реестру.

  • Используйте npm install --prefer-offline и блокируйте запросы к публичному реестру для внутренних имён на уровне сети.

  • В CI пайплайне проверяйте целостность пакетов через npm audit --audit-level=high и сравнивайте хеши.


Timing-атака на сравнение строк (JWT, API-ключей)

Классическая ошибка: проверять токены или ключи через ===. В Node.js сравнение строк идёт побайтово и занимает разное время. Злоумышленник может измерить отклик и подобрать токен посимвольно.

Пример уязвимого кода:

const expectedApiKey = process.env.API_KEY!;app.post('/webhook', (req, res) => {  const apiKey = req.headers['x-api-key'] as string;  if (apiKey !== expectedApiKey) {  // уязвимость    return res.status(403).send('Forbidden');  }  // обработка});

При неравных длинах сравнение обрывается мгновенно, а при правильном первом символе — чуть дольше. Повторяя запросы с разными значениями, можно восстановить ключ.

Защита: используйте crypto.timingSafeEqual для сравнения секретов.

import { timingSafeEqual } from 'crypto';function constantTimeCompare(a: string, b: string): boolean {  const bufA = Buffer.from(a);  const bufB = Buffer.from(b);  return timingSafeEqual(bufA, bufB);}

И обязательно нормализуйте длину, чтобы пауза не выдавала длину ключа.


GraphQL: Introspection Abuse и инъекции в аргументы

На бэкенде с Apollo Server (TypeScript) часто оставляют включённой интроспекцию в production. Это позволяет злоумышленнику получить полную схему и найти секретные мутации или поля, доступные только админам. Ещё опаснее становится с инъекцией через невалидированные аргументы.

Уязвимость в резолвере:

const resolvers = {  Query: {    user: (_: unknown, args: { id: string }) => {      // Аргумент id не проверяется на соответствие ожидаемому формату      return db.raw(`SELECT * FROM users WHERE id = '${args.id}'`);    }  }};

Шаги к защите:

  • Запретите интроспекцию в production.

  • Валидируйте аргументы через Zod или graphql-scalars.

Пример запрета интроспекции в конфигах:

const server = new ApolloServer({  typeDefs,  resolvers,  introspection: process.env.NODE_ENV !== 'production',});

Пример валидации кода:

mport { z } from 'zod';const userIdSchema = z.string().uuid();user: (_: unknown, args: { id: string }) => {  const id = userIdSchema.parse(args.id);  return db.query('SELECT * FROM users WHERE id = $1', [id]);}

SSRF через URL-парсинг в Node.js

Многие приложения принимают URL от пользователя (например, для импорта аватара). Злоумышленники обходят проверки с помощью Unicode-трюков или редиректов.

Пример уязвимого кода:

app.post('/import', async (req, res) => {  const { url } = req.body as { url: string };  const parsedUrl = new URL(url);  if (parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1') {    return res.status(400).send('Invalid URL');  }  const response = await fetch(url);  // ...});

Обход проверки хоста: http://127.0.0.1:80@evil.com (часть до @ считается учётными данными, в итоге hostname = evil.com, а запрос уходит на 127.0.0.1). Другой пример: http://0x7f.0.0.1/ (HEX-нотация IP).

Защита:

  • Не парсить URL самостоятельно. Используйте библиотеку вроде is-ip или проверяйте финальный IP после разрешения DNS.

  • Ограничьте схему только http и https. Запретите raw IP.

import { promises as dns } from 'dns';async function resolveIp(url: string): Promise<string> {  const hostname = new URL(url).hostname;  const addresses = await dns.resolve4(hostname);  return addresses[0]; // упрощённо}// Затем проверяйте на вхождение в приватные диапазоны // (10/8, 172.16/12, 192.168/16, 127/8)

RCE через небезопасную десериализацию в TypeScript

Некоторые библиотеки для удобства позволяют сериализовывать функции или выполнять eval при десериализации. Например, serialize-javascript (используется в Next.js) безопасен, но пакеты вроде node-serialize, cookie-serialize позволяют воспроизвести RCE.

Пример уязвимого кода:

import * as serialize from 'node-serialize';app.get('/state', (req, res) => {  const state = serialize.unserialize(req.cookies.state);  // state может содерать объекты с кодом});

Пример атаки: куки state с сериализованным объектом, где поле rce: "_$$ND_FUNC$$_function(){ require('child_process').exec('rm -rf /') }".

Защита: никогда не использовать десериализацию, которая может восстанавливать функции. Используйте только JSON. Например:

const state = JSON.parse(req.cookies.state || '{}');

Если нужны сложные типы, применяйте zod для валидации после JSON.parse, но не запускайте код. Любой импорт библиотек с расширенной сериализацией должен быть под запретом.


Практический чек-лист безопасности TypeScript-проекта

Для бэкенда:

  • Валидация всех входящих данных через Zod / class-validator / io-ts. Никаких any и as.

  • Параметризованные запросы к БД, никакой конкатенации строк (даже внутри Raw() и методах QueryBuilder).

  • Чистим объекты от proto и constructor (или используйте безопасные map/reduce).

  • Фиксированные алгоритмы JWT, короткое время жизни токенов, рефреш-токены с ротацией.

  • Безопасные настройки CORS (не * с credentials).

  • Логирование без утечки токенов/паролей.

  • Helmet-подобные middleware.

Для фронтенда:

  • Никакого dangerouslySetInnerHTML без DOMPurify.

  • CSP-заголовки, запрещающие inline-скрипты.

  • Правильное использование encodeURIComponent и валидация URL.

  • Разделение чувствительных env-переменных: в клиентский код попадает только то, что действительно нужно.

  • Защита от CSRF: SameSite=Strict/Lax, проверка Origin, токены для state-changing запросов.

  • Регулярное и очень внимательное обновление зависимостей.

Общие практики:

  • Линтер с правилами безопасности (eslint-plugin-security).

  • Статический анализ с тайпингами, но без фанатизма; помните, что any-кастование ломает защиту.

  • Runtime-проверки типов (ts-runtime, type guards) для данных с сервера, ведь ответ API тоже может быть не тем, что вы описали в интерфейсе.


Заключение

TypeScript, действительно мощный помощник, но не телохранитель. Строгая типизация снижает количество багов, делает код более предсказуемым, но не отменяет классические уязвимости веба. Сегодня мы разобрали реальные примеры, в которых компилятор абсолютно слеп к опасности: от подстановки в SQL (даже через высокоуровневые операторы TypeORM) до прототипного загрязнения, timing-атак и десериализации.

В дополнение, последние пять кейсов демонстрируют, что атаки мимикрируют под современные технологии, и защита должна эволюционировать.

Главный вывод: воспринимайте типы как фундамент, на котором вы строите многоуровневую систему безопасности. Валидируйте всё на границах доверия, никогда не доверяйте клиенту, и помните, что any это не тип, а дыра в защите.

Безопасность процесс, а не финальное состояние. Пусть ваш TypeScript будет не только строгим, но и безопасным.

Спасибо за прочтение. Какие еще виды уязвимостей вы хотели бы рассмотреть, возможно более глубоко и с неочевидных точек зрения?

ссылка на оригинал статьи https://habr.com/ru/articles/1029598/