const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); const helmet = require('helmet'); // rateLimit.js: mutationLimiter (POST-роуты) и exportLimiter (GET /export) const { mutationLimiter, exportLimiter } = require('./src/middleware/rateLimit'); const { doubleCsrf } = require('csrf-csrf'); require('dotenv').config(); const { checkConnection } = require('./src/db'); const { initAuth, requireAdmin } = require('./src/auth'); const { aggregateCIDRs } = require('./src/validators'); const q = require('./src/queries'); // config.js: MOCK_USERS (мок-пользователи для dev) и backUrl (хелпер редиректа) const { MOCK_USERS, backUrl } = require('./src/config'); const app = express(); const PORT = process.env.PORT || 3000; app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // ── Безопасность HTTP-заголовков ─────────────────────────────────────────────── // helmet выставляет: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, // Referrer-Policy и пр. CSP отключён явно — EJS использует inline-стили/скрипты. app.use(helmet({ contentSecurityPolicy: false })); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true, limit: '32kb' })); app.use(cookieParser()); 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 app.get('/healthz', (req, res) => res.send('OK')); // JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме) if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler); // /export — агрегированный список всех активных CIDR по всем компаниям. // ТЗ: «авторизация не требуется, доступ ограничивается на сетевом уровне». // Маршрут регистрируется ДО auth.middleware чтобы не требовать jwt-cookie. // exportLimiter: не более 20 запросов/мин с одного IP (защита от scraping). app.get('/export', exportLimiter, async (req, res) => { try { // Получаем все активные CIDR без фильтра по компании const cidrs = await q.getExportCIDRs(); // Суммаризация: объединяем пересекающиеся диапазоны → минимальный набор const aggregated = aggregateCIDRs(cidrs); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Disposition', 'inline; filename="whitelist.txt"'); res.send(aggregated.join('\n') + (aggregated.length ? '\n' : '')); } catch (e) { console.error('Export error:', e); res.status(500).send('Export error'); } }); // Страница входа — отдаём форму выбора мок-пользователя. // Генерируем 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. // 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 { const token = await auth.issue({ clientId: user.clientId, companyId: user.companyId, companyName: user.companyName, email: user.email, }); res.cookie('jwt', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000, // 24 часа }); res.redirect(req.query.returnTo || '/'); } catch (e) { res.redirect('/login?error=' + encodeURIComponent('Ошибка выпуска токена')); } }); // Выход: очищаем cookie и возвращаем на /login app.get('/logout', (req, res) => { res.clearCookie('jwt'); res.redirect('/login'); }); // ── Все маршруты ниже требуют валидного JWT (проверяет auth.middleware) ─────── app.use(auth.middleware); // ── Главная страница: список белых адресов ──────────────────────────────────── app.get('/', async (req, res) => { const { clientId, companyName, isAdmin } = req.user; try { if (isAdmin) { // Администратор видит все компании с фильтром по выбранной. // ?company= — числовой PK из таблицы companies. const companies = await q.getAllCompanies(); const selectedId = req.query.company ? parseInt(req.query.company, 10) : (companies[0] ? companies[0].id : null); const selectedCompany = companies.find(c => c.id === selectedId) || companies[0] || null; const entries = selectedCompany ? await q.listEntries(selectedCompany.id) : []; const limit = selectedCompany ? await q.getLimit(selectedCompany) : 0; return res.render('index', { entries, limit, used: entries.length, user: req.user, isAdmin: true, companies, selectedCompany, error: req.query.error || null, message: req.query.message || null, wasNormalized: req.query.wasNormalized === '1', // CSRF-токен для форм (add, edit, delete) на странице csrfToken: generateCsrfToken(req, res), }); } // Обычный пользователь: только его компания const company = await q.getOrCreateCompany(clientId, companyName); const limit = await q.getLimit(company); const entries = await q.listEntries(company.id); res.render('index', { entries, limit, used: entries.length, user: req.user, isAdmin: false, companies: null, selectedCompany: company, 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); res.render('index', { entries: [], limit: 15, used: 0, user: req.user, isAdmin, companies: null, selectedCompany: null, error: 'Ошибка загрузки данных: ' + e.message, message: null, wasNormalized: false, csrfToken: generateCsrfToken(req, res), }); } }); // ── Добавление записи ───────────────────────────────────────────────────────── // mutationLimiter: не более 30 POST-запросов/мин с одного IP. // doubleCsrfProtection: проверяет CSRF-токен из скрытого поля формы. app.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => { const { value, comment } = req.body; const { clientId, companyName, email, isAdmin } = req.user; let company; try { if (isAdmin && req.body.company_id) { // Администратор добавляет в выбранную компанию (скрытое поле company_id) const id = parseInt(req.body.company_id, 10); if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id'); company = await q.getCompanyById(id); if (!company) throw new Error('Компания не найдена'); } else { // Пользователь: только своя компания (из токена) company = await q.getOrCreateCompany(clientId, companyName); } const result = await q.createEntry(company.id, value, comment, email); const msg = result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено'; res.redirect(backUrl(isAdmin, company.id, { message: msg, wasNormalized: result.wasNormalized ? '1' : '0', })); } catch (e) { // При ошибке возвращаемся к тому же виду что был const cid = company ? company.id : req.body.company_id; res.redirect(backUrl(isAdmin, cid, { error: e.message })); } }); // ── Редактирование записи ───────────────────────────────────────────────────── 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); let company; try { if (isAdmin && req.body.company_id) { const id = parseInt(req.body.company_id, 10); if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id'); company = await q.getCompanyById(id); if (!company) throw new Error('Компания не найдена'); } else { company = await q.getOrCreateCompany(clientId, companyName); } const result = await q.updateEntry(entryId, company.id, value, comment, email); const msg = result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Сохранено'; res.redirect(backUrl(isAdmin, company.id, { message: msg, wasNormalized: result.wasNormalized ? '1' : '0', })); } catch (e) { const cid = company ? company.id : req.body.company_id; res.redirect(backUrl(isAdmin, cid, { error: e.message })); } }); // ── Удаление записи (soft delete) ──────────────────────────────────────────── app.post('/delete/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => { const { clientId, companyName, email, isAdmin } = req.user; const entryId = parseInt(req.params.id, 10); let company; try { if (isAdmin && req.body.company_id) { const id = parseInt(req.body.company_id, 10); if (!Number.isFinite(id) || id <= 0) throw new Error('Некорректный company_id'); company = await q.getCompanyById(id); if (!company) throw new Error('Компания не найдена'); } else { company = await q.getOrCreateCompany(clientId, companyName); } await q.deleteEntry(entryId, company.id, email); res.redirect(backUrl(isAdmin, company.id, { message: 'Запись удалена' })); } catch (e) { const cid = company ? company.id : req.body.company_id; res.redirect(backUrl(isAdmin, cid, { error: e.message })); } }); // ── Журнал аудита (только admin) ────────────────────────────────────────────── // ТЗ 4.6: «журнал доступен для просмотра только администратору» app.get('/audit', requireAdmin, async (req, res) => { try { // Если передан ?company=X — показываем только эту компанию const companyId = req.query.company ? parseInt(req.query.company, 10) : null; const companies = await q.getAllCompanies(); const rows = await q.getAudit(companyId || null); res.render('audit', { user: req.user, rows, companies, selectedCompanyId: companyId, error: req.query.error || null, }); } catch (e) { console.error('GET /audit error:', e); res.render('audit', { user: req.user, rows: [], companies: [], selectedCompanyId: null, error: 'Ошибка загрузки: ' + e.message, }); } }); // ── Страница управления лимитами (только admin) ─────────────────────────────── app.get('/admin', requireAdmin, async (req, res) => { try { const companies = await q.getAllCompanies(); const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; res.render('admin', { user: req.user, companies, 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); res.status(500).send('Ошибка: ' + e.message); } }); // ── Изменение лимита компании (только admin) ────────────────────────────────── // ТЗ 4.5: «администратор может задать индивидуальный лимит» 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 компании')); } try { const raw = req.body.limit; let newLimit; if (raw === '' || raw === null || raw === undefined) { // Пустое поле = сброс на глобальный дефолт newLimit = null; } else { newLimit = parseInt(raw, 10); if (!Number.isFinite(newLimit) || newLimit < 0) { throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса'); } } await q.setLimit(companyId, newLimit); const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`; res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`)); } catch (e) { res.redirect('/admin?error=' + encodeURIComponent(e.message)); } }); // ── 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')) .catch(e => console.error('DB not ready:', e.message)); app.listen(PORT, () => console.log('Server on port ' + PORT)); } start().catch(e => { console.error('Startup error:', e); process.exit(1); });