Разберем вопросы защиты приложений на базе 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 и TypeORM
import { 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 будет не только строгим, но и безопасным.

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

Комментарии (3)


  1. savostin
    29.04.2026 20:51

    Так и не понял почему TypeScript виноват?


    1. poznohub Автор
      29.04.2026 20:51

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


      1. meowpointerexception
        29.04.2026 20:51

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


  1. EugeneGarbuzov
    29.04.2026 20:51

    Рекомендация использовать eslint-plugin-security указана в общих практиках. Но какие плюсы от его использования в клиентской части (фронтенде)? В описании плагина написано, что это rules for Node security. Как помогут клиенту отслеживание сложности регулярок или запрет доступа к полям объекта через квадратные скобки?