Архитектура Node.js предоставляет приложениям прямой низкоуровневый доступ к системным вызовам операционной системы: файловой системе (node:fs), порождению дочерних процессов (node:child_process) и сетевым сокетам (node:net). При компрометации логики обработки данных злоумышленник может перейти от уязвимости в прикладном коде к удаленному выполнению команд (Remote Code Execution, RCE) или утечке конфиденциальной инфраструктуры облака.
Комплексное укрепление безопасности (Hardening) рантайма Node.js требует нейтрализации четырех фундаментальных векторов атак и применения нативной модели разрешений (Permission Model).
Векторы атак на рантайм Node.js и уровни защиты:
1. Prototype Pollution:
Входной JSON: {"__proto__": {"shell": "/bin/bash", "env": {"NODE_OPTIONS": "--require /tmp/evil.js"}}}
│
▼
Загрязнение Object.prototype
│
▼
child_process.spawn() читает опции из прототипа ──> Несанкционированное выполнение RCE!
[Защита: Object.create(null), Map, Object.freeze(Object.prototype), валидация ключей]
2. ReDoS (Catastrophic Backtracking):
Входная строка: "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" ──> Regex: /(a+)+$/
│
▼
Движок V8 NFA выполняет 2^N комбинаций перебора
│
▼
Блокировка Event Loop на 100% CPU ──> Полный отказ в обслуживании (Denial of Service)
[Защита: safe-regex, движок Google RE2 (DFA O(N)), таймауты регулярных выражений]
3. SSRF & DNS Rebinding:
Пользовательский URL: http://api.webhook.internal/ ──> Разрешение в 169.254.169.254 (AWS IMDSv2)
│
▼
Сервер выполняет fetch() во внутреннюю сеть ──> Компрометация ключей доступа к облаку!
[Защита: DNS Pre-flight Inspection, валидация частных CIDR подсетей RFC 1918, фиксация IP сокета]В JavaScript наследование построено на прототипах. Все стандартные объекты неявно наследуют свойства от Object.prototype. Если приложение выполняет небезопасное рекурсивное слияние (Deep Merge), клонирование или установку свойств по пути без проверки ключей, атакующий может внедрить свойства в базовый прототип:
// Небезопасный merge
target["__proto__"]["isAdmin"] = true;
// Теперь ЛЮБОЙ объект в приложении имеет свойство isAdmin:
const user = {};
console.log(user.isAdmin); // true!В Node.js последствия Prototype Pollution выходят далеко за рамки манипуляции логикой приложения. При порождении процессов модуль child_process.spawn() читает объект настроек options. Загрязнение свойств shell, NODE_OPTIONS или env в Object.prototype приводит к автоматическому внедрению вредоносных бинарных файлов при любом системном вызове.
Движок регулярных выражений V8 использует алгоритм недетерминированного конечного автомата (NFA) с поиском с возвратом (Backtracking). Если регулярное выражение содержит вложенные квантификаторы (например, (a+)+$ или ([a-zA-Z]+)*$), сопоставление со специально сформированной строкой длиной всего в 30–40 символов требует миллиардов шагов проверки:
Сложность = O(2^n)Поскольку Node.js исполняет JavaScript в однопоточном цикле событий (Event Loop), выполнение такого сопоставления на 100% утилизирует ядро процессора, блокируя обработку всех остальных сетевых запросов сервера.
Информационная безопасность в Node.js — это эшелонированная оборона (Defense in Depth), охватывающая фильтрацию входных структур данных, изоляцию криптографических операций и ограничение привилегий самого системного процесса.
Prototype Pollution ReDoS (Regular Expression Denial of Service) SSRF (Server-Side Request Forgery) DNS Rebinding Timing-Safe Equal Node.js Permission ModelСравнение времени строкового сравнения: === против crypto.timingSafeEqual
1. Стандартное сравнение через === (Уязвимо к Timing Attacks):
Секрет: "a b c d e f g h"
Попытка 1: "x b c d e f g h" ──> Ошибка на символе 0! (Время проверки: ~0.001 мкс)
Попытка 2: "a x c d e f g h" ──> Ошибка на символе 1! (Время проверки: ~0.002 мкс)
Попытка 3: "a b x d e f g h" ──> Ошибка на символе 2! (Время проверки: ~0.003 мкс)
Атакующий статистически вычисляет правильный символ по задержке ответа сервера!
2. Криптографическое сравнение crypto.timingSafeEqual (Константное время):
Секрет: [0x61, 0x62, 0x63, 0x64]
Попытка: [0x78, 0x62, 0x63, 0x64]
Операция: Побитовый XOR всех байтов до самого конца без раннего выхода!
Время проверки: Строго одинаковое вне зависимости от количества совпавших байт.Тело запроса сохраняется в виде неизмененного бинарного Buffer до парсинга JSON для корректного вычисления HMAC.
Вычисляется HMAC SHA-256 от буфера и верифицируется через crypto.timingSafeEqual для предотвращения атак по времени.
Парсинг JSON выполняется в объект без прототипа (Object.create(null)) с фильтрацией ключей __proto__ и constructor.
Длина строковых полей ограничивается жестким лимитом; сложные шаблоны валидируются движком линейной сложности RE2.
Бизнес-логика исполняется в среде с активным флагом --permission, блокирующим несанкционированный доступ к файлам.
Почему сравнение секретных HMAC-токенов через оператор === является уязвимостью безопасности и как её устранить?
crypto.timingSafeEqual для исключения утечки секретного ключа через анализ наносекундных сетевых флуктуаций.169.254.169.254), предотвращение DNS Rebinding.RE2, гарантирующий линейное время исполнения $O(N)$ и исключающий ReDoS-атаки на Event Loop.--permission и --allow-fs-read, предотвращающий кражу SSH-ключей, токенов окружения .env и файлов /etc/passwd вредоносными supply-chain пакетами./**
* Черный список опасных ключей прототипа
*/
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
/**
* Проверка, является ли значение примитивным или простым объектом
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/**
* Защищенное глубокое слияние объектов
*/
export function safeDeepMerge<T extends Record<string, any>>(
target: T,
source: Record<string, any>
): T {
// Для словарей используем объекты без прототипа
const output = (target ?? Object.create(null)) as T;
for (const key of Object.keys(source)) {
// 1. Блокировка опасных ключей
if (FORBIDDEN_KEYS.has(key)) {
console.warn(`[Security Alert] Попытка Prototype Pollution через ключ: "${key}"`);
continue;
}
const sourceVal = source[key];
const targetVal = output[key];
if (isPlainObject(sourceVal)) {
if (isPlainObject(targetVal)) {
output[key] = safeDeepMerge(targetVal, sourceVal);
} else {
// Создаем чистый объект без прототипа
output[key] = safeDeepMerge(Object.create(null), sourceVal);
}
} else {
output[key] = sourceVal;
}
}
return output;
}
// Защита глобального рантайма на уровне инициализации сервиса:
// Запрет любых модификаций базового прототипа
Object.freeze(Object.prototype);Для надежной защиты от SSRF недостаточно просто проверить URL строковым методом url.hostname !== 'localhost'. Злоумышленник может использовать IPv6-нотацию (::1), шестнадцатеричные представления (0x7f000001), перенаправления (HTTP 302) или атаку DNS Rebinding.
Правильная архитектура требует предварительного разрешения DNS, проверки IP-адреса по таблице зарезервированных подсетей и установления TCP-сокета напрямую к проверенному IP-адресу:
import dns from "node:dns/promises";
import net from "node:net";
import { request } from "undici";
// Диапазоны приватных и специальных IP-адресов (RFC 1918, RFC 3927, Loopback)
const BLOCKED_IP_PATTERNS = [
/^127\./, // IPv4 Loopback
/^10\./, // Class A Private
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Class B Private
/^192\.168\./, // Class C Private
/^169\.254\./, // Link-Local / Cloud Metadata (AWS, GCP)
/^0\./, // Current Network
/^::1$/, // IPv6 Loopback
/^fc00:/i, // IPv6 Unique Local
/^fe80:/i, // IPv6 Link-Local
];
function isPrivateIp(ip: string): boolean {
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(ip));
}
/**
* Безопасный клиент выполнения внешних запросов
*/
export async function ssrfSafeFetch(targetUrl: string): Promise<string> {
const parsed = new URL(targetUrl);
// 1. Разрешаем только протоколы HTTP и HTTPS
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Недопустимый сетевой протокол: ${parsed.protocol}`);
}
// 2. Выполняем разрешение DNS для проверки IP адреса (Pre-flight DNS check)
const dnsLookupResult = await dns.lookup(parsed.hostname, { all: true });
if (!dnsLookupResult.length) {
throw new Error(`Не удалось разрешить имя хоста: ${parsed.hostname}`);
}
// Проверяем все возвращенные A и AAAA записи
for (const record of dnsLookupResult) {
if (isPrivateIp(record.address)) {
throw new Error(`[SSRF Blocked] Попытка доступа к приватному адресу: ${record.address}`);
}
}
// 3. Выполняем запрос с отключением следования редиректам (maxRedirections: 0)
// Редиректы должны валидироваться повторно вручную!
const response = await request(parsed.href, {
method: "GET",
maxRedirections: 0,
headersTimeout: 5000,
bodyTimeout: 5000,
});
if (response.statusCode >= 300 && response.statusCode < 400) {
const location = response.headers["location"];
throw new Error(`Редирект на ${location} заблокирован политикой безопасности`);
}
return await response.body.text();
}import crypto from "node:crypto";
/**
* Верификация вебхука с защитой от атак по времени (Timing Attacks)
*/
export function verifyWebhookSignature(
rawPayload: Buffer,
signatureHeader: string,
secretKey: string
): boolean {
// Вычисляем эталонный HMAC SHA-256
const hmac = crypto.createHmac("sha256", secretKey);
hmac.update(rawPayload);
const expectedSignatureHex = hmac.digest("hex");
// Преобразуем обе строки в буферы равной длины
const expectedBuffer = Buffer.from(expectedSignatureHex, "utf8");
const receivedBuffer = Buffer.from(signatureHeader, "utf8");
// Если длины буферов не совпадают, сравнение невозможно
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
// Безопасное сравнение за константное время:
// Исключает возможность посимвольного подбора секрета по задержке CPU
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}Начиная с Node.js 20, в рантайм внедрен флаг --permission, позволяющий ограничивать права запущенного процесса на уровне системных вызовов операционной системы без внешних песочниц.
Node.js запускается с флагами --permission, определяющими белый список директорий для чтения/записи.
Сторонний модуль пытается прочитать конфиденциальный файл: fs.readFileSync('/etc/shadow').
Внутренний перехватчик рантайма сверяет запрашиваемый путь с деревом разрешенных путей.
Генерируется немедленная системная ошибка ERR_ACCESS_DENIED, предотвращая утечку данных.
#!/usr/bin/env bash
set -euo pipefail
# Запуск микросервиса Node.js с жестким ограничением прав
node \
--permission \
--allow-fs-read="/app/dist,/app/node_modules,/app/package.json" \
--allow-fs-write="/app/logs,/tmp" \
--disable-proto=throw \
--no-addons \
dist/server.js// Проверка наличия прав перед вызовом файловых операций
if (process.permission && process.permission.has("fs.read", "/app/dist")) {
console.log("[Security] Доступ к чтению директории /app/dist разрешен");
} else {
console.error("[Security] Отсутствуют привилегии на чтение директории");
}Почему простой валидации IP-адреса хоста перед выполнением HTTP-запроса недостаточно для защиты от SSRF при атаке DNS Rebinding?
Верификация JWT токенов библиотеками без явного указания белого списка разрешенных алгоритмов (algorithms: ['RS256']). Если клиент присылает заголовок {"alg": "none"}, уязвимый сервер принимает неподписанный токен. Если клиент меняет RS256 на HS256 и подписывает токен публичным ключом сервера, сервер с общей функцией верификации проверяет HMAC публичным ключом как строкой и принимает поддельный токен администратора.
Проверка токенов сессий, CSRF-токенов или HMAC-подписей через стандартные операторы === или !==. Обычное сравнение строк прерывается на первом несовпадающем символе (Short-Circuit Evaluation). Измеряя задержку ответа с субмиллисекундной точностью, злоумышленник может посимвольно восстановить валидную подпись. Необходимо использовать исключительно crypto.timingSafeEqual().
Выполнение запроса с библиотекой, автоматически переходящей по HTTP-редиректам (maxRedirects > 0). Даже если первоначальный адрес https://example.com/image.png прошел валидацию, внешний сервер может вернуть заголовок Location: http://169.254.169.254/latest/meta-data/. HTTP-клиент перейдет по ссылке и отдаст приватные облачные токены атакующему.
Применение даже оптимизированных регулярных выражений к строкам произвольной длины от клиента. Передача строки длиной в несколько мегабайт гарантированно вызовет всплеск потребления памяти и заморозит цикл событий. Перед любым вызовом RegExp необходимо жестко валидировать длину строки (например, не более 256 символов).