
Я часто замечаю, насколько некоторые разработчики халатно относятся к вопросам безопасности своих приложений. И начинают задумываться о методах защиты только тогда, когда уже приходится переписывать большую часть приложения. Сегодня мы пройдемся по классическим и не только методам атаки, посмотрим, где компилятор бессилен, и построим современную защиту, опираясь на лучшие практики и конкретные примеры кода.
В данной статье специально приведены упрощенные методы атак и примеры уязвимостей, чтобы было проще понимать саму механику.
Введение
Безусловно, 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 (да, это возможно)

Многие думают, что 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 верит, что bio = string, но переменная может содержать 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 audit,snyk,socket.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)

EugeneGarbuzov
29.04.2026 20:51Рекомендация использовать eslint-plugin-security указана в общих практиках. Но какие плюсы от его использования в клиентской части (фронтенде)? В описании плагина написано, что это rules for Node security. Как помогут клиенту отслеживание сложности регулярок или запрет доступа к полям объекта через квадратные скобки?
savostin
Так и не понял почему TypeScript виноват?
poznohub Автор
А, да, забыл пояснить, что видел некоторых индивидуумов, которые думают что TypeScript закрывает большую часть уязвимостей из коробочки.
meowpointerexception
В статье не раскрыто, что TypeScript также не защищает от простуды, утечки памяти и плохого настроения.