Skip to main content

Як захистити додаток Express.js?

Безпека Express.js - це набір middleware-шарів, які захищають API від поширених веб-атак. Кожен шар зупиняє окремий клас загроз, і всі вони потрібні разом.

Теорія

TL;DR

  • helmet автоматично встановлює 11 HTTP-заголовків безпеки, включно з CSP, HSTS і X-Frame-Options
  • Rate limiting блокує brute-force на маршрутах авторизації (5 спроб за 15 хвилин - стандартний поріг)
  • Санітизація (sanitization) вводу видаляє оператори Mongo ($where, $gt) і HTML-теги до того, як вони дістануться твоєї логіки
  • Секрети зберігаються в змінних середовища, ніколи в коді
  • Порядок middleware має значення: шари безпеки підключаються до маршрутів

Базове налаштування

js
const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const mongoSanitize = require('express-mongo-sanitize'); const xss = require('xss-clean'); const hpp = require('hpp'); // Middleware безпеки запускається до будь-яких маршрутів app.use(helmet()); app.use(mongoSanitize()); // видаляє оператори MongoDB-ін'єкцій app.use(xss()); // прибирає HTML/script-теги з тіла запиту app.use(hpp()); // запобігає дублюванню query-параметрів app.use(express.json({ limit: '10kb' })); // обмежує розмір тіла запиту

Один виклик helmet() замінює ручне встановлення 11 заголовків. Решта покривають ін'єкцію, XSS, забруднення параметрів і атаки через розмір payload.

HTTP-заголовки через Helmet

Звичайний Express-додаток відправляє X-Powered-By: Express у кожній відповіді. Це відразу говорить сканеру, який стек ти використовуєш. helmet() прибирає цей заголовок і додає ті, що браузери реально застосовують.

Що helmet() встановлює за замовчуванням:

  • Content-Security-Policy - контролює, звідки можна завантажувати скрипти та ресурси
  • X-Frame-Options: DENY - блокує clickjacking через iframe
  • Strict-Transport-Security - каже браузерам використовувати тільки HTTPS наступний рік
  • X-Content-Type-Options: nosniff - запобігає атакам через підбір MIME-типу

Для більшості додатків ручне налаштування не потрібне. app.use(helmet()) - достатньо для старту.

Rate limiting

Будь-який ендпоінт, що приймає облікові дані, без rate limiting є мішенню для brute-force. express-rate-limit відстежує запити за IP і блокує після досягнення порогу.

js
const rateLimit = require('express-rate-limit'); // Загальне обмеження для API const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // вікно 15 хвилин max: 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Занадто багато запитів, спробуй пізніше' } }); // Суворіше обмеження для маршрутів авторизації const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, // 5 спроб за 15 хвилин skipSuccessfulRequests: true, // зараховуються тільки невдалі спроби message: { error: 'Занадто багато спроб входу' } }); app.use('/api/', apiLimiter); app.use('/auth/login', authLimiter); app.use('/auth/register', authLimiter);

skipSuccessfulRequests: true на маршрутах авторизації означає, що реальний користувач, який кілька разів входить успішно, не заблокується. До ліміту потрапляють лише невдалі спроби.

Санітизація вводу і розмір payload

Два окремих вектори атаки надходять через тіло запиту. NoSQL-ін'єкція і XSS обидва експлуатують несанітизований ввід. Занадто великі payload можуть вичерпати пам'ять і покласти сервер.

js
app.use(mongoSanitize()); // видаляє $where, $gt, $ne та подібні оператори app.use(xss()); // прибирає script/HTML-теги з полів вводу app.use(express.json({ limit: '10kb' })); app.use(express.urlencoded({ limit: '10kb', extended: true }));

10kb - розумний за замовчуванням для більшості JSON API. Завантаження файлів обробляються окремим middleware на кшталт multer з власними обмеженнями розміру.

Примусовий HTTPS

У продакшені весь трафік має бути зашифрований. Якщо Express стоїть за nginx або балансувальником навантаження, TLS-термінація зазвичай відбувається на рівні проксі. Якщо Express відкритий напряму, додай редирект:

js
app.use((req, res, next) => { if (process.env.NODE_ENV === 'production' && !req.secure) { return res.redirect(301, `https://${req.hostname}${req.url}`); } next(); });

helmet() додатково встановлює Strict-Transport-Security, що каже браузерам пам'ятати про HTTPS навіть якщо користувач вводить http:// вручну.

Секрети в змінних середовища

js
// Це потрапить в git-історію. Хтось знайде. const JWT_SECRET = 'hardcoded-secret-123'; // Це залишається на сервері. const JWT_SECRET = process.env.JWT_SECRET;

Локально використовуй dotenv. У продакшені змінні середовища вводяться через платформу деплою (Railway, Render, AWS ECS). Додай .env до .gitignore одразу, коли починаєш проєкт, а не після того, як вже щось закомітив.

Запобігання забрудненню параметрів

Маловідома атака: передача дублікатів query-параметрів на кшталт /api/users?sort=name&sort=admin. Express та middleware обробляють дублікати по-різному: одні беруть перше значення, інші - останнє, треті будують масив. Поведінка непередбачувана. Пакет hpp нормалізує це:

js
const hpp = require('hpp'); app.use(hpp()); // /api?sort=name&sort=price → req.query.sort стає 'price'

Типові помилки

Неправильний порядок middleware. Якщо helmet() підключений після маршрутів, він не додасть заголовки до їхніх відповідей. Middleware безпеки завжди йде першим, до будь-яких визначень маршрутів.

Великий ліміт розміру тіла. limit: '100mb' у express.json() - типове копіювання з туторіалу, що передбачав файлові завантаження. JSON-тіло розміром 100mb може вичерпати RAM сервера за секунди. За замовчуванням встановлюй 10kb і збільшуй лише при конкретній потребі.

Відсутність skipSuccessfulRequests на маршрутах входу. Без цього користувач, який 100 разів успішно залогінився, заблокується так само, як і зловмисник. Встановлюй true на ендпоінтах авторизації.

Відсутність перевірки секретів при запуску. Запуск з JWT_SECRET = undefined означає, що jsonwebtoken кине помилку в момент спроби входу, а не при старті. Краще перевіряти одразу:

js
if (!process.env.JWT_SECRET) { console.error('JWT_SECRET не встановлено'); process.exit(1); // падаємо голосно при старті, а не опівночі }

Різні повідомлення про помилку для неправильного email і неправильного пароля. Це дає зловмиснику змогу перебирати, які акаунти існують. Завжди повертай однакове загальне повідомлення для обох випадків.

Чеклист безпеки

ШарПакетЩо зупиняє
HTTP-заголовкиhelmetXSS, clickjacking, MIME-снифінг
Rate limitingexpress-rate-limitBrute-force, credential stuffing
CORScorsНеавторизовані cross-origin запити
NoSQL-ін'єкціяexpress-mongo-sanitizeІн'єкція операторів MongoDB
XSSxss-cleanІн'єкція скриптів через тіло запиту
Розмір payloadexpress.json({ limit })Вичерпання пам'яті
JWTjsonwebtokenПідробка сесії
Хешування паролівbcryptjs (12+ раундів)Витік облікових даних
HTTPSnginx / redirect middlewareПерехоплення трафіку
Секретиenv vars + dotenvВитік credentials у коді
Забруднення параметрівhppНепередбачуваний парсинг запитів

Питання на співбесіді

Q: Чи працює rate limiting за IP за балансувальником навантаження?
A: Ні, за замовчуванням не працює. Якщо весь трафік приходить з одного IP балансувальника, всі користувачі ділять один лічильник. Потрібно встановити trustProxy: true і читати X-Forwarded-For для реального IP клієнта. При кількох інстансах сервера - підключи Redis-сховище, щоб усі інстанси ділили спільний стан rate limit.

Q: Чи достатньо xss-clean для захисту від XSS?
A: Ні. xss-clean санітизує тіло запиту на вході. XSS спрацьовує, коли несанітизовані дані виводяться в HTML на виході. Потрібне також екранування на фронтенді і суворий Content-Security-Policy через helmet. xss-clean - це один шар, не повне рішення.

Q: Коли варто використовувати більше ніж 12 раундів у bcrypt?
A: Тільки якщо сервер витримає CPU-навантаження. Заміряй на реальному залізі. Хеш, що займає 200-300ms на операцію, зазвичай є правильним компромісом. Більше - і затримка при вході стає помітною для користувачів.

Q: Що робити, якщо багато користувачів за одним IP (корпоративний NAT, університет)?
A: Rate limiting тільки за IP заблокує всіх за одним NAT після того, як один порушник досягне ліміту. Комбінуй з блокуванням на рівні акаунта (блокуй акаунт після 5 невдалих спроб незалежно від IP) і додавай CAPTCHA при повторних невдачах.

Q: Навіщо прибирати заголовок X-Powered-By?
A: Сам по собі він мало що захищає. Але він дає зловмиснику сигнал, який звужує вибір CVE та експлоїтів для перевірки. Автоматичні сканери перевіряють мільйони ендпоінтів. Дати їм менше інформації нічого не коштує. helmet() прибирає цей заголовок автоматично.

Приклади

Базовий стек безпеки для нового Express-додатку

js
require('dotenv').config(); const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const mongoSanitize = require('express-mongo-sanitize'); const xss = require('xss-clean'); const hpp = require('hpp'); const app = express(); // Спочатку безпека, потім маршрути app.use(helmet()); app.use(mongoSanitize()); app.use(xss()); app.use(hpp()); app.use(express.json({ limit: '10kb' })); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false }); app.use('/api', apiLimiter); app.get('/api/users', (req, res) => { res.json({ users: [] }); }); app.listen(process.env.PORT || 3000);

Увесь middleware безпеки запускається до будь-якого обробника маршруту. Express обробляє middleware зверху вниз, тому порядок не опціональний.

Маршрут авторизації з суворішим rate limiting і хешуванням паролів

js
const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, skipSuccessfulRequests: true, message: { error: 'Занадто багато спроб входу, спробуй за 15 хвилин' } }); app.post('/auth/login', authLimiter, async (req, res) => { const { email, password } = req.body; const user = await User.findOne({ email }); if (!user) { // Однакове повідомлення і для неправильного email, і для неправильного пароля return res.status(401).json({ error: 'Невірні облікові дані' }); } const match = await bcrypt.compare(password, user.passwordHash); if (!match) return res.status(401).json({ error: 'Невірні облікові дані' }); const token = jwt.sign( { userId: user._id }, process.env.JWT_SECRET, { expiresIn: '15m' } // короткий термін; для довших сесій використовуй refresh tokens ); res.json({ token }); });

Обидва випадки - неправильний email і неправильний пароль - повертають однакову загальну помилку. Це блокує перебір акаунтів, коли зловмисник перевіряє, які email-адреси зареєстровані.

Перевірка секретів при запуску

js
// Перевіряємо всі необхідні секрети при старті, а не в момент запиту const requiredEnv = ['JWT_SECRET', 'DB_PASSWORD', 'SESSION_SECRET']; for (const key of requiredEnv) { if (!process.env[key]) { console.error(`Відсутня обов'язкова змінна середовища: ${key}`); process.exit(1); } } // Примусовий HTTPS у продакшені if (process.env.NODE_ENV === 'production') { app.use((req, res, next) => { if (!req.secure) { return res.redirect(301, `https://${req.hostname}${req.url}`); } next(); }); }

Падати при старті через відсутній секрет краще, ніж виявити проблему, коли користувач намагається увійти опівночі. Я бачив додатки, що тижнями працювали з JWT_SECRET = undefined, бо помилка з'являлась лише в момент підпису токена, а не при завантаженні.

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

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

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

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