Я часто замечаю, насколько некоторые разработчики халатно относятся к вопросам безопасности своих приложений. И начинают задумываться о методах защиты только тогда, когда уже приходится переписывать большую часть приложения. Сегодня мы пройдемся по классическим и не только методам атаки, посмотрим, где компилятор бессилен, и построим современную защиту, опираясь на лучшие практики и конкретные примеры кода.
В данной статье специально приведены упрощенные методы атак и примеры уязвимостей, чтобы было проще понимать саму механику.
Введение
Безусловно, 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 и 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 верит, что 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 будет не только строгим, но и безопасным.
Спасибо за прочтение. Какие еще виды уязвимостей вы хотели бы рассмотреть, возможно более глубоко и с неочевидных точек зрения?
ссылка на оригинал статьи https://habr.com/ru/articles/1029598/