feat: CSRF защита (double-submit cookie) — все мутирующие POST-роуты, login-форма
This commit is contained in:
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user