Які основні практики безпеки в Node.js?
Практики безпеки в Node.js - набір захисних патернів, які блокують найпоширеніші вектори атак: ін'єкції, prototype pollution (забруднення прототипу), брутфорс та експлойти через залежності.
Теорія
TL;DR
- Node виконує JS на сервері, тому несанітизований вхід може запустити код напряму, а не просто зламати UI
- Валідуй кожен вхід на межі, хешуй паролі через bcrypt (12+ раундів) і запускай
npm auditперед кожним деплоєм helmet()встановлює 7+ security-заголовків одним рядком; без нього XSS, clickjacking і MIME-sniffing відкриті- Prototype pollution через
__proto__мутуєObject.prototypeглобально і залишається до перезапуску процесу - Ніколи не запускай від root, обмежуй розмір тіла запиту і rate-limit кожен публічний ендпоінт
Швидкий приклад
// Express signup з трьома шарами захисту
const helmet = require('helmet');
const { body, validationResult } = require('express-validator');
const rateLimit = require('express-rate-limit');
app.use(helmet()); // CSP, HSTS, X-Frame-Options одним викликом
app.use(express.json({ limit: '10kb' })); // блокує надмірно великі payloads
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.post('/signup', limiter, [
body('email').isEmail().normalizeEmail(),
body('password').isStrongPassword({ minLength: 12, minNumbers: 1 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors });
// "admin' OR 1=1" не проходить isEmail(); брутфорс впирається в rate limit
});Три рядки до обробника маршруту роблять більшу частину роботи. Middleware валідації запускається до будь-якого звернення до бази, тому некоректний вхід ніколи не доходить до рівня запиту.
Чому Node.js вимагає серверної валідації
Браузери мають CSP і sandbox. У Node цього немає. Несанітизований рядок з req.body може потрапити прямо в eval(), exec() або оператор MongoDB. Event loop обробляє кожен запит синхронно, тому один regex з катастрофічним backtracking-ом може заморозити весь процес для всіх одночасних користувачів.
Один запит. Неправильний вхід. Процес зависає.
Ін'єкції: три варіанти
SQL-ін'єкція - використовуй параметризовані запити, ніколи конкатенацію рядків.
// Погано - зловмисник надсилає id = "' OR '1'='1"
const query = `SELECT * FROM users WHERE id = '${req.params.id}'`;
// Добре
const result = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);NoSQL-ін'єкція (MongoDB) - { "password": { "$gt": "" } } обходить перевірку пароля, якщо передати сирий req.body у запит. Приводь до рядка або використовуй схему валідації перед запитом.
// Погано - зловмисник надсилає { "password": { "$gt": "" } }
db.users.find({ username: req.body.username, password: req.body.password });
// Добре - приведення до рядка і хешування
db.users.find({
username: String(req.body.username),
password: await bcrypt.hash(req.body.password, 12)
});Command injection - exec() передає перший аргумент прямо в shell. execFile() з масивом аргументів не викликає shell взагалі.
// Погано - dir = "; cat /etc/passwd" спрацює без помилки
exec(`ls ${req.query.dir}`);
// Добре - без shell, аргументи масиву не інтерполюються
execFile('ls', [req.query.dir], { encoding: 'utf8' });Prototype pollution (забруднення прототипу)
V8 парсить JSON-вхід як звичайні об'єкти. Зловмисник, який надсилає { "__proto__": { "isAdmin": true } }, мутує Object.prototype глобально. Кожен об'єкт у процесі успадкує цю властивість до перезапуску. Саме так працювала CVE-2019-10744 проти lodash нижче 4.17.21 у production-додатках.
// Блокуємо три небезпечні ключі
function safeMerge(target, source) {
if (typeof source !== 'object' || source === null) return target;
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
target[key] = typeof source[key] === 'object'
? safeMerge(target[key] || {}, source[key])
: source[key];
}
return target;
}
// Ще надійніше: Object.create(null) не має ланцюжка прототипів
req.user = safeMerge(Object.create(null), req.body.userData || {});З власного досвіду: prototype pollution - це саме та вразливість, яка найчастіше застає команди зненацька. Мутація глобальна і тиха, жодної помилки немає, тому баг проявляється далеко від місця атаки.
Аутентифікація та зберігання паролів
bcrypt з 12+ раундами солі - це стандарт. MD5 і SHA-1 для паролів не підходять.
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12);
const isValid = await bcrypt.compare(inputPassword, hash);JWT-токени: короткий термін дії (15 хвилин для access-токена), httpOnly cookies замість localStorage, refresh-токени в Redis зі списком відкликання. RS256 замість HS256 дозволяє ротувати signing key без перерозгортання всіх сервісів, бо ключ верифікації публічний.
Безпека залежностей
Запускай npm audit --audit-level=high у CI. Одна застаріла транзитивна залежність може відкрити весь додаток. Snyk знаходить більше, ніж npm audit, особливо в транзитивних залежностях.
npm audit --audit-level=high
npm ci --only=production # dev-залежності не потрапляють у продЗа даними Snyk, 70% звітів про вразливості в Node.js пов'язані з dev-залежностями, які потрапили в production. Встанови production=true в .npmrc або використовуй npm ci --only=production.
Захист від DoS
Rate limiting - перша лінія. Обмеження розміру тіла - друга. ReDoS (backtracking у регулярних виразах) - непомітна третя.
// 5 спроб входу за 15 хвилин з однієї IP
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.use('/login', loginLimiter);
// Ліміт розміру до будь-якого парсингу
app.use(express.json({ limit: '10kb' }));
// Цей regex зависає процес на "aaaaaaaaaaaaaX"
const dangerous = /^(a+)+$/;
// re2 гарантує лінійний час виконання
const RE2 = require('re2');
const safe = new RE2('^[a-z]+$');Змінні середовища та секрети
// Ніколи
const secret = 'hardcoded-jwt-secret';
// Завжди
const secret = process.env.JWT_SECRET;
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET є обов\'язковим');Ніколи не коміть .env у git. Ротуй секрети, коли розробник виходить з команди. Для production використовуй менеджер секретів (AWS Secrets Manager, HashiCorp Vault).
Типові помилки
1. JSON.parse(req.body) без схемної валідації.
Дозволяє { "constructor": { "prototype": { "isAdmin": true } } } пошкодити Object.prototype глобально. Виправлення: валідуй через Joi або express-validator перед роботою з розпарсеним об'єктом.
2. Шаблонні рядки всередині exec().
// dir = "; rm -rf /" спрацює без помилки
exec(`ls ${dir}`);
// Виправлення: без shell, без інтерполяції
execFile('ls', [dir]);3. Запуск від root.
app.listen(3000) від root означає, що при успішному RCE зловмисник отримує root-доступ до машини. Виправлення: sudo -u www-data node app.js або process.setuid('nobody') перед listen.
4. Dev-залежності в production.
npm install у production включає jest, nodemon та всі їхні дерева залежностей. Кожна - потенційна поверхня атаки. Використовуй npm ci --only=production.
5. Відсутній ліміт розміру тіла.
express.json() без limit приймає payloads будь-якого розміру. JSON на 500 МБ парситься синхронно і виснажує пам'ять. Додай { limit: '10kb' } або відповідний розмір для твого випадку.
Де це зустрічається в реальних проектах
- Netflix, PayPal API -
app.use(helmet()); app.use(express.json({ limit: '10kb' }))першим middleware - NestJS (мікросервіси Uber) -
@UseGuards(RateLimitGuard)разом з декораторами class-validator - Hapi (e-commerce Walmart) -
server.auth.strategy('jwt', 'jwt', { key: privateKey }) - Express-сесії (Discord-боти) -
express-session({ store: new RedisStore(), secret: process.env.SESSION_SECRET }) - Fastify (Vercel edge) -
fastify.register(require('@fastify/rate-limit'))
Питання для співбесіди
Q: Як prototype pollution поширюється між модулями в Node.js?
A: Мутує Object.prototype через шлях парсингу JSON. Кожен цикл for...in і виклик Object.keys() у кожному модулі бачить забруднену властивість до перезапуску процесу. Object.freeze(Object.prototype) зупиняє нові мутації, але зазвичай надто агресивний для production.
Q: Яка різниця між bcrypt і scrypt для хешування паролів?
A: bcrypt добре працює на багатоядерному Node (підходить для worker threads); scrypt вимагає більше пам'яті на операцію, що краще захищає від GPU-зламування. Обидва прийнятні. scrypt - новіша рекомендація NIST, якщо починаєш проект з нуля.
Q: Як захистити завантаження файлів в Express?
A: Multer з лімітом fileSize і фільтром MIME-типів: fileFilter: (req, file, cb) => /^image\//.test(file.mimetype) ? cb(null, true) : cb(new Error('Invalid type')). Скануй файли через ClamAV (clamscan) перед переміщенням у постійне сховище.
Q: Як запобігти ReDoS без переписування кожного regex у кодовій базі?
A: Використовуй пакет safe-regex для виявлення вразливих патернів у CI, потім заміни їх бібліотекою re2, яка гарантує лінійний час виконання незалежно від вхідних даних.
Q: Як інтегрувати аудит залежностей у CI/CD?
A: npm audit --audit-level=high завершується з кодом 1 на вразливостях рівня high або critical. У GitHub Actions: npm audit --json | jq '.metadata.vulnerabilities.high > 0' і fail build. Snyk і Sonatype дають глибший аналіз транзитивних залежностей.
Приклади
Базовий: Helmet і rate limiting на публічному API
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
app.use(helmet()); // X-Content-Type-Options, HSTS, CSP, X-Frame-Options та інші
app.use(express.json({ limit: '10kb' }));
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Too many requests' }
});
app.use('/api/', apiLimiter);
// Кожен маршрут /api/* тепер має сім заголовків безпеки і rate limithelmet() без аргументів активує сім заголовків, що покривають найпоширеніші браузерні атаки. Ліміт тіла зупиняє виснаження пам'яті до запуску будь-якого контролера.
Середній: Повна валідація вводу при реєстрації (production-патерн)
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
app.post('/signup', [
body('email').isEmail().normalizeEmail(),
body('password').isStrongPassword({ minLength: 12, minNumbers: 1, minSymbols: 1 }),
body('username').trim().escape().isLength({ min: 3, max: 20 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const hash = await bcrypt.hash(req.body.password, 12);
await db.users.create({
email: req.body.email,
username: req.body.username,
password: hash
});
res.status(201).json({ message: 'Created' });
// "admin' OR 1=1" не пройде isEmail(); "password" не пройде isStrongPassword()
});Ланцюжок валідації запускається до тіла async-функції. Якщо будь-яке поле не проходить, запит завершується на шостому рядку. База даних ніколи не бачить сирий вхід користувача.
Просунутий: Захист від prototype pollution у merge middleware
// Вектор атаки: { "userData": { "__proto__": { "isAdmin": true } } }
// Використовувався проти lodash < 4.17.21 у production (CVE-2019-10744)
function safeMerge(target, source) {
if (typeof source !== 'object' || source === null) return target;
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
target[key] = typeof source[key] === 'object'
? safeMerge(target[key] || {}, source[key])
: source[key];
}
return target;
}
app.use(express.json());
app.use((req, res, next) => {
// Object.create(null) не має прототипу - нічого забруднити
req.user = safeMerge(Object.create(null), req.body.userData || {});
next();
});
// Після спроби атаки:
console.log({}.isAdmin); // undefined - Object.prototype чистий
console.log(req.user.isAdmin); // undefined - заблоковано перевіркою ключаObject.create(null) створює об'єкт без ланцюжка прототипів. Навіть якщо блок-лист пропустить якийсь ключ, успадковувати нема від чого. Разом з цим запускай npm audit fix --force для lodash нижче 4.17.21, щоб закрити базову вразливість залежності.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.