diff --git a/server/__tests__/rate-limit.test.js b/server/__tests__/rate-limit.test.js new file mode 100644 index 0000000..f8f8c63 --- /dev/null +++ b/server/__tests__/rate-limit.test.js @@ -0,0 +1,63 @@ +/** + * Rate-limit smoke tests. + * + * Под NODE_ENV=test глобальные лимитеры в routes/api.js специально подняты + * до 10000 запросов, чтобы не мешать integration-тестам. Поэтому здесь мы + * проверяем сам middleware через отдельный мини-app — это гарантирует, что + * express-rate-limit установлен, работает, возвращает 429 и стандартные + * RateLimit-* заголовки. + */ +const express = require('express'); +const request = require('supertest'); +const rateLimit = require('express-rate-limit'); + +function buildApp({ max = 3, windowMs = 60_000, skipSuccessfulRequests = false } = {}) { + const app = express(); + app.use(express.json()); + const limiter = rateLimit({ + windowMs, + max, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests, + message: { success: false, message: 'Слишком много попыток входа.' }, + }); + app.post('/login', limiter, (req, res) => { + if (req.body?.bad) return res.status(401).json({ success: false }); + return res.json({ success: true }); + }); + return app; +} + +describe('express-rate-limit middleware', () => { + it('возвращает 429 после исчерпания лимита', async () => { + const app = buildApp({ max: 2 }); + + const r1 = await request(app).post('/login').send({ bad: true }); + expect(r1.status).toBe(401); + + const r2 = await request(app).post('/login').send({ bad: true }); + expect(r2.status).toBe(401); + + const r3 = await request(app).post('/login').send({ bad: true }); + expect(r3.status).toBe(429); + expect(r3.body).toMatchObject({ success: false, message: expect.any(String) }); + }); + + it('выставляет RateLimit-* заголовки на каждом ответе', async () => { + const app = buildApp({ max: 5 }); + const r = await request(app).post('/login').send({ bad: true }); + expect(r.headers['ratelimit-limit']).toBeDefined(); + expect(r.headers['ratelimit-remaining']).toBeDefined(); + }); + + it('skipSuccessfulRequests=true не учитывает 2xx ответы в счётчике', async () => { + const app = buildApp({ max: 2, skipSuccessfulRequests: true }); + + // 5 успешных запросов подряд — не должны исчерпывать лимит + for (let i = 0; i < 5; i++) { + const r = await request(app).post('/login').send({}); + expect(r.status).toBe(200); + } + }); +}); diff --git a/server/package-lock.json b/server/package-lock.json index 6e77a61..3d5258b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", + "express-rate-limit": "^8.5.1", "joi": "^17.9.2", "jsonwebtoken": "^9.0.0", "multer": "^1.4.5-lts.1", @@ -3039,6 +3040,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3760,7 +3779,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", - "optional": true, "engines": { "node": ">= 12" } diff --git a/server/package.json b/server/package.json index c7ae88e..6b1cf01 100644 --- a/server/package.json +++ b/server/package.json @@ -22,6 +22,7 @@ "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", + "express-rate-limit": "^8.5.1", "joi": "^17.9.2", "jsonwebtoken": "^9.0.0", "multer": "^1.4.5-lts.1", diff --git a/server/routes/api.js b/server/routes/api.js index 4c15960..48edf0d 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -3,9 +3,41 @@ */ const express = require('express'); const router = express.Router(); +const rateLimit = require('express-rate-limit'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); + +// В тестах (NODE_ENV=test для Jest или NODE_ENV=e2e для Playwright) лимиты +// поднимаем на потолок — иначе интеграционные/E2E тесты, которые регистрируют +// сотни директоров подряд из одного IP, упрутся в 429. +const isTestEnv = process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e'; + +// Брутфорс-защита логина: 10 неуспешных попыток на 15 минут с одного IP +// (успешные ответы не считаются — skipSuccessfulRequests). +const loginRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: isTestEnv ? 100000 : 10, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, + message: { + success: false, + message: 'Слишком много попыток входа. Попробуйте через 15 минут.', + }, +}); + +// Регистрация: 5 аккаунтов в час с одного IP (антиспам). +const registerRateLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: isTestEnv ? 100000 : 5, + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + message: 'Слишком много регистраций с этого IP. Попробуйте позже.', + }, +}); const config = require('../config'); const { authenticateToken } = require('../middleware/auth'); const authController = require('../controllers/auth'); @@ -46,8 +78,8 @@ router.get('/health', (req, res) => { }); // Маршруты аутентификации -router.post('/auth/login', authController.login); -router.post('/auth/register', authController.register); +router.post('/auth/login', loginRateLimiter, authController.login); +router.post('/auth/register', registerRateLimiter, authController.register); router.get('/auth/me', authenticateToken, authController.getCurrentUser); router.get('/profile', authenticateToken, authController.getCurrentUser); // Добавлен маршрут для совместимости с фронтендом router.post('/leads/incoming', callCenterRoutes.receiveIncomingLead);