Архітектура середовища виконання 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 перевірка, валідація приватних CIDR підмереж RFC 1918, фіксація IP сокета]У JavaScript успадкування побудоване на прототипах. Усі стандартні екземпляри об'єктів неявно успадковують властивості від Object.prototype. Якщо додаток виконує небезпечне рекурсивне злиття (Deep 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. Стандартне порівняння через === (Вразливе до атак за часом):
Секрет: "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 усіх байтів до самого кінця без раннього виходу!
Час перевірки: Абсолютно однаковий незалежно від кількості співпалих байтів.crypto.timingSafeEqual для виключення витоку секретного ключа через наносекундні мережеві флуктуації.169.254.169.254), запобігання DNS Rebinding.RE2, що гарантує суворо лінійний час виконання $O(N)$ та нейтралізує атаки ReDoS на Event Loop.--permission та --allow-fs-read, що запобігає несанкціонованому читанню SSH-ключів, файлів .env та конфігурацій системи сторонніми пакетами./**
* Чорний список небезпечних ключів прототипу
*/
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
/**
* Перевірка, чи є значення звичайним об'єктом JavaScript
*/
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);Проста перевірка через url.hostname !== 'localhost' не рятує від SSRF. Зловмисник може використати нотацію IPv6 (::1), шістнадцяткові значення (0x7f000001), HTTP-редиректи (302) або атаку DNS Rebinding.
Надійний захист вимагає попереднього розв'язання DNS, перевірки 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 (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] Спроба звернення до приватної IP-адреси: ${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;
}
// Безпечне порівняння за константний час:
// Виключає можливість побайтового підбору секрету за затримкою процесора
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] Недостатньо привілеїв для доступу до файлової системи");
}Верифікація токенів без жорсткого білого списку алгоритмів (algorithms: ['RS256']). Якщо клієнт надсилає {"alg": "none"}, вразлива бібліотека приймає непідписаний токен. Якщо зловмисник змінює RS256 на HS256 і підписує токен відкритим публічним ключем сервера, сервер перевірить HMAC публічним ключем як рядком і надасть підроблені права адміністратора.
Порівняння хешів сесій, CSRF-токенів або підписів через оператори === чи !==. Звичайне порівняння переривається на першому невідповідному символі. Вимірюючи час відповіді сервера із субмілісекундною точністю, зловмисник може побайтово реконструювати коректний токен. Необхідно використовувати виключно crypto.timingSafeEqual().
Виконання вихідних запитів клієнтом із автоматичним слідуванням редиректам (maxRedirects > 0). Навіть якщо початковий домен є безпечним, віддалений сервер може повернути Location: http://169.254.169.254/latest/meta-data/. HTTP-клієнт виконає редирект і віддасть приватні хмарні токени зловмиснику.
Застосування регулярних виразів до рядків довільного розміру від клієнта. Обробка рядка розміром у кілька мегабайтів викличе стрибок пам'яті та заблокує Event Loop. Перед будь-яким викликом RegExp необхідно обов'язково перевіряти максимальну довжину рядка (наприклад, не більше 256 символів).
Тіло запиту зберігається у вигляді незміненого бінарного Buffer до парсингу JSON для точного обчислення HMAC.
Обчислюється HMAC SHA-256 і верифікується через crypto.timingSafeEqual для нейтралізації атак за часом.
Парсинг JSON виконується в об'єкт без прототипу (Object.create(null)) із фільтрацією ключів __proto__ та constructor.
Довжина полів обмежується строгими лімітами; складні шаблони обробляються лінійним двигуном RE2.
Бізнес-логіка виконується в середовищі з прапорцем --permission, що обмежує доступ до системних файлів.