diff --git a/package-lock.json b/package-lock.json index 3b80a6e..ddfde00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "cookie-parser": "^1.4.7", + "csrf-csrf": "^4.0.3", "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.21.1", @@ -165,6 +166,14 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "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": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/package.json b/package.json index c08e9ee..6e7e197 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "cookie-parser": "^1.4.7", + "csrf-csrf": "^4.0.3", "dotenv": "^16.4.7", "ejs": "^3.1.10", "express": "^4.21.1", diff --git a/server.js b/server.js index 186fe2a..4f43684 100644 --- a/server.js +++ b/server.js @@ -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( + '
' + + 'Недействительный CSRF-токен. ' + + 'Вернитесь назад и повторите действие.' + + '
' + ); + } + console.error('Unhandled error:', err); + res.status(500).send('Внутренняя ошибка сервера'); + }); + // ── Старт сервера ───────────────────────────────────────────────────────────── checkConnection() .then(() => console.log('DB connected')) diff --git a/views/admin.ejs b/views/admin.ejs index 05f42be..ef073e0 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -148,6 +148,8 @@