Skip to main content

Які основні практики безпеки в 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 кожен публічний ендпоінт

Швидкий приклад

js
// 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-ін'єкція - використовуй параметризовані запити, ніколи конкатенацію рядків.

js
// Погано - зловмисник надсилає 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 у запит. Приводь до рядка або використовуй схему валідації перед запитом.

js
// Погано - зловмисник надсилає { "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 взагалі.

js
// Погано - 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-додатках.

js
// Блокуємо три небезпечні ключі 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 для паролів не підходять.

js
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, особливо в транзитивних залежностях.

bash
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 у регулярних виразах) - непомітна третя.

js
// 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]+$');

Змінні середовища та секрети

js
// Ніколи 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().

js
// 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

js
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 limit

helmet() без аргументів активує сім заголовків, що покривають найпоширеніші браузерні атаки. Ліміт тіла зупиняє виснаження пам'яті до запуску будь-якого контролера.

Середній: Повна валідація вводу при реєстрації (production-патерн)

js
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

js
// Вектор атаки: { "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, щоб закрити базову вразливість залежності.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?