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