diff --git a/package-lock.json b/package-lock.json index 2d233c6..3b80a6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.21.1", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", "pg": "^8.13.1" } @@ -332,6 +334,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -449,6 +468,17 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -484,6 +514,14 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index d9b04c8..c08e9ee 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.21.1", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", "pg": "^8.13.1" } diff --git a/server.js b/server.js index f9389cc..186fe2a 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,8 @@ const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); +const helmet = require('helmet'); +const { rateLimit } = require('express-rate-limit'); require('dotenv').config(); const { checkConnection } = require('./src/db'); const { initAuth, requireAdmin } = require('./src/auth'); @@ -12,6 +14,32 @@ const PORT = process.env.PORT || 3000; app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); + +// ── Безопасность HTTP-заголовков ─────────────────────────────────────────────── +// helmet выставляет: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, +// Referrer-Policy и пр. CSP отключён явно — EJS использует inline-стили/скрипты. +app.use(helmet({ contentSecurityPolicy: false })); + +// ── Rate limiting ────────────────────────────────────────────────────────────── +// Мутирующие POST-маршруты: не более 30 запросов за 1 минуту с одного IP. +// Это защищает от случайного flood и простых скриптовых атак. +const mutationLimiter = rateLimit({ + windowMs: 60_000, // окно 1 минута + limit: 30, // макс 30 POST-запросов + standardHeaders: 'draft-7', // Retry-After в заголовках + legacyHeaders: false, + message: { error: 'Слишком много запросов. Подождите минуту.' }, +}); + +// /export доступен публично — ограничиваем отдельно, мягче (по IP) +const exportLimiter = rateLimit({ + windowMs: 60_000, + limit: 20, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: 'Слишком много запросов к /export.', +}); + app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true, limit: '32kb' })); app.use(cookieParser()); @@ -50,7 +78,8 @@ async function start() { // /export — агрегированный список всех активных CIDR по всем компаниям. // ТЗ: «авторизация не требуется, доступ ограничивается на сетевом уровне». // Маршрут регистрируется ДО auth.middleware чтобы не требовать jwt-cookie. - app.get('/export', async (req, res) => { + // exportLimiter: не более 20 запросов/мин с одного IP (защита от scraping). + app.get('/export', exportLimiter, async (req, res) => { try { // Получаем все активные CIDR без фильтра по компании const cidrs = await q.getExportCIDRs(); @@ -148,7 +177,8 @@ async function start() { }); // ── Добавление записи ───────────────────────────────────────────────────────── - app.post('/add', async (req, res) => { + // mutationLimiter: не более 30 POST-запросов/мин с одного IP. + app.post('/add', mutationLimiter, async (req, res) => { const { value, comment } = req.body; const { clientId, companyName, email, isAdmin } = req.user; @@ -181,7 +211,7 @@ async function start() { }); // ── Редактирование записи ───────────────────────────────────────────────────── - app.post('/edit/:id', async (req, res) => { + app.post('/edit/:id', mutationLimiter, async (req, res) => { const { value, comment } = req.body; const { clientId, companyName, email, isAdmin } = req.user; const entryId = parseInt(req.params.id, 10); @@ -212,7 +242,7 @@ async function start() { }); // ── Удаление записи (soft delete) ──────────────────────────────────────────── - app.post('/delete/:id', async (req, res) => { + app.post('/delete/:id', mutationLimiter, async (req, res) => { const { clientId, companyName, email, isAdmin } = req.user; const entryId = parseInt(req.params.id, 10); @@ -279,7 +309,7 @@ async function start() { // ── Изменение лимита компании (только admin) ────────────────────────────────── // ТЗ 4.5: «администратор может задать индивидуальный лимит» - app.post('/admin/limit/:companyId', requireAdmin, async (req, res) => { + app.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, async (req, res) => { const companyId = parseInt(req.params.companyId, 10); if (!Number.isFinite(companyId) || companyId <= 0) { return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании'));