feat: CSRF защита (double-submit cookie) — все мутирующие POST-роуты, login-форма

This commit is contained in:
2026-05-30 13:28:01 +03:00
parent 2332827326
commit 695ed231e6
6 changed files with 75 additions and 10 deletions
+9
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"csrf-csrf": "^4.0.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.1",
@@ -165,6 +166,14 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
}, },
"node_modules/csrf-csrf": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz",
"integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==",
"dependencies": {
"http-errors": "^2.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+1
View File
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"csrf-csrf": "^4.0.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.1",
+55 -10
View File
@@ -3,6 +3,7 @@ const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
const helmet = require('helmet'); const helmet = require('helmet');
const { rateLimit } = require('express-rate-limit'); const { rateLimit } = require('express-rate-limit');
const { doubleCsrf } = require('csrf-csrf');
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');
@@ -67,6 +68,22 @@ function backUrl(isAdmin, companyId, extra = {}) {
async function start() { async function start() {
const auth = await initAuth(); const auth = await initAuth();
// ── CSRF защита (double-submit cookie pattern) ─────────────────────────────
// getSecret: секрет для HMAC подписи CSRF-токена. В проде берётся из env.
// getSessionIdentifier: привязывает токен к конкретной сессии (JWT cookie).
// Если пользователь не залогинен (пустая строка) — токен не привязан к сессии,
// но это допустимо для login-формы (mock-auth).
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod',
getSessionIdentifier: (req) => req.cookies.jwt || '',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
httpOnly: true,
},
size: 64, // длина токена в байтах
});
// ── Публичные маршруты (до auth.middleware) ────────────────────────────────── // ── Публичные маршруты (до auth.middleware) ──────────────────────────────────
// k8s liveness probe // k8s liveness probe
@@ -94,13 +111,16 @@ async function start() {
} }
}); });
// Страница входа — отдаём форму выбора мок-пользователя // Страница входа — отдаём форму выбора мок-пользователя.
app.get('/login', (req, res) => // Генерируем CSRF-токен и передаём в шаблон для скрытого поля формы.
res.render('login', { users: MOCK_USERS, error: req.query.error || null }) app.get('/login', (req, res) => {
); const csrfToken = generateCsrfToken(req, res);
res.render('login', { users: MOCK_USERS, error: req.query.error || null, csrfToken });
});
// Обработка логина: создаём JWT и кладём в httpOnly cookie // Обработка логина: создаём JWT и кладём в httpOnly cookie.
app.post('/login', async (req, res) => { // doubleCsrfProtection проверяет, что форма отправлена с нашей страницы /login.
app.post('/login', doubleCsrfProtection, async (req, res) => {
const user = MOCK_USERS.find(u => u.id === req.body.user); const user = MOCK_USERS.find(u => u.id === req.body.user);
if (!user) return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден')); if (!user) return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
try { try {
@@ -149,6 +169,8 @@ async function start() {
error: req.query.error || null, error: req.query.error || null,
message: req.query.message || null, message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1', wasNormalized: req.query.wasNormalized === '1',
// CSRF-токен для форм (add, edit, delete) на странице
csrfToken: generateCsrfToken(req, res),
}); });
} }
@@ -163,6 +185,8 @@ async function start() {
error: req.query.error || null, error: req.query.error || null,
message: req.query.message || null, message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1', wasNormalized: req.query.wasNormalized === '1',
// CSRF-токен для форм (add, edit, delete)
csrfToken: generateCsrfToken(req, res),
}); });
} catch (e) { } catch (e) {
console.error('GET / error:', e); console.error('GET / error:', e);
@@ -172,13 +196,15 @@ async function start() {
companies: null, selectedCompany: null, companies: null, selectedCompany: null,
error: 'Ошибка загрузки данных: ' + e.message, error: 'Ошибка загрузки данных: ' + e.message,
message: null, wasNormalized: false, message: null, wasNormalized: false,
csrfToken: generateCsrfToken(req, res),
}); });
} }
}); });
// ── Добавление записи ───────────────────────────────────────────────────────── // ── Добавление записи ─────────────────────────────────────────────────────────
// mutationLimiter: не более 30 POST-запросов/мин с одного IP. // mutationLimiter: не более 30 POST-запросов/мин с одного IP.
app.post('/add', mutationLimiter, async (req, res) => { // doubleCsrfProtection: проверяет CSRF-токен из скрытого поля формы.
app.post('/add', mutationLimiter, doubleCsrfProtection, 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;
@@ -211,7 +237,7 @@ async function start() {
}); });
// ── Редактирование записи ───────────────────────────────────────────────────── // ── Редактирование записи ─────────────────────────────────────────────────────
app.post('/edit/:id', mutationLimiter, async (req, res) => { app.post('/edit/:id', mutationLimiter, doubleCsrfProtection, 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);
@@ -242,7 +268,7 @@ async function start() {
}); });
// ── Удаление записи (soft delete) ──────────────────────────────────────────── // ── Удаление записи (soft delete) ────────────────────────────────────────────
app.post('/delete/:id', mutationLimiter, async (req, res) => { app.post('/delete/:id', mutationLimiter, doubleCsrfProtection, 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);
@@ -300,6 +326,8 @@ async function start() {
defaultLimit, defaultLimit,
message: req.query.message || null, message: req.query.message || null,
error: req.query.error || null, error: req.query.error || null,
// CSRF-токен для форм /admin/limit/:id на странице
csrfToken: generateCsrfToken(req, res),
}); });
} catch (e) { } catch (e) {
console.error('GET /admin error:', e); console.error('GET /admin error:', e);
@@ -309,7 +337,7 @@ async function start() {
// ── Изменение лимита компании (только admin) ────────────────────────────────── // ── Изменение лимита компании (только admin) ──────────────────────────────────
// ТЗ 4.5: «администратор может задать индивидуальный лимит» // ТЗ 4.5: «администратор может задать индивидуальный лимит»
app.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, async (req, res) => { app.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection, 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 компании'));
@@ -336,6 +364,23 @@ async function start() {
} }
}); });
// ── CSRF error handler ────────────────────────────────────────────────────────
// doubleCsrfProtection вызывает next(err) с кодом 403 при невалидном токене.
// Ловим ДО общего обработчика — возвращаем понятное сообщение пользователю.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err && (err.status === 403 || err.code === 'EBADCSRFTOKEN')) {
return res.status(403).send(
'<p style="font-family:sans-serif;padding:2rem">' +
'Недействительный CSRF-токен. ' +
'<a href="javascript:history.back()">Вернитесь назад</a> и повторите действие.' +
'</p>'
);
}
console.error('Unhandled error:', err);
res.status(500).send('Внутренняя ошибка сервера');
});
// ── Старт сервера ───────────────────────────────────────────────────────────── // ── Старт сервера ─────────────────────────────────────────────────────────────
checkConnection() checkConnection()
.then(() => console.log('DB connected')) .then(() => console.log('DB connected'))
+2
View File
@@ -148,6 +148,8 @@
<form method="POST" action="/admin/limit/<%= c.id %>" <form method="POST" action="/admin/limit/<%= c.id %>"
style="display:flex;align-items:center;gap:.5rem;" style="display:flex;align-items:center;gap:.5rem;"
onsubmit="return confirmLimit(this, '<%= c.name %>')"> onsubmit="return confirmLimit(this, '<%= c.name %>')">
<%# CSRF-токен: одно значение на всю страницу (генерируется сервером) %>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input <input
class="limit-input" class="limit-input"
type="number" type="number"
+6
View File
@@ -239,6 +239,8 @@
// и использует clientId из токена — защита от подмены. // и использует clientId из токена — защита от подмены.
%> %>
<input type="hidden" name="company_id" value="<%= companyPk || '' %>"> <input type="hidden" name="company_id" value="<%= companyPk || '' %>">
<%# CSRF-токен: защита от межсайтовой подделки запроса (double-submit cookie) %>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div class="form-grid"> <div class="form-grid">
<div class="field"> <div class="field">
<label>IPv4 адрес или подсеть CIDR</label> <label>IPv4 адрес или подсеть CIDR</label>
@@ -308,6 +310,8 @@
style="display:inline" style="display:inline"
onsubmit="return confirm('Удалить запись <%= e.value_cidr %>?')"> onsubmit="return confirm('Удалить запись <%= e.value_cidr %>?')">
<input type="hidden" name="company_id" value="<%= companyPk || '' %>"> <input type="hidden" name="company_id" value="<%= companyPk || '' %>">
<%# CSRF-токен: одно значение на всю страницу, сгенерировано сервером %>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-danger">Удалить</button> <button class="btn btn-danger">Удалить</button>
</form> </form>
</td> </td>
@@ -337,6 +341,8 @@
<form method="POST" id="edit-form"> <form method="POST" id="edit-form">
<!-- Скрытые поля: action (action='/edit/N') и company_id заполняет openEditModal() --> <!-- Скрытые поля: action (action='/edit/N') и company_id заполняет openEditModal() -->
<input type="hidden" id="edit-company-id" name="company_id" value=""> <input type="hidden" id="edit-company-id" name="company_id" value="">
<%# CSRF-токен: значение рендерится сервером один раз для всей страницы %>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div class="field" style="margin-bottom:.75rem;"> <div class="field" style="margin-bottom:.75rem;">
<label>IPv4 адрес или подсеть CIDR</label> <label>IPv4 адрес или подсеть CIDR</label>
+2
View File
@@ -56,6 +56,8 @@
<% if (error) { %><div class="error"><%= error %></div><% } %> <% if (error) { %><div class="error"><%= error %></div><% } %>
<form method="POST" action="/login"> <form method="POST" action="/login">
<%# CSRF-токен: защита от CSRF-атаки на форму входа %>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div class="field"> <div class="field">
<label>Пользователь</label> <label>Пользователь</label>
<select name="user"> <select name="user">