Пошук уроків, статей та іншого контенту
Захистимо API від підбору паролів і перевантаження, обмежуючи частоту запитів за IP та ідентифікатором.
Rate limiting — це контроль кількості запитів, які клієнт може виконати за певний проміжок часу.
Для API це допомагає:
ускладнити підбір паролів;
зменшити ризик перевантаження сервера;
обмежити автоматизовані запити;
захистити дорогі операції, наприклад надсилання листів або генерацію звітів.
Важливо обмежувати запити не лише за IP-адресою. Під час атаки один IP може змінюватися, а багато користувачів можуть перебувати за однією спільною адресою. Тому для автентифікації часто використовують щонайменше два ключі:
IP-адреса клієнта;
ідентифікатор облікового запису — наприклад, email або username.
Один із найпростіших алгоритмів — fixed window, або фіксоване вікно.
Наприклад:
дозволити 5 спроб входу за 60 секунд для однієї IP-адреси;
дозволити 10 спроб за 60 секунд для одного username;
після завершення 60 секунд почати рахунок спочатку.
Для кожного ключа зберігаються:
кількість використаних запитів;
момент завершення поточного вікна.
Якщо ліміт перевищено, сервер повертає статус
429 Too Many RequestsРазом із відповіддю корисно повертати заголовок Retry-After. Він повідомляє клієнту, через скільки секунд можна повторити запит.
Нижче наведено повний приклад HTTP-сервера на вбудованому модулі node:http. Він має:
загальний ліміт за IP;
окремий ліміт для входу за IP;
окремий ліміт для входу за username;
очищення застарілих записів;
відповідь 429 із Retry-After.
const http = require('node:http');
class RateLimiter {
constructor({ limit, windowMs }) {
this.limit = limit;
this.windowMs = windowMs;
this.entries = new Map();
// Періодично видаляємо застарілі записи, щоб Map не зростав безмежно.
this.cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (entry.resetAt <= now) {
this.entries.delete(key);
}
}
}, windowMs);
this.cleanupTimer.unref();
}
check(key) {
const now = Date.now();
let entry = this.entries.get(key);
if (!entry || entry.resetAt <= now) {
entry = {
count: 0,
resetAt: now + this.windowMs
};
this.entries.set(key, entry);
}
entry.count += 1;
const retryAfter = Math.max(
1,
Math.ceil((entry.resetAt - now) / 1000)
);
return {
allowed: entry.count <= this.limit,
retryAfter,
remaining: Math.max(0, this.limit - entry.count)
};
}
}
const generalLimiter = new RateLimiter({
limit: 100,
windowMs: 60 * 1000
});
const loginByIpLimiter = new RateLimiter({
limit: 5,
windowMs: 60 * 1000
});
const loginByUsernameLimiter = new RateLimiter({
limit: 10,
windowMs: 60 * 1000
});
function normalizeIp(ip) {
// Node.js може повертати IPv4 у форматі ::ffff:127.0.0.1.
return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
}
function sendJson(response, statusCode, data, headers = {}) {
const body = JSON.stringify(data);
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
...headers
});
response.end(body);
}
function sendRateLimitResponse(response, retryAfter) {
sendJson(
response,
429,
{
error: 'Забагато запитів',
message: 'Спробуйте ще раз пізніше'
},
{
'Retry-After': String(retryAfter)
}
);
}
function readJsonBody(request, maxBytes = 1024 * 16) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes = 0;
let finished = false;
request.on('data', (chunk) => {
if (finished) {
return;
}
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
finished = true;
reject(new Error('REQUEST_TOO_LARGE'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('end', () => {
if (finished) {
return;
}
finished = true;
try {
const text = Buffer.concat(chunks).toString('utf8');
resolve(text ? JSON.parse(text) : {});
} catch {
reject(new Error('INVALID_JSON'));
}
});
request.on('error', (error) => {
if (!finished) {
finished = true;
reject(error);
}
});
});
}
async function handleLogin(request, response, ip) {
let body;
try {
body = await readJsonBody(request);
} catch (error) {
if (error.message === 'REQUEST_TOO_LARGE') {
sendJson(response, 413, {
error: 'Запит надто великий'
});
return;
}
sendJson(response, 400, {
error: 'Некоректний JSON'
});
return;
}
const username =
typeof body.username === 'string'
? body.username.trim().toLowerCase()
: '';
if (!username || typeof body.password !== 'string') {
sendJson(response, 400, {
error: 'Потрібні username і password'
});
return;
}
const ipResult = loginByIpLimiter.check(ip);
const usernameResult = loginByUsernameLimiter.check(username);
if (!ipResult.allowed || !usernameResult.allowed) {
const retryAfter = Math.max(
ipResult.retryAfter,
usernameResult.retryAfter
);
sendRateLimitResponse(response, retryAfter);
return;
}
// Демонстраційні облікові дані. У реальному застосунку пароль
// потрібно перевіряти через безпечний механізм зберігання паролів.
const isValid =
username === 'admin' &&
body.password === 'demo-password';
if (!isValid) {
// Однакова відповідь для неправильного username і неправильного пароля
// не повинна розкривати, чи існує обліковий запис.
sendJson(response, 401, {
error: 'Неправильні облікові дані'
});
return;
}
sendJson(response, 200, {
message: 'Вхід виконано'
});
}
const server = http.createServer(async (request, response) => {
const ip = normalizeIp(request.socket.remoteAddress || 'unknown');
const generalResult = generalLimiter.check(ip);
if (!generalResult.allowed) {
sendRateLimitResponse(response, generalResult.retryAfter);
return;
}
if (request.method === 'POST' && request.url === '/login') {
await handleLogin(request, response, ip);
return;
}
sendJson(response, 404, {
error: 'Маршрут не знайдено'
});
});
server.listen(3000, () => {
console.log('Сервер запущено на http://localhost:3000');
});Збережіть код у файлі server.js і запустіть:
node server.jsТестовий запит:
curl -i \
-X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"wrong-password"}'Після кількох таких запитів сервер поверне:
HTTP/1.1 429 Too Many Requests
Retry-After: 42Значення Retry-After залежить від моменту, коли було вичерпано ліміт.
IP-адреса підходить для загального захисту API, але не є ідентифікатором користувача з абсолютною точністю.
Проблеми такого підходу:
багато користувачів можуть мати одну публічну IP-адресу;
мобільні клієнти можуть часто змінювати IP;
атакер може використовувати проксі або ботнет;
сервер може працювати за reverse proxy.
У прикладі використовується:
const ip = normalizeIp(request.socket.remoteAddress || 'unknown');Це адреса безпосереднього мережевого клієнта. Не слід безумовно довіряти заголовку X-Forwarded-For, якщо сервер не налаштований працювати за довіреним проксі. Інакше клієнт зможе самостійно підставити будь-яку IP-адресу в заголовок і обійти обмеження.
Для endpoint входу одного обмеження за IP недостатньо. Наприклад, атакер може надсилати запити з різних IP до одного облікового запису.
Тому використовується додатковий ключ:
const username =
typeof body.username === 'string'
? body.username.trim().toLowerCase()
: '';Нормалізація важлива, щоб варіанти на кшталт:
Admin;
admin;
admin
не вважалися різними ідентифікаторами.
Для різних endpoint можна використовувати різні ліміти:
вхід: суворий ліміт;
читання загальнодоступних даних: м’якший ліміт;
надсилання листа: суворий ліміт за IP та обліковим записом;
дорогі операції: окремий низький ліміт.
Ключ має бути стабільним і контрольованим. Не варто бездумно додавати до нього необмежені частини введення користувача, оскільки це може призвести до надмірного використання пам’яті.
У прикладі лічильники зберігаються в Map. Це зручно для навчання та одного процесу Node.js, але має обмеження:
після перезапуску сервера всі лічильники зникають;
кожен процес має власний набір лічильників;
кілька екземплярів застосунку не ділять один стан;
пам’ять одного процесу має обмежений розмір.
Для одного сервера in-memory зберігання може бути достатнім. Якщо застосунок працює в кількох процесах або на кількох інстансах, лічильники потрібно зберігати у спільному сховищі. Інакше клієнт зможе надсилати запити до різних інстансів і фактично отримувати окремий ліміт на кожному з них.
Незалежно від сховища, потрібно очищати записи, термін дії яких завершився. У прикладі це робить періодичний таймер.
Під час налаштування rate limiting варто:
повертати 429 Too Many Requests, коли ліміт перевищено;
додавати Retry-After;
використовувати окремі ліміти для різних типів операцій;
рахувати невдалі спроби входу, а не лише успішні;
комбінувати обмеження за IP та ідентифікатором;
не розкривати, чи існує певний обліковий запис;
обмежувати розмір тіла запиту;
не довіряти X-Forwarded-For без коректної конфігурації проксі.
Якщо збільшувати лічильник лише після правильного пароля, підбір паролів залишиться без обмежень.
Лічильник потрібно збільшувати до перевірки облікових даних.
Атакер може змінювати IP, а різні користувачі можуть спільно використовувати одну адресу. Для endpoint входу додавайте обмеження за username або email.
X-Forwarded-ForЦей заголовок може бути сформований самим клієнтом. Його можна використовувати лише тоді, коли запит гарантовано проходить через налаштований довірений reverse proxy.
Retry-AfterБез цього заголовка клієнт не знає, коли повторити запит, і може продовжувати надсилати запити без користі.
Map, до якого постійно додаються нові ключі, може поступово спожити всю доступну пам’ять. Записи потрібно видаляти після завершення їхнього часового вікна.
Обмеження частоти зменшує кількість спроб, але не замінює:
безпечне зберігання паролів;
захищене з’єднання;
коректну автентифікацію;
моніторинг і журналювання підозрілих запитів.
Rate limiting обмежує кількість запитів за часовий проміжок.
Після перевищення ліміту API має повертати 429 Too Many Requests.
Загальні запити зручно обмежувати за IP.
Для входу потрібно додатково обмежувати запити за username або іншим ідентифікатором.
Заголовок Retry-After повідомляє клієнту, коли можна повторити запит.
In-memory лічильники підходять для одного процесу, але не для розподіленого застосунку без спільного сховища.
Rate limiting особливо важливий для endpoint входу, відновлення пароля та інших операцій, які можуть використовуватися для автоматизованих атак.