Безпека API: JWT та OAuth, перевірка даних, обмеження частоти запитів, моніторинг.
Забезпечення безпеки API на практиці: автентифікація та авторизація за допомогою JWT та OAuth, шифрування, перевірка вхідних даних, обмеження частоти запитів та облік подій для виявлення атак.
Джерела: What Are the Best Practices for API Security? A Complete Guide to Securing Modern APIs
Кожен API, який ви розкриваєте, є входом у вашу систему, і зловмисники досліджують ці входи набагато більш систематично, ніж це роблять більшість команд під час тестування. Безпека, яку додають після запуску, часто залишає прогалини: маршрут без захисту, запит, створений на основі необробленого вхідного даних, кінцева точка входу, яка охоче приймає мільйон спроб. У цьому посібнику розглядаються стандарти, які варто знати, найпоширеніші загрози та шість конкретних рівнів захисту, реалізованих у Node.js з Express, щоб ви могли пройти аудит існуючого API або створити новий з захистом вже з першого дня.
Розглядайте наведене нижче як чек-лист, до якого ви будете звертатися протягом усього життєвого циклу розробки: перед випуском, після виправлення помилок та щоразу, коли змінюються залежності чи маршрути. Регулярне виконання цих перевірок дозволяє виявляти вразливості, поки вони ще невеликі.
Чому безпека API потребує окремої уваги
Сучасні продукти все частіше створюються за допомогою API. Замість того, щоб розробляти кожну функцію самостійно, команди інтегрують сервіси оплати, ідентифікації, обміну повідомленнями та обробки даних через чітко визначені інтерфейси, і багато компаній тепер випускають продукти, орієнтовані на API. Одне з досліджень ринку оцінює розмір економіки API у 2026 році приблизно у 20 мільярдів доларів (резюме звіту); якою б не була точна цифра, залежність є реальною та зростає.
Ця залежність є двосторонньою. API надає легітимним клієнтам готові, повторно використовувані функції, але водночас створює для зловмисників документований, зручний для машин вхідний пункт. Дослідження галузі постійно пов’язують значну кількість порушень безпеки з API; звіт Traceable за 2023 рік приписує їм 74% випадків витоку даних.
API часто знаходяться безпосередньо біля найбільш конфіденційних даних, які має компанія: платформи ідентифікації з особистими даними, фінансові записи, внутрішні процеси роботи. Несанкціонований доступ може призвести до пошкодження даних, зловживання послугами, фінансових втрат, втрати довіри клієнтів та штрафів згідно з законодавством про захист даних. Оскільки на кону стоять такі великі ризики, безпека має бути передбачена у угодах про рівень послуг разом із часом їх функціонування, і це є відповідальністю кожної команди, що розробляє продукт, а не лише спеціалізованої групи з безпеки. Розумним початком є використання стандартів, які вже узгоджені в галузі.
Стандарти, які варто знати, від OWASP до OAuth та JWT
Стандарти безпеки API — це формальні специфікації, протоколи та рекомендації, які застосовуються протягом усього життєвого циклу розробки програмного забезпечення, щоб забезпечити послідовний захист, а не імпровізовані заходи. Найпоширеніші з них:
- OWASP API Security Top 10: ранжований список найбільш критичних ризиків API, який підтримується проєктом Open Web Application Security Project. Він охоплює такі проблеми, як несправна авторизація на рівні об’єктів, підробка запитів з боку сервера та необмежене споживання ресурсів, і є найкращою вихідною точкою для аналізу загроз.
- OAuth 2.0 та 2.1: фреймворк делегованої авторизації. Клієнт отримує токен доступу з визначеними обмеженнями та використовує його для дій від імені користувача, не маючи доступу до його пароля; токени оновлення дозволяють клієнту отримувати нові токени доступу без необхідності знову запитувати користувача.
- OpenID Connect (OIDC): шар ідентифікації, створений на основі OAuth. Він стандартизує токен ідентифікації та способи його верифікації клієнтами, що забезпечує сумісність функцій одноразового входу та отримання профілю від постачальників ідентифікації.
Це створює міцну основу, хоча це не вичерпний перелік.
Загрози, від яких ви захищаєтесь
Найпоширеніші вразливості в реальних API є:
- Порушена автентифікація: слабкі або відсутні перевірки ідентичності та недостатнє керування сеансами дозволяють зловмиснику вкрасти куки чи токени та використати їх знову для доступу до ваших сервісів.
- Порушена авторизація на рівні об’єктів (BOLA): API перевіряє, чи у користувача є доступ у системі, але не перевіряє, чи дозволено йому редагувати певний запис, тож зміна ID у URL може викрити дані іншої особи чи внутрішні процеси роботи.
- Вставка SQL: дані, контрольовані зловмисником, додаються до запиту та виконуються базою даних. Майже кожен клієнт бази даних пропонує механізм параметрів, який забезпечує безпечну передачу значень.
- Вставка команд: ненадійні дані з запиту потрапляють до системного шеллу чи процесора команд, що дозволяє зловмиснику виконувати довільні команди з привілеями процесу вашого сервера.
Для кожного з цих аспектів існує відповідна практика програмування. Решта цього посібника розглядає їх у шести рівнях, використовуючи JavaScript та Express протягом усього тексту.
1. Аутентифікація за допомогою паролів та JWT: підтвердження особи користувача
Кожен захищений API повинен вимагати від клієнта підтвердження його ідентичності перед виконанням будь-яких значущих дій, чи то через ім’я користувача та пароль, ключ API чи підписаний токен. Методи аутентифікації зазвичай поділяються на п’ять категорій: аутентифікація за ім’ям користувача та паролем, багатофакторна аутентифікація, аутентифікація на основі токенів, аутентифікація на основі сертифікатів та біометрія.
Поширеною причиною плутанини є те, що насправді замінює JWT. Традиційні серверні сесії зберігали стан на сервері та використовували куки браузера для передачі ідентифікатора сесії. JWT усуває необхідність пошуку на сервері з кожним запитом, але сам по собі не перевіряє облікові дані: хтось все одно має перевірити пароль ще раз перед видачею токена. Саме тому добре підходить гібридний підхід. Користувач увійшов за допомогою електронної пошти та пароля, сервер при успішній автентикації видає JWT, а кожен наступний запит передає лише токен. Перевірка облікових даних та автентифікація з кожним запитом стають окремими завданнями, і пароль більше не передається з кожним викликом.
У Express бібліотека Passport підтримує обидва підходи за допомогою підключуваних стратегій. Налаштування вимагає трьох кроків.
Крок 1: зареєструвати локальну стратегію та стратегію JWT
Локальна стратегія виконується один раз під час входу та відповідає за пошук користувача за електронною поштою та порівняння наданого пароля з збереженим хешем. Стратегія JWT виконується з кожним захищеним запитом: вона витягує токен з заголовка Authorization: Bearer, перевіряє підпис за допомогою JWT_SECRET та визначає користувача, на якого посилається вантаж запиту. Експорт попередньо налаштованого проміжку authenticateJWT із параметром session: false чітко вказує на відсутність стану.
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
// Local Strategy: Verify username and password during login.
passport.use(
new LocalStrategy(
{ usernameField: "email", passwordField: "password" },
async (email, password, done) => {
// Find the user and compare the hashed password.
// If valid, return the user.
}
)
);
// JWT Strategy: Verify the token on protected requests.
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
// Find the user referenced in the token.
}
)
);
// Middleware
const authenticateJWT = passport.authenticate("jwt", { session: false, });
module.exports = { passport, authenticateJWT, };
Функції повернення після перевірки залишаються тут у вигляді коментарів, і саме тут відбувається справжня робота з безпекою. Для порівняння паролів використовуйте повільний алгоритм хеширування з додаванням «солі», такий як bcrypt або Argon2, та повертайте однаковий загальний код помилки у будь-якому випадку — чи електронна пошта невідома, чи пароль неправильний, щоб кінцева точка не могла використовуватися для виявлення наявних облікових записів.
Крок 2: автентифікація під час входу та підписування токена
Обробник входу викликає локальну стратегію через спеціальну функцію повернення. Помилки передаються до механізму обробки помилок Express, відсутній користувач призводить до коду 401, а успішна автентифікація генерує токен, підписаний ідентифікатором та роллю користувача, з терміном дії, взятим з параметра JWT_EXPIRES_IN (за замовчуванням — дві години).
const jwt = require("jsonwebtoken");
const passport = require("passport");
const login = (req, res, next) => {
passport.authenticate("local", { session: false }, (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ message: info.message, });
// Issue a signed JWT after successful authentication.
const token = jwt.sign(
{ id: user.id, role: user.role,},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || "2h",}
);
return res.status(200).json({ message: "Login successful.", token, user,});
})(req, res, next);
};
Два моменти заслуговують уваги. Короткий термін дії обмежує час, протягом якого вкрадений токен залишається корисним; якщо сесії потрібно підтримувати довше, слід поєднувати короткі токени доступу з процедурою оновлення, як описано у нашій стратегії оновлення токенів для систем автентифікації Node.js. Крім того, у відповіді повертається об’єкт user у первісному вигляді. Якщо цей об’єкт є сирою записом з бази даних, він може містити хеш пароля та внутрішні поля, що є саме тим надмірним викриттям даних, про яке йшлося раніше. Натомість слід повертати лише виразну підмножину даних, таку як ID, електронна пошта та роль.
Крок 3: захист захищених маршрутів
Після експорту мідлверу захист маршруту означає розміщення функції authenticateJWT перед контролером у визначенні маршруту. Запити без дійсного токена відхиляються ще до виконання будь-якої бізнес-логіки.
const { Router } = require("express");
const authRouter = Router();
// Get auth middleware and sample prorected controller
const authController = require("../controllers/auth.controller");
const { authenticateJWT } = require("../middleware/authentication");
// Use JWT as a guard to protect certain routes
authRouter.get("/me", authenticateJWT, authController.me);
authRouter.patch("/password", authenticateJWT, authController.updatePassword);
Якщо ви віддаєте перевагу легким роутерам, цей захист можна включити до власної ланцюга мідлверу контролера. У будь-якому разі нехай захист є стандартним для роутера, а публічні маршрути слід навмисно виключити з нього, замість того щоб постійно пам’ятати додавати захист до кожного маршруту окремо.
2. Авторизація та діапазони OAuth: визначення того, що може робити користувач
Аутентифікація відповідає на запитання „Хто ви?“; авторизація — на запитання „Що вам дозволено робити?“. Вона зазвичай виконується одразу після аутентифікації та перевіряє кожну ідентичність за правилами доступу перед тим, як схвалити чи відхилити запит. Без неї будь-який увійшовший користувач може читати конфіденційні дані чи виконувати дії з привілеями, саме так і виникають вразливості типу BOLA.
Три моделі покривають більшість потреб:
- Контроль доступу на основі ролей (RBAC) надає права ролям, а ролі — користувачам. API для блогінгу може мати ролі адміністратора, редактора та переглядача. Він підходить для стабільних функцій та груп, тому часто використовується в корпоративних додатках.
- Контроль доступу на основі атрибутів (ABAC) оцінює атрибути користувача, ресурсу та середовища запиту (відділ, рівень конфіденційності ресурсу, час доби, мережа) з урахуванням правил. Він підходить для API, чиї рішення сильно залежать від контексту або часто змінюються.
- Контроль доступу на основі взаємозв’язків (ReBAC) надає доступ на основі взаємозв’язку між користувачем та певним ресурсом, таким як власність або приналежність до групи, що зазвичай перевіряється шляхом аналізу графа взаємозв’язків. Він ідеально підходить для спільних продуктів, таких як обмін документами чи соціальні платформи.
Самостійна налаштування системи авторизації — це чудовий спосіб зрозуміти її тонкощі, проте у продакшн-системах видачу та перевірку токенів часто делегують постачальнику облікових даних. Коли API реєструється у такому постачальнику, як Microsoft Entra ID, та налаштовується для прийому токенів-носіїв, конфіденційні кінцеві точки перевіряють діапазони та ролі кожного токену перед його виконанням. Недійсний токен або відсутні права призводять до помилки 401 Unauthorized. У Express захищений маршрут виглядає так:
app.get(
"/api/orders",
passport.authenticate("oauth-bearer", { session: false }),
(req, res) => {
res.json({ message: "Protected resource." });
}
);
Пам’ятайте, що підтверджений токен встановлює лише загальні права доступу. Перевірки на рівні об’єкта, такі як «Чи належить це замовлення цьому користувачеві?», все одно мають відбуватися у вашому обробнику або шарі даних, оскільки жоден постачальник ідентичності не знає, хто є власником рядка 4812 у вашій базі даних.
Щодо самих протоколів, слід спиратися на стандарти галузі, такі як OAuth 2.0, OpenID Connect та SAML. Ви можете самостійно реалізувати ці процеси або делегувати їх постачальникам ідентичності, таким як Ping Identity, Okta, Microsoft Entra ID, AWS чи IBM Security Verify.
3. Шифрування: захист даних під час передачі та у спокої
Шифрування перетворює читабельні дані на зашифрований текст, який є безполезним без відповідного ключа. TLS захищає дані під час їх передачі; шифрування у спокої захищає їх у місцях зберігання, включаючи бази даних. Критично важливі системи зазвичай використовують обидва методи, оскільки без шифрування дані, такі як фінансові облікові дані, можуть бути перехоплені або викрадені з компрометованого сховища.
Основні підходи протиставляють швидкість управлінню ключами:
- Симетричне шифрування використовує один спільний ключ. Воно дуже швидке та ефективно обробляє великі обсяги даних, тому є правильним вибором для шифрування даних у спокої та вмісту повідомлень, проте обидві сторони повинні надійно зберігати однаковий секретний ключ.
- Асиметричне шифрування використовує пару публічного та приватного ключів, тому не потрібна зміна спільного секрету. Воно значно повільніше та практичне лише для невеликих обсягів даних.
Для симетричного шифрування вбудований модуль crypto у Node підтримує стандарт AES-256-GCM. Наведений нижче механізм серіалізує тіло запиту, генерує новий 12-байтовий вектор ініціалізації, шифрує дані та повертає вектор ініціалізації, тег автентифікації GCM та зашифрований текст у вигляді шістнадцяткових рядків.
const crypto = require("crypto");
const algorithm = "aes-256-gcm";
const key = Buffer.from(process.env.ENCRYPTION_KEY, "hex");
app.post("/api/orders", (req, res) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(algorithm, key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(req.body), "utf8"),
cipher.final(),
]);
const payload = {
iv: iv.toString("hex"),
tag: cipher.getAuthTag().toString("hex"),
data: encrypted.toString("hex"),
};
// Store or transmit the encrypted payload
res.json(payload);
});
Кілька факторів роблять цей підхід правильним. Ключ має мати розмір саме 32 байти (64 шістнадцяткові символи у ENCRYPTION_KEY) та походити з менеджера секретів, а не з вихідного коду. IV має бути унікальним для кожного процесу шифрування під однаковим ключем; повторне використання IV з алгоритмом GCM призводить до катастрофічних наслідків, тому він генерується для кожного запиту. Тег автентифікації дозволяє стороні, яка розшифровує дані, виявити зміни, тому його необхідно зберігати разом із зашифрованим текстом та перевіряти під час розшифрування. У реальному сервісі цей пакет даних буде зберігатися або передаватися далі, а не надсилатися назад викликаючій стороні, як це робить демонстраційний варіант.
У тому ж модулі також доступне асиметричне шифрування. Дані, зашифровані за допомогою публічного ключа, можна розшифрувати лише за допомогою відповідного приватного ключа:
const crypto = require("crypto");
const encrypted = crypto.publicEncrypt(
publicKey,
Buffer.from("Sensitive API data")
);
Оскільки RSA може шифрувати лише дані, розмір яких менший за розмір ключа, функція publicEncrypt підходить для коротких значень, таких як поле з секретом чи симетричний ключ, а не для цілих документів. Саме цю обмеженість усуває гібридний підхід: створюється тимчасовий ключ AES, за допомогою якого шифруються дані, а ключ AES шифрується публічним ключем RSA отримувача, після чого обидва елементи надсилаються. Отримувач використовує свій приватний ключ для розшифрування ключа AES, а потім — дані. Більшість API ніколи не будуть потребувати цього у коді додатку, оскільки TLS вже здійснює подібну заміну ключів, проте це варто зрозуміти для сценаріїв кінцево-кінцевого шифрування.
4. Перевірка та очищення вхідних даних
Як тільки ваш API приймає дані від клієнта, ви не можете передбачити, що саме надійде. Неправильно сформовані дані, фрагменти SQL та вміст скриптів виглядають як звичайні рядки, поки щось їх не інтерпретує. Для вирішення цієї проблеми існують дві взаємодоповнюючі техніки. Валідація відхиляє вхідні дані, які порушують ваші структурні та семантичні правила. Очищення перетворює прийняті дані на безпечну, нормалізовану форму перед тим, як вони потраплять до обробників.
Спочатку перевіряйте тип контенту
Найпростішою перевіркою є формат самого запиту. Цей невеликий мідлвейр використовує req.is() для підтвердження значення Content-Type; у протилежному випадку він повертає код 415 Unsupported Media Type. Його можна встановити глобально, для кожного роутера чи окремого ендпоїнта.
const requireContentType = (type) => (req, res, next) => {
if (!req.is(type)) {
return res.status(415).json({ error: "Unsupported Media Type", });
}
next();
};
app.post("/api/users", requireContentType("application/json"),
(req, res) => {
res.json({ message: "User created." });
}
);
Перевірка форми та змісту тіла запиту
Після забезпечення правильного формату наступний шар перевіряє, чи запит сформований належним чином. За допомогою express-validator правила слід зберігати у окремому модулі-валідаторі. Цей модуль вимагає синтаксично коректної електронної пошти, виконує асинхронну власну перевірку, яка відхиляє адреси, що вже є в базі даних, та забезпечує мінімальну довжину пароля у вісім символів.
const { body } = require("express-validator");
const { getUserEmail } = require("../db/queries");
const validateRegistration = [
body("email")
.isEmail()
.withMessage("Invalid email format")
.custom(async (value) => {
if (await getUserEmail(value)) {
throw new Error("Email is already in use");
}
return true;
}),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long"),
];
module.exports = { validateRegistration }
Потім масив валідаторів додається до ланцюга мідлверів маршруту. У обробнику функція validationResult(req) збирає всі помилки, і маршрут повертає код 400 із повним списком помилок замість того, щоб продовжувати роботу.
const { validationResult } = require("express-validator");
const { validateRegistration } = require("../validators/userValidator");
app.post("/api/register", validateRegistration, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
});
Очищення після перевірки
Оскільки Express обробляє мідлвейри у порядку, ланцюжок очищення може розташовуватися відразу після перевірки. Тут ім’я обрізається та ескейпується від HTML, а електронна пошта нормалізується.
const sanitizeRegistration = [
body("firstName").trim().escape(),
body("email").normalizeEmail(),
];
app.post(
"/api/register",
validateRegistration,
sanitizeRegistration,
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
}
);
Тут порядок має важливе значення. Перевірка унікальності в валідаторі виконується перед функцією normalizeEmail(), тому два різні варіанти написання однієї адреси можуть пройти її та призвести до створення дублікатів облікових записів. Нормалізація даних перед пошуком або забезпечення унікальності нормалізованих значень на рівні бази даних усуває цю проблему. Також потрібно уважно ставитися до використання функції escape(): кодування HTML для вхідних даних захищає шаблони, які відображають ці дані, але змінює збережені дані; багато команд воліють зберігати сирі дані та кодувати їх під час виведення. Якщо ви розглядаєте бібліотеки для валідації, наша порівняльна стаття Zod та express-validator розглядає всі переваги та недоліки.
Використовуйте параметризовані запити до бази даних
Ніколи не створюйте SQL-запити шляхом об’єднання даних, наданих користувачем. Клієнти баз даних, такі як pg, та ORM-інструменти, такі як Prisma, підтримують параметризовані запити, які надсилають текст запиту та значення окремо, тож база даних завжди розглядає вхідні дані як звичайні дані, а не як виконуваний SQL-код.
За допомогою pg створіть пул з’єднань один раз та експортуйте його для ваших модулів даних:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
Запити потім використовують нумеровані місця заміни ($1, $2) з значеннями, наданими у окремому масиві. Навіть якщо email містить лапку, за якою слідує DROP TABLE, він зберігається як літеральний рядок.
app.post("/api/users", async (req, res) => {
const { email, name } = req.body;
await pool.query(
"INSERT INTO users (email, name) VALUES ($1, $2)",
[email, name]
);
res.status(201).json({ message: "User created." });
});
Разом ці елементи створюють багатошарову систему обробки. Express-validator дозволяє ізолювати правила як повторно використовувані одиниці, які працюють як мідлвейр та повідомляють про кожну невдачу, тоді як параметризація гарантує, що навіть дані, які пройшли обробку без перевірки, не зможуть змінити ваші запити.
5. Обмеження частоти та теортлінг
Обмеження частоти встановлює ліміт кількості запитів, які клієнт може надіслати протягом певного проміжку часу. Це ускладнює спроби атак типу brute-force та denial-of-service, а також запобігає ситуації, коли один інтенсивний користувач заважає всім іншим.
Ліміти можна застосовувати за різними критеріями:
- Для кожного клієнта: запити підраховуються за кожним API-ключем або IP-адресою. Коли клієнт досягає ліміту, він чекає, поки проміжок часу не перезапуститься, або отримує більший ліміт, зазвичай у платній версії.
- За географією чи часом: обмеження варіюються залежно від регіону чи проміжку часу — наприклад, дозволяється більше трафіку з регіонів, де працюють ваші клієнти, а обмеження посилюються там, де походить підозрілий трафік.
- За потужністю сервера: деякі частини API направляються на спеціалізовану інфраструктуру з власними обмеженнями, такими як невеликий пул фонових процесів для виконання ресурсомістких завдань.
Існує багато алгоритмів (фіксоване вікно, ковзне вікно, контейнер токенів), і вам не потрібно їх самостійно реалізовувати, щоб розпочати роботу. Мідлвейр express-rate-limit за замовчуванням підраховує кількість запитів за IP-адресою. У наведеному нижче прикладі встановлено загальний ліміт у 100 запитів протягом 15 хвилин для всього, що знаходиться під адресою /api, а також значно суворіший ліміт у п’ять спроб протягом п’яти хвилин для процедури входу, з власним повідомленням для відхиленіх запитів.
const rateLimit = require("express-rate-limit");
// Apply to all API routes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
});
// Apply stricter limits to authentication endpoints
const loginLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 5,
message: "Too many login attempts. Please try again later.",
});
app.use("/api", apiLimiter);
app.post("/api/login", loginLimiter, (req, res) => {
res.json({ message: "Login successful." });
});
Публічні API зазвичай виділяють кожному користувачеві ключ, і обмеження за цим ключем є справедливішим, ніж обмеження за IP-адресою, оскільки багато користувачів можуть ділитися однією адресою через корпоративний проксі. Спеціальний keyGenerator читає заголовок X-API-Key та використовує його як ідентифікатор лічильника.
const rateLimit = require("express-rate-limit");
const apiKeyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
keyGenerator: (req) => req.get("X-API-Key"),
});
app.use("/api", apiKeyLimiter);
Як зазначено, кожен запит, у якому відсутній заголовок, створює однаковий ключ undefined та використовує один і той самий бак. На практиці слід відхиляти запити без ключа на ранніх етапах обробки або використовувати IP-адресу як альтернативу.
Обмеження швидкості виконання дорогих кінцевих точок
Обмеження швидкості впливають на те, наскільки швидко приймаються запити, щоб раптові сплески не перевантажили сервіс. У наступній конфігурації використовується той самий проміжний сервіс із дуже короткими інтервалами: максимум десять запитів на секунду через API та лише один запит на пошук кожні дві секунди на клієнта, оскільки пошук є найбільш ресурсоємною кінцевою точкою.
const rateLimit = require("express-rate-limit");
// Throttle all API requests
const apiThrottle = rateLimit({
windowMs: 1000, // 1 second
max: 10, // Allow up to 10 requests per second
});
// Apply a stricter throttle to resource-intensive endpoints
const searchThrottle = rateLimit({
windowMs: 2000, // 2 seconds
max: 1, // Allow 1 request every 2 seconds
message: "Please wait before sending another search request.",
});
app.use("/api", apiThrottle);
app.get("/api/search", searchThrottle, (req, res) => {
res.json({ results: [] });
});
Строго кажучи, це все ще є обмеженням частоти запитів із невеликими інтервалами: зайві запити відхиляються з кодом 429, а не затримуються. Якщо ви хочете справжнє обмеження швидкості, яке уповільнює клієнтів перед відмовою, потрібен додатковий пакет, такий як express-slow-down, який додає поступові затримки. Також зверніть увагу, що стандартний внутрішній механізм зберігання підраховує запити за кожним процесом, тому за лоад-балансером із кількома інстанціями потрібен спільний механізм зберігання, такий як Redis, щоб обмеження залишалися актуальними. У новіших версіях express-rate-limit опція max також називається limit; перевірте документацію до версії, яку ви встановлюєте. Щодо легкого альтернативного рішення на TypeScript, дивіться нашу статтю про мінімальний обмежувач частоти запитів для Express.
6. Логування, моніторинг та виявлення інцидентів
Ви не можете відповісти на атаку, яку ніколи не бачили. Журналування фіксує запити та відповіді разом із їхніми метаданими, контекстом, часом виконання та кодами помилок, щоб ви могли усувати проблеми, проводити аудит та розуміти реальну активність. Моніторинг відстежує діяльність у реальному часі, стежить за такими показниками, як затримка, частота помилок та пропускна здатність, та виявляє аномалії, які можуть свідчити про зловживання, недосягнення цілей рівня послуги чи експлуатацію вразливостей.
Основні підходи та їхні переваги та недоліки:
- Журналування запитів за допомогою проміжного програмного забезпечення, такого як Morgan, дешево фіксує кожен надходження HTTP-запиту, але не надає інформації про стан системи.
- Журналювання додатків за допомогою Winston, Pino чи Bunyan фіксує бізнес- та безпекові події, такі як входи, операції з базою даних та помилки. Більші системи потребують структурованого виведення даних та централізованого зберігання для їх корисності.
- Метрики за допомогою Prometheus та Grafana відстежують частоту запитів, затримку, використання CPU, пам’яті та кількість помилок. Вони показують загальні тенденції, але не те, що відбулося під час окремого запиту.
- Централізоване управління журналами за допомогою ELK Stack, OpenSearch, Splunk чи CloudWatch об’єднує журнали з різних сервісів, але це потребує додаткової інфраструктури та операційних зусиль.
- Моніторинг продуктивності додатків за допомогою Datadog, New Relic чи Dynatrace поєднує журнали, метрики, трекування та сповіщення в одній платформі, що призводить до вищої вартості ліцензій та складності у керуванні.
Будь-який з цих інструментів підключається до Express. Далі наведені чотири поширені елементи.
Журналізація запитів за допомогою Morgan
Реєстрація Morgan у форматі combined записує рядок у стилі Apache для кожного запиту, включаючи метод, шлях, статус, розмір відповіді та користувацький агент.
const express = require("express");
const morgan = require("morgan");
const app = express();
// Log every incoming request
app.use(morgan("combined"));
app.get("/api/users", (req, res) => {
res.json({ message: "Users retrieved successfully." });
});
Структуроване журналювання дій за допомогою Winston
Winston записує події у вигляді структурованих об’єктів. Журналювання ідентифікаторів користувача та замовлення під час його створення створює аудитну історію, яку можна згодом пошукати.
const winston = require("winston");
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
],
});
app.post("/api/orders", (req, res) => {
logger.info("Order created", {
userId: req.user.id,
orderId: req.body.id,
});
res.status(201).json({ message: "Order created." });
});
Будьте обережні з тим, що потрапляє до журналів. ID користувачів — це нормально; паролі, токени, повні номери карток та весь вміст запитів — ні, а журнали є поширеним джерелом витоку конфіденційних даних.
Централізоване логування помилок
Мідлвейр обробки помилок Express, який впізнається за своїми чотирма аргументами, ловить помилки з будь-якого маршруту. Він записує повідомлення про помилку разом із шляхом та методом та повертає загальний код 500, щоб стек-трейси та внутрішні деталі ніколи не потрапляли до клієнта.
app.use((err, req, res, next) => {
/* Logger is built as an independent module or class */
logger.error(err.message, {
path: req.originalUrl,
method: req.method,
});
res.status(500).json({
error: "Internal Server Error",
});
});
Розкриття метрик для Prometheus
Бібліотека prom-client збирає стандартні метрики процесу Node.js та розкриває їх через кінцеву точку /metrics, щоб Prometheus міг їх збирати.
const client = require("prom-client");
client.collectDefaultMetrics();
app.get("/metrics", async (req, res) => {
res.set("Content-Type", client.register.contentType);
res.end(await client.register.metrics());
});
Цей кінцевий пункт розкриває внутрішні деталі вашого сервісу, тому обмежте його доступ лише вашої мережі моніторингу або розмістіть його за системою автентифікації, замість того щоб залишити його загальнодоступним.
Тут стають у пригоді фреймворки з чітко визначеною архітектурою. NestJS постачається з вбудованим логгером та структурою, яка полегшує додавання функцій логування та збору метрик до кожного кінцевого пункту, тоді як у фреймворках без чітко визначеної архітектури, таких як Express, доводиться свідомо додавати функції логування, зазвичай у вигляді проміжкового програмного забезпечення, яке виконується перед надсиланням відповіді. Виявлення інцидентів потім ґрунтується на цих логах: попередження щодо підозрілих закономірностей, таких як сплески відповідей типу 401 або відмови через обмеження частоти запитів, надсилаються у канал сповіщень, який використовує ваша команда.
Безпека як безперервний процес
Жодна окрема стаття не охоплює все, але перед кожним випуском ви можете переконатися, що ваш API відповідає цим базовим вимогам:
- Кожен кінцевий пункт обслуговується лише через HTTPS.
- Використовується OAuth або еквівалентний механізм токенів.
- Видалені JWT мають термін дії.
- Обмеження захищають усі маршрути, причому більш суворі — для процесу входу.
- Кожен вхідний даний елемент перевіряється перед використанням.
- Запити протестувалися на наявність спроб вставки даних.
- Правила доступу протестувалися, включаючи перевірки на рівні об’єктів.
- Ключі та секрети зберігаються поза кодовою базою.
- Записуються події, пов’язані з безпекою.
- Налаштовані панелі керування та сповіщення.
- У відповідях містяться заголовки безпеки.
- Клієнти ніколи не бачать стек-трейсів чи деталей внутрішніх помилок.
- Залежності є актуальними та пройшли аудит.
У прикладах тут використовується Express, але ті самі принципи застосовні й до інших фреймворків бекенду, більшість з яких або безпосередньо інтегрують ці інструменти, або пропонують їхні власні аналоги. Наприклад, NestJS здійснює перевірку запитів через об’єкти передачі даних. У документації вашого фреймворку буде наведена стандартна версія кожного рівня.
Ті самі принципи є основою для розгортання в хмарі на таких платформах, як Azure, Google Cloud та AWS, які додають власні шлюзи, сервіси ідентифікації та механізми керованого обмеження кількості запитів. Практики, специфічні для хмари, потребують окремого розгляду, але вищезазначені рівні вже значною мірою готують API до проходження перевірки на безпеку.
Короткий аудит безпеки API Express
- Розділіть перевірку облікових даних від автентифікації за кожним запитом: перевірте пароль один раз, а потім використовуйте токени з коротким терміном дії та підписом.
- Аутентифікація — це не авторизація. Перевіряйте діапазони та ролі, а також уточнюйте власність кожного об’єкта, який бере участь у запиті.
- Для даних у спокої використовуйте AES-GCM із унікальним IV для кожної операції; залиште асиметричне шифрування для невеликих значень та обміну ключами.
- Додайте перевірки типу контенту, їх верифікацію, очищення та параметризовані запити, а також подумайте про порядок їх виконання.
- Обмежуйте частоту запитів, застосовуючи суворіші обмеження до процедур входу та дорогих кінцевих точок, а також використовуйте спільний сховище, якщо запускаєте більше однієї інстанції.
- Фіксуйте події, пов’язані з безпекою, не записуючи секрети, та перетворюйте ці журнали на сповіщення, які хтось зможе використати.
Пов’язана література
- Коли автентифікація JWT стає залежною від стану: аргументи на користь сеансів з боку сервера в Node: Дізнайтеся, як списки блокування та механізми оновлення роблять автентифікацію JWT крихкою, як сеанси Express з підтримкою Postgres її спрощують, та де JWT все ще можуть бути корисними.
- Створення автентифікації JWT готової до використання в продакшені в API на Node.js: Як хешувати паролі, видавати тимчасові JWT, додавати токени оновлення та механізми скасування, розділяти авторизацію та автентифікацію, протидіяти атакам типу brute force та перевіряти, чи правильно відбувається відмова у доступі.