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
+55 -10
View File
@@ -3,6 +3,7 @@ const cookieParser = require('cookie-parser');
const path = require('path');
const helmet = require('helmet');
const { rateLimit } = require('express-rate-limit');
const { doubleCsrf } = require('csrf-csrf');
require('dotenv').config();
const { checkConnection } = require('./src/db');
const { initAuth, requireAdmin } = require('./src/auth');
@@ -67,6 +68,22 @@ function backUrl(isAdmin, companyId, extra = {}) {
async function start() {
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) ──────────────────────────────────
// k8s liveness probe
@@ -94,13 +111,16 @@ async function start() {
}
});
// Страница входа — отдаём форму выбора мок-пользователя
app.get('/login', (req, res) =>
res.render('login', { users: MOCK_USERS, error: req.query.error || null })
);
// Страница входа — отдаём форму выбора мок-пользователя.
// Генерируем CSRF-токен и передаём в шаблон для скрытого поля формы.
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
app.post('/login', async (req, res) => {
// Обработка логина: создаём JWT и кладём в httpOnly cookie.
// doubleCsrfProtection проверяет, что форма отправлена с нашей страницы /login.
app.post('/login', doubleCsrfProtection, async (req, res) => {
const user = MOCK_USERS.find(u => u.id === req.body.user);
if (!user) return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
try {
@@ -149,6 +169,8 @@ async function start() {
error: req.query.error || null,
message: req.query.message || null,
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,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
// CSRF-токен для форм (add, edit, delete)
csrfToken: generateCsrfToken(req, res),
});
} catch (e) {
console.error('GET / error:', e);
@@ -172,13 +196,15 @@ async function start() {
companies: null, selectedCompany: null,
error: 'Ошибка загрузки данных: ' + e.message,
message: null, wasNormalized: false,
csrfToken: generateCsrfToken(req, res),
});
}
});
// ── Добавление записи ─────────────────────────────────────────────────────────
// 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 { 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 { clientId, companyName, email, isAdmin } = req.user;
const entryId = parseInt(req.params.id, 10);
@@ -242,7 +268,7 @@ async function start() {
});
// ── Удаление записи (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 entryId = parseInt(req.params.id, 10);
@@ -300,6 +326,8 @@ async function start() {
defaultLimit,
message: req.query.message || null,
error: req.query.error || null,
// CSRF-токен для форм /admin/limit/:id на странице
csrfToken: generateCsrfToken(req, res),
});
} catch (e) {
console.error('GET /admin error:', e);
@@ -309,7 +337,7 @@ async function start() {
// ── Изменение лимита компании (только admin) ──────────────────────────────────
// ТЗ 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);
if (!Number.isFinite(companyId) || companyId <= 0) {
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()
.then(() => console.log('DB connected'))