Пошук уроків, статей та іншого контенту
Розгляньте захист API: валідацію введення, rate limiting, токени, ідемпотентність і протидію типовим атакам.
API приймає дані з ненадійного середовища: від браузера, мобільного застосунку, іншого сервісу або безпосередньо від зловмисника. Навіть якщо клієнт використовує «правильний» інтерфейс, сервер не може йому довіряти.
Захист API має охоплювати:
автентифікацію — хто виконує запит;
авторизацію — що саме цьому користувачу дозволено;
валідацію введення;
обмеження частоти запитів;
безпечну роботу з токенами;
захист операцій від повторного виконання;
протидію ін'єкціям, перебору паролів та іншим атакам.
Безпека повинна перевірятися на сервері для кожного запиту. Приховування кнопки в інтерфейсі або перевірка даних лише в браузері не є захистом API.
Валідація — це перевірка того, що вхідні дані:
мають очікуваний тип;
містять допустимі значення;
не перевищують розумний розмір;
відповідають бізнес-правилам;
не містять небезпечних або несподіваних структур.
Наприклад, для створення платежу сервер може дозволити лише такі дані:
{
"amount": 2500,
"currency": "UAH"
}Валідація повинна виконуватися незалежно для кожного поля:
amount — ціле додатне число в допустимому діапазоні;
currency — значення з відомого переліку;
додаткові поля — або явно дозволені, або відхиляються.
Надійніше перелічити дозволені значення, ніж намагатися скласти список усіх небезпечних.
Поганий підхід:
if (!input.includes("<script>")) {
// Дані нібито безпечні
}Такий код пропустить інші варіанти атаки.
Кращий підхід:
const allowedCurrencies = new Set(["UAH", "USD", "EUR"]);
if (
Number.isInteger(body.amount) &&
body.amount > 0 &&
body.amount <= 1_000_000 &&
typeof body.currency === "string" &&
allowedCurrencies.has(body.currency)
) {
// Дані відповідають очікуваному формату
}Валідація не замінює параметризовані SQL-запити, екранування HTML або інші спеціальні механізми захисту. Вона лише гарантує, що структура та значення даних відповідають контракту API.
Якщо endpoint очікує лише amount і currency, варто вирішити політику щодо інших полів:
відхиляти запит із невідомими полями;
або ігнорувати їх, але ніколи не передавати автоматично весь об'єкт у базу даних чи модель.
Автоматичне масове оновлення на кшталт update(request.body) може дозволити клієнту змінити поля, які він не повинен контролювати: роль, власника ресурсу або статус оплати.
Автентифікація відповідає на питання: «Хто це?».
Поширений варіант для API — access token у заголовку:
Authorization: Bearer <access-token>Сервер повинен:
перевірити наявність токена;
перевірити його підпис або знайти його в сховищі;
перевірити термін дії;
визначити користувача;
перевірити, що токен не відкликаний, якщо така можливість передбачена.
Авторизація відповідає на питання: «Що цьому користувачу дозволено?».
Недостатньо перевірити, що користувач увійшов у систему. Потрібно також перевірити доступ до конкретного ресурсу:
GET /users/42/orders/10Сервер повинен переконатися, що замовлення 10 належить користувачу 42, а поточний користувач має право його переглядати.
Не можна покладатися на ідентифікатор із URL:
const userId = request.params.userId;
// Небезпечно: саме число userId не доводить право доступуІдентифікатор користувача потрібно брати з перевіреного контексту автентифікації, а доступ до ресурсу — перевіряти окремо. Інакше виникає уразливість IDOR (Insecure Direct Object References), коли користувач змінює ID в URL і отримує чужі дані.
Передавайте токени лише через HTTPS.
Не передавайте токени в URL або query-параметрах: вони можуть потрапити в журнали, історію браузера чи заголовок Referer.
Використовуйте короткий час життя access token.
Не зберігайте токени у відкритих логах.
Для різних клієнтів і середовищ використовуйте різні ключі та облікові дані.
Відкликайте скомпрометовані токени.
Не приймайте токен без перевірки алгоритму, підпису, видавця та аудиторії, якщо ці поля використовуються системою.
Токен — це не пароль і не доказ того, що всі дії користувача дозволені. Він лише переносить інформацію про автентифікацію, а перевірка прав повинна відбуватися на сервері.
JWT може містити ідентифікатор користувача, ролі та час завершення дії. Його підпис перевіряє сервер. Однак JWT не шифрує payload: дані всередині можна прочитати, якщо токен отримано.
Непрозорий токен не містить зрозумілих даних для клієнта. Сервер зберігає відповідність між токеном і сесією у сховищі. Такий підхід спрощує відкликання, але вимагає доступу до сховища під час перевірки.
Незалежно від формату, викрадений дійсний токен зазвичай можна використати до завершення його дії або відкликання. Тому важливі HTTPS, короткий час життя, безпечне зберігання та моніторинг підозрілих запитів.
Rate limiting обмежує кількість запитів за певний проміжок часу. Це допомагає протидіяти:
перебору паролів і токенів;
масовому надсиланню форм;
зловживанню дорогими endpoint;
частині атак на доступність сервісу.
Ліміт можна застосовувати за:
IP-адресою;
користувачем;
токеном;
комбінацією цих ознак;
окремим endpoint.
Для входу в систему важливий ліміт за IP і за обліковим записом. Якщо обмежити лише IP, зловмисник може розподілити запити між великою кількістю адрес. Якщо обмежити лише акаунт, можна заблокувати користувача через спільну мережу.
Коли ліміт перевищено, API зазвичай повертає:
429 Too Many Requests
Retry-After: 30У розподіленій системі лічильник не слід зберігати лише в пам'яті одного процесу. Потрібне спільне сховище або механізм, який бачать усі екземпляри сервісу. Ліміти також потрібно налаштовувати з урахуванням нормального навантаження: надто мале значення створить відмови для легітимних клієнтів.
Операція є ідемпотентною, якщо її повторне виконання не створює додаткового небажаного ефекту.
Наприклад, клієнт надіслав запит на оплату, але не отримав відповідь через мережеву помилку. Він повторює запит. Без захисту сервер може створити дві оплати.
Для операцій, які створюють побічний ефект, клієнт може передати ключ:
Idempotency-Key: 4f8d2f7a-0f5e-4b6d-9f1e-123456789abcСервер повинен:
отримати ключ;
пов'язати його з користувачем і конкретним endpoint;
зберегти результат першого успішного виконання;
повернути той самий результат для повторного запиту;
не дозволяти використати той самий ключ для іншого payload.
Ключ і результат потрібно зберігати достатньо довго для можливих повторних запитів. У розподіленому середовищі сховище ідемпотентності має бути спільним для всіх екземплярів сервісу.
Нижче наведено самодостатній демонстраційний сервер на Node.js без зовнішніх залежностей. Він показує:
перевірку Bearer-токена;
обмеження кількості запитів;
обмеження розміру body;
валідацію JSON;
ідемпотентність через Idempotency-Key.
У production-системі ліміти та ідемпотентність потрібно зберігати у спільному сховищі, а токени — не кодувати в програмі.
const http = require("node:http");
const crypto = require("node:crypto");
const PORT = 3000;
const MAX_BODY_SIZE = 1_000_000;
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60_000;
// Демонстраційний токен. У production його не зберігають у коді.
const validTokens = new Map([
["demo-access-token", { userId: "user-123" }]
]);
const rateLimits = new Map();
const idempotencyStore = new Map();
function sendJson(response, statusCode, body, extraHeaders = {}) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
...extraHeaders
});
response.end(JSON.stringify(body));
}
function getClientKey(request) {
// За проксі потрібно окремо налаштувати довіру до proxy-заголовків.
return request.socket.remoteAddress || "unknown";
}
function isRateLimited(clientKey) {
const now = Date.now();
const current = rateLimits.get(clientKey);
if (!current || now >= current.resetAt) {
rateLimits.set(clientKey, {
count: 1,
resetAt: now + RATE_WINDOW_MS
});
return false;
}
current.count += 1;
return current.count > RATE_LIMIT;
}
function getBearerToken(request) {
const header = request.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return null;
}
return header.slice("Bearer ".length).trim();
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = "";
let size = 0;
request.setEncoding("utf8");
request.on("data", (chunk) => {
size += Buffer.byteLength(chunk);
if (size > MAX_BODY_SIZE) {
reject(new Error("BODY_TOO_LARGE"));
request.destroy();
return;
}
body += chunk;
});
request.on("end", () => {
try {
resolve(body ? JSON.parse(body) : null);
} catch {
reject(new Error("INVALID_JSON"));
}
});
request.on("error", reject);
});
}
function validatePayment(body) {
const allowedCurrencies = new Set(["UAH", "USD", "EUR"]);
if (!body || typeof body !== "object" || Array.isArray(body)) {
return "Тіло запиту має бути JSON-об'єктом";
}
if (!Number.isInteger(body.amount) || body.amount < 1 || body.amount > 1_000_000) {
return "amount має бути цілим числом від 1 до 1000000";
}
if (
typeof body.currency !== "string" ||
!allowedCurrencies.has(body.currency)
) {
return "currency має бути одним із: UAH, USD, EUR";
}
const allowedFields = new Set(["amount", "currency"]);
const unknownFields = Object.keys(body).filter(
(field) => !allowedFields.has(field)
);
if (unknownFields.length > 0) {
return "Запит містить невідомі поля";
}
return null;
}
const server = http.createServer(async (request, response) => {
const clientKey = getClientKey(request);
if (isRateLimited(clientKey)) {
sendJson(
response,
429,
{ error: "Забагато запитів" },
{ "Retry-After": "60" }
);
return;
}
if (request.method !== "POST" || request.url !== "/payments") {
sendJson(response, 404, { error: "Маршрут не знайдено" });
return;
}
const token = getBearerToken(request);
const session = token ? validTokens.get(token) : null;
if (!session) {
sendJson(response, 401, { error: "Потрібна автентифікація" });
return;
}
const idempotencyKey = request.headers["idempotency-key"];
if (
typeof idempotencyKey !== "string" ||
idempotencyKey.length < 16 ||
idempotencyKey.length > 128
) {
sendJson(response, 400, {
error: "Потрібен коректний Idempotency-Key"
});
return;
}
const storageKey = `${session.userId}:POST:/payments:${idempotencyKey}`;
try {
const body = await readJsonBody(request);
const validationError = validatePayment(body);
if (validationError) {
sendJson(response, 400, { error: validationError });
return;
}
const payloadHash = crypto
.createHash("sha256")
.update(JSON.stringify(body))
.digest("hex");
const saved = idempotencyStore.get(storageKey);
if (saved) {
if (saved.payloadHash !== payloadHash) {
sendJson(response, 409, {
error: "Цей Idempotency-Key вже використано з іншими даними"
});
return;
}
sendJson(response, saved.statusCode, saved.body);
return;
}
// Тут у реальному сервісі виконувалася б транзакція в базі даних.
const result = {
paymentId: crypto.randomUUID(),
userId: session.userId,
amount: body.amount,
currency: body.currency,
status: "accepted"
};
const savedResponse = {
statusCode: 201,
body: result,
payloadHash
};
idempotencyStore.set(storageKey, savedResponse);
sendJson(response, 201, result);
} catch (error) {
if (error.message === "BODY_TOO_LARGE") {
sendJson(response, 413, { error: "Тіло запиту завелике" });
return;
}
if (error.message === "INVALID_JSON") {
sendJson(response, 400, { error: "Некоректний JSON" });
return;
}
sendJson(response, 500, { error: "Внутрішня помилка сервера" });
}
});
server.listen(PORT, () => {
console.log(`API запущено на http://localhost:${PORT}`);
});Запуск:
node server.jsЗапит до endpoint:
curl -i -X POST http://localhost:3000/payments \
-H "Authorization: Bearer demo-access-token" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 4f8d2f7a-0f5e-4b6d-9f1e-123456789abc" \
-d '{"amount":2500,"currency":"UAH"}'Повторення цього запиту з тим самим ключем поверне той самий paymentId, а не створить новий платіж.
Виникає, коли введення користувача конкатенується із SQL-кодом:
const query = "SELECT * FROM users WHERE email = '" + email + "'";Потрібно використовувати параметризовані запити або підготовлені вирази:
const query = "SELECT * FROM users WHERE email = ?";
const params = [email];Валідація формату email корисна, але сама по собі не захищає від SQL-ін'єкції.
XSS виникає, коли введені користувачем дані повертаються в HTML як код. API повинно:
не генерувати HTML із неперевірених даних;
правильно кодувати значення під час формування відповіді;
не дозволяти небезпечний HTML без спеціальної санітизації;
встановлювати відповідні політики безпеки на рівні клієнта та сервера.
JSON-відповідь сама по собі не робить подальше відображення даних безпечним. Клієнт також має правильно працювати з отриманим текстом.
CSRF особливо актуальна, коли автентифікація працює через cookies, які браузер додає автоматично.
Захист може включати:
SameSite для cookies;
CSRF-токени;
перевірку Origin або Referer;
відсутність небезпечних змін через GET.
Якщо API використовує Bearer-токен у заголовку, який клієнт додає явно, класичний сценарій CSRF зазвичай не працює так само, як із cookie-сесією. Проте XSS може викрасти токен або виконати дії від імені користувача, тому захист від XSS залишається важливим.
Захисні заходи:
rate limiting для входу та endpoint, пов'язаних із токенами;
тимчасове блокування або додаткова перевірка після багатьох невдалих спроб;
безпечне хешування паролів спеціалізованими алгоритмами;
відсутність детальних повідомлень на кшталт «користувач існує, але пароль неправильний»;
журналювання та сповіщення про аномальну активність.
Не повертайте клієнту:
stack trace;
SQL-запити;
секрети конфігурації;
внутрішні ідентифікатори, якщо вони не потрібні;
різні надто детальні помилки для існуючих і неіснуючих користувачів.
Клієнту достатньо стабільного коду помилки та безпечного повідомлення. Деталі повинні залишатися в контрольованих серверних журналах.
Якщо зловмисник перехопить дійсний запит, він може повторити його. Допомагають:
HTTPS;
короткоживучі токени;
перевірка терміну дії;
ідемпотентні ключі для операцій зі змінами;
nonce або timestamp для спеціальних протоколів;
відкликання скомпрометованих токенів.
Ідемпотентність не замінює автентифікацію: вона лише контролює наслідки повторної доставки запиту.
Коректні коди допомагають клієнту правильно реагувати:
400 Bad Request — некоректний формат або дані;
401 Unauthorized — відсутня або недійсна автентифікація;
403 Forbidden — користувач автентифікований, але не має права;
404 Not Found — ресурс або маршрут не знайдено;
409 Conflict — конфлікт стану, наприклад повторне використання ключа з іншим payload;
413 Payload Too Large — тіло запиту завелике;
429 Too Many Requests — перевищено rate limit;
500 Internal Server Error — помилка сервера.
Не варто використовувати 200 OK для кожної помилки. Це ускладнює клієнтську логіку, моніторинг і виявлення атак.
Перевіряти введення лише на клієнті.
Вважати прихований endpoint або випадковий ID достатнім захистом.
Перевіряти автентифікацію, але не перевіряти право на конкретний ресурс.
Зберігати токени та паролі у логах.
Передавати токени в URL.
Використовувати один глобальний rate limit для всіх endpoint.
Зберігати лічильники rate limiting лише в пам'яті одного процесу в кластері.
Генерувати нову операцію для кожного повторного запиту клієнта.
Приймати весь JSON і без перевірки передавати його в модель або базу даних.
Повертати stack trace та внутрішні деталі у production.
Вважати JWT зашифрованим контейнером для секретів.
Використовувати blacklist-перевірки замість явного опису дозволених значень.
Перед публікацією endpoint перевірте:
Чи використовується HTTPS?
Чи є автентифікація там, де вона потрібна?
Чи перевіряються права доступу до кожного ресурсу?
Чи валідовані типи, діапазони, розміри та обов'язкові поля?
Чи відхиляються або безпечно обробляються невідомі поля?
Чи застосовуються параметризовані запити до бази даних?
Чи є rate limiting для дорогих і чутливих операцій?
Чи захищені операції створення від повторного виконання?
Чи не потрапляють секрети у відповіді та журнали?
Чи повертаються коректні HTTP-коди?
Чи перевіряються негативні сценарії: прострочений токен, чужий ресурс, завелике тіло, повторний ключ і надто багато запитів?
Безпека API будується з кількох незалежних шарів:
валідація не дозволяє приймати неочікувані дані;
автентифікація визначає користувача;
авторизація визначає його дозволи;
токени передають і підтверджують контекст доступу;
rate limiting зменшує ризик перебору та зловживання;
ідемпотентність запобігає дублюванню небезпечних операцій;
параметризовані запити, безпечний вивід і контроль помилок протидіють типовим ін'єкціям та витокам.
Жоден окремий механізм не захищає API повністю. Надійний захист виникає тоді, коли кожен endpoint має явний контракт, перевіряє всі вхідні дані та права доступу й безпечно обробляє повторні та зловмисні запити.