feat: helmet (security headers) + rate-limit (30/min mutations, 20/min export)
This commit is contained in:
Generated
+38
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 компании'));
|
||||||
|
|||||||
Reference in New Issue
Block a user