feat: CSRF защита (double-submit cookie) — все мутирующие POST-роуты, login-форма
This commit is contained in:
Generated
+9
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -148,6 +148,8 @@
|
||||
<form method="POST" action="/admin/limit/<%= c.id %>"
|
||||
style="display:flex;align-items:center;gap:.5rem;"
|
||||
onsubmit="return confirmLimit(this, '<%= c.name %>')">
|
||||
<%# CSRF-токен: одно значение на всю страницу (генерируется сервером) %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<input
|
||||
class="limit-input"
|
||||
type="number"
|
||||
|
||||
@@ -239,6 +239,8 @@
|
||||
// и использует clientId из токена — защита от подмены.
|
||||
%>
|
||||
<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="field">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
@@ -308,6 +310,8 @@
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Удалить запись <%= e.value_cidr %>?')">
|
||||
<input type="hidden" name="company_id" value="<%= companyPk || '' %>">
|
||||
<%# CSRF-токен: одно значение на всю страницу, сгенерировано сервером %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button class="btn btn-danger">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -337,6 +341,8 @@
|
||||
<form method="POST" id="edit-form">
|
||||
<!-- Скрытые поля: action (action='/edit/N') и company_id заполняет openEditModal() -->
|
||||
<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;">
|
||||
<label>IPv4 адрес или подсеть CIDR</label>
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
<% if (error) { %><div class="error"><%= error %></div><% } %>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<%# CSRF-токен: защита от CSRF-атаки на форму входа %>
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<div class="field">
|
||||
<label>Пользователь</label>
|
||||
<select name="user">
|
||||
|
||||
Reference in New Issue
Block a user