feat: helmet (security headers) + rate-limit (30/min mutations, 20/min export)

This commit is contained in:
2026-05-30 13:22:54 +03:00
parent c319e76c74
commit 2332827326
3 changed files with 75 additions and 5 deletions
+38
View File
@@ -12,6 +12,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.13.1" "pg": "^8.13.1"
} }
@@ -332,6 +334,23 @@
"url": "https://opencollective.com/express" "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": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -449,6 +468,17 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "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", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+2
View File
@@ -12,6 +12,8 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.13.1" "pg": "^8.13.1"
} }
+35 -5
View File
@@ -1,6 +1,8 @@
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
const helmet = require('helmet');
const { rateLimit } = require('express-rate-limit');
require('dotenv').config(); require('dotenv').config();
const { checkConnection } = require('./src/db'); const { checkConnection } = require('./src/db');
const { initAuth, requireAdmin } = require('./src/auth'); const { initAuth, requireAdmin } = require('./src/auth');
@@ -12,6 +14,32 @@ const PORT = process.env.PORT || 3000;
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); 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.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true, limit: '32kb' })); app.use(express.urlencoded({ extended: true, limit: '32kb' }));
app.use(cookieParser()); app.use(cookieParser());
@@ -50,7 +78,8 @@ async function start() {
// /export — агрегированный список всех активных CIDR по всем компаниям. // /export — агрегированный список всех активных CIDR по всем компаниям.
// ТЗ: «авторизация не требуется, доступ ограничивается на сетевом уровне». // ТЗ: «авторизация не требуется, доступ ограничивается на сетевом уровне».
// Маршрут регистрируется ДО auth.middleware чтобы не требовать jwt-cookie. // Маршрут регистрируется ДО auth.middleware чтобы не требовать jwt-cookie.
app.get('/export', async (req, res) => { // exportLimiter: не более 20 запросов/мин с одного IP (защита от scraping).
app.get('/export', exportLimiter, async (req, res) => {
try { try {
// Получаем все активные CIDR без фильтра по компании // Получаем все активные CIDR без фильтра по компании
const cidrs = await q.getExportCIDRs(); 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 { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user; 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 { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user; const { clientId, companyName, email, isAdmin } = req.user;
const entryId = parseInt(req.params.id, 10); const entryId = parseInt(req.params.id, 10);
@@ -212,7 +242,7 @@ async function start() {
}); });
// ── Удаление записи (soft delete) ──────────────────────────────────────────── // ── Удаление записи (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 { clientId, companyName, email, isAdmin } = req.user;
const entryId = parseInt(req.params.id, 10); const entryId = parseInt(req.params.id, 10);
@@ -279,7 +309,7 @@ async function start() {
// ── Изменение лимита компании (только admin) ────────────────────────────────── // ── Изменение лимита компании (только admin) ──────────────────────────────────
// ТЗ 4.5: «администратор может задать индивидуальный лимит» // ТЗ 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); const companyId = parseInt(req.params.companyId, 10);
if (!Number.isFinite(companyId) || companyId <= 0) { if (!Number.isFinite(companyId) || companyId <= 0) {
return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании')); return res.redirect('/admin?error=' + encodeURIComponent('Некорректный ID компании'));