v0.5.11: зачистка мёртвого кода + новые интеграционные тесты

Удалено:
- src/routes/*.js (5 файлов) — старая монолитная архитектура
- views/dev-login.ejs — старый dev-логин
- tests/debug-session.js — тесты старой архитектуры

Добавлено:
- safeReturn в src/auth.js
- Новые интеграционные тесты tests/integration.js (67 тестов, UI+API)

Обновлено:
- tests/run-tests.js — убраны старые require, добавлены новые модули
- tests/tz-compliance.js — /exp → /api/v1/entries/export
- server.js — убран require('./src/routes/exp')
This commit is contained in:
2026-06-02 18:04:33 +03:00
parent 4694b7e8a6
commit 15b458eb35
12 changed files with 357 additions and 1767 deletions
-1
View File
@@ -76,7 +76,6 @@ async function start() {
// JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме) // JWKS endpoint для валидации токенов внешними сервисами (только в mock-режиме)
if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler); if (auth.jwksHandler) app.get('/.well-known/jwks.json', auth.jwksHandler);
app.use(require('./src/routes/exp')); // TODO: удалить (временный /exp без авторизации)
// ── REST API v1 (Bearer JWT, без сессий/CSRF) ───────────────────────────── // ── REST API v1 (Bearer JWT, без сессий/CSRF) ─────────────────────────────
const { createApiRouter } = require('./src/api/index'); const { createApiRouter } = require('./src/api/index');
+12 -1
View File
@@ -332,4 +332,15 @@ function httpPost(url, body, headers) {
}); });
} }
module.exports = { initAuth, requireAdmin }; /**
* Валидирует redirect-цель: разрешает только локальные пути.
* Защита от open redirect: //evil.com и https://evil.com → '/'.
*/
function safeReturn(target) {
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
return target;
}
return '/';
}
module.exports = { initAuth, requireAdmin, safeReturn };
-139
View File
@@ -1,139 +0,0 @@
/**
* src/routes/admin.js — маршруты администратора.
*
* Маршруты:
* GET /audit — журнал аудита всех изменений
* GET /admin — страница управления лимитами компаний
* POST /admin/limit/:companyId — изменить или сбросить лимит компании
*
* Все маршруты защищены requireAdmin — возвращают 403 для не-admin.
* Дополнительно POST /admin/limit:
* - mutationLimiter: не более 30 запросов/мин с одного IP
* - doubleCsrfProtection: проверка CSRF-токена
*
* Зависимости (через deps):
* q — объект из src/queries.js (все DB-операции)
* requireAdmin — middleware из src/auth.js
* doubleCsrfProtection — middleware проверки CSRF
* mutationLimiter — middleware ограничения частоты POST
* generateCsrfToken — функция генерации CSRF-токена для шаблонов
*
* @param {{ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken }} deps
* @returns {import('express').Router}
*/
'use strict';
const { Router } = require('express');
function createRouter({ q, requireAdmin, doubleCsrfProtection, mutationLimiter, generateCsrfToken }) {
const router = Router();
// ── GET /audit ────────────────────────────────────────────────────────────────
// Журнал всех изменений белого списка (CREATE / UPDATE / DELETE).
// ТЗ 4.6: «журнал доступен для просмотра только администратору».
//
// ?company=<id> — фильтр по конкретной компании (необязательно).
// Максимум 500 строк (ограничение в queries.js).
router.get('/audit', requireAdmin, async (req, res) => {
try {
// Парсим фильтр: если ?company не задан — показываем все компании
const companyId = req.query.company ? parseInt(req.query.company, 10) : null;
// Грузим список компаний для dropdown-фильтра в шаблоне
const companies = await q.getAllCompanies();
// Строки аудита (JOIN с companies → company_name, client_id)
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);
// При ошибке рендерим страницу с пустым списком, не падаем с 500
res.render('audit', {
user: req.user,
rows: [],
companies: [],
selectedCompanyId: null,
error: 'Ошибка загрузки: ' + e.message,
});
}
});
// ── GET /admin ────────────────────────────────────────────────────────────────
// Страница управления лимитами: таблица компаний с их active_count и лимитом.
// Для каждой компании — форма изменения лимита (POST /admin/limit/:id).
//
// DEFAULT_LIMIT из env — используется как дефолт при отображении и при сбросе.
router.get('/admin', requireAdmin, async (req, res) => {
try {
const companies = await q.getAllCompanies();
// Если DEFAULT_LIMIT не задан в env — используем 15 (как в queries.js)
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);
}
});
// ── POST /admin/limit/:companyId ──────────────────────────────────────────────
// Изменить или сбросить индивидуальный лимит компании.
// ТЗ 4.5: «администратор может задать индивидуальный лимит».
//
// Логика:
// - Пустое поле limit → null → сброс на DEFAULT_LIMIT (глобальный дефолт)
// - Число ≥ 0 → custom_limit = это число
// - Отрицательное или нечисловое → ошибка
router.post('/admin/limit/:companyId', requireAdmin, mutationLimiter, doubleCsrfProtection,
async (req, res) => {
// Валидация числового PK компании из URL-параметра
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) {
// Пустое поле — сброс: setLimit(id, null) → queries.js удаляет custom_limit
newLimit = null;
} else {
newLimit = parseInt(raw, 10);
if (!Number.isFinite(newLimit) || newLimit < 0) {
throw new Error('Лимит должен быть целым числом ≥ 0, или пустым для сброса');
}
}
await q.setLimit(companyId, newLimit);
// Формируем человекочитаемое сообщение для flash-уведомления
const label = newLimit === null ? 'сброшен на дефолт' : `установлен: ${newLimit}`;
res.redirect('/admin?message=' + encodeURIComponent(`Лимит ${label}`));
} catch (e) {
res.redirect('/admin?error=' + encodeURIComponent(e.message));
}
}
);
return router;
}
module.exports = { createRouter };
-232
View File
@@ -1,232 +0,0 @@
/**
* src/routes/auth.js — маршруты аутентификации.
*
* OIDC-режим (KC_CLIENT_ID + KC_CLIENT_SECRET заданы в окружении):
* GET /login → генерирует state, сохраняет в сессии, редиректит на Keycloak
* GET /callback → проверяет state, обменивает code→токен, кладёт user в сессию
* GET /logout → уничтожает сессию + редирект на KK logout
*
* Mock-режим (нет OIDC credentials):
* GET /login → рендерит views/login.ejs — список MOCK_USERS
* POST /login → CSRF → сохраняет выбранного user в сессию → /
* GET /logout → уничтожает сессию → /login
*
* Dev-backdoor (DEV_MODE=true или DEV_SECRET задан):
* GET /dev-login → рендерит views/dev-login.ejs (требует ?key если DEV_SECRET задан)
* POST /dev-login → CSRF → кладёт произвольного user в сессию → /
* Работает в ЛЮБОМ режиме (OIDC или mock) — обходит нормальный поток.
*
* @param {{ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS }} deps
* @returns {import('express').Router}
*/
'use strict';
const crypto = require('crypto');
const { Router } = require('express');
/**
* Валидирует redirect-цель: разрешает только локальные пути (начинается с '/',
* не начинается с '//').
* Защита от open redirect: //evil.com и https://evil.com → '/'.
* @param {string|undefined} target
* @returns {string}
*/
function safeReturn(target) {
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
return target;
}
return '/';
}
function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USERS, authLimiter }) {
const router = Router();
// ── GET /login ─────────────────────────────────────────────────────────────
router.get('/login', (req, res) => {
if (auth.isOidc) {
// OIDC: генерируем state (защита от CSRF на callback), редиректим на KK
const state = crypto.randomBytes(16).toString('hex');
req.session.oidcState = state;
req.session.returnTo = req.query.returnTo || '/';
return res.redirect(auth.buildAuthUrl(state));
}
// Mock-режим: форма выбора пользователя
const csrfToken = generateCsrfToken(req, res);
res.render('login', {
users: MOCK_USERS,
error: req.query.error || null,
csrfToken,
devLoginUrl: auth.devLoginEnabled ? '/dev-login' : null,
});
});
// ── POST /login (только mock-режим) ────────────────────────────────────────
router.post('/login', authLimiter, doubleCsrfProtection, (req, res) => {
if (auth.isOidc) return res.redirect('/login');
const user = MOCK_USERS.find(u => u.id === req.body.user);
if (!user) {
return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
}
const userData = {
email: user.email,
clientId: user.clientId,
companyId: user.companyId,
companyName: user.companyName,
isAdmin: user.role === 'admin',
};
// Добавляем allClientIds для поддержки мульти-компании
userData.allClientIds = user.clientId.split(',').map(s => s.trim()).filter(Boolean);
userData.activeClientId = userData.allClientIds[0] || user.clientId;
// session.regenerate() меняет идентификатор сессии — защита от session fixation (A07).
// Данные пишем уже в новую сессию.
req.session.regenerate((err) => {
if (err) { console.error('[login] session.regenerate error:', err); return res.status(500).send('Session error'); }
req.session.user = userData;
req.session.save((saveErr) => {
if (saveErr) { console.error('[login] session.save error:', saveErr); return res.status(500).send('Session error'); }
res.redirect(safeReturn(req.query.returnTo));
});
});
});
// ── GET /callback (только OIDC-режим) ──────────────────────────────────────
router.get('/callback', authLimiter, async (req, res) => {
if (!auth.isOidc) return res.redirect('/login');
const { code, state, error } = req.query;
if (error) {
console.error('[auth] Keycloak error:', error, req.query.error_description || '');
return res.redirect('/login?error=' + encodeURIComponent('Keycloak: ' + error));
}
// Проверяем state (защита от CSRF)
if (!state || state !== req.session.oidcState) {
return res.redirect('/login?error=' + encodeURIComponent('Недействительный state'));
}
delete req.session.oidcState;
try {
const { user, idToken, refreshToken } = await auth.exchangeCode(code);
// Сохраняем returnTo до regenerate, так как regenerate сбросит сессию.
const returnTo = safeReturn(req.session.returnTo);
// session.regenerate() — защита от session fixation (A07).
req.session.regenerate((err) => {
if (err) { console.error('[callback] session.regenerate error:', err); return res.status(500).send('Session error'); }
req.session.user = user;
req.session.idToken = idToken;
req.session.refreshToken = refreshToken;
req.session.save((saveErr) => {
if (saveErr) { console.error('[callback] session.save error:', saveErr); return res.status(500).send('Session error'); }
res.redirect(returnTo);
});
});
} catch (e) {
console.error('[auth] exchangeCode error:', e.message);
res.redirect('/login?error=' + encodeURIComponent('Ошибка авторизации: ' + e.message));
}
});
// ── GET /dev-login ─────────────────────────────────────────────────────────
//
// Тестовый вход в обход OIDC/mock. Доступен если:
// • DEV_MODE=true → без пароля
// • DEV_SECRET=xyz → с ?key=xyz или session.devKey
router.get('/dev-login', (req, res) => {
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
if (auth.DEV_SECRET) {
const keyOk = req.query.key === auth.DEV_SECRET || req.session.devKey === auth.DEV_SECRET;
if (!keyOk) return res.status(403).send('403 Forbidden: неверный dev-ключ');
req.session.devKey = auth.DEV_SECRET; // кешируем в сессии: ?key= нужен только раз
}
const csrfToken = generateCsrfToken(req, res);
res.render('dev-login', {
users: MOCK_USERS,
error: req.query.error || null,
csrfToken,
});
});
// ── POST /dev-login ────────────────────────────────────────────────────────
router.post('/dev-login', authLimiter, doubleCsrfProtection, (req, res) => {
if (!auth.devLoginEnabled) return res.status(404).send('Not Found');
if (auth.DEV_SECRET && req.session.devKey !== auth.DEV_SECRET) {
return res.status(403).send('403 Forbidden');
}
let user;
if (req.body.preset) {
// Пресет из MOCK_USERS
const mockUser = MOCK_USERS.find(u => u.id === req.body.preset);
if (!mockUser) {
return res.redirect('/dev-login?error=' + encodeURIComponent('Пользователь не найден'));
}
const allClientIds = mockUser.clientId.split(',').map(s => s.trim()).filter(Boolean);
user = {
email: mockUser.email,
clientId: mockUser.clientId,
allClientIds,
activeClientId: allClientIds[0] || mockUser.clientId,
companyId: mockUser.companyId,
companyName: mockUser.companyName,
isAdmin: mockUser.role === 'admin',
};
} else {
// Произвольные поля
const clientId = (req.body.clientId || '').trim();
if (!clientId) {
return res.redirect('/dev-login?error=' + encodeURIComponent('ClientID обязателен'));
}
const allClientIds = clientId.split(',').map(s => s.trim()).filter(Boolean);
user = {
email: (req.body.email || '').trim() || clientId + '@dev.local',
clientId,
allClientIds,
activeClientId: allClientIds[0] || clientId,
companyId: (req.body.companyId || '').trim() || null,
companyName: (req.body.companyName || '').trim() || clientId,
isAdmin: req.body.isAdmin === 'on',
};
}
req.session.regenerate((err) => {
if (err) { console.error('[dev-login] session.regenerate error:', err); return res.status(500).send('Session error'); }
req.session.user = user;
// Восстанавливаем devKey в новой сессии — иначе следующий запрос потребует повторного ?key=
if (auth.DEV_SECRET) req.session.devKey = auth.DEV_SECRET;
req.session.save((saveErr) => {
if (saveErr) { console.error('[dev-login] session.save error:', saveErr); return res.status(500).send('Session error'); }
res.redirect('/');
});
});
});
// ── GET /logout ────────────────────────────────────────────────────────────
router.get('/logout', (req, res) => {
const idToken = req.session.idToken || null;
const isOidc = auth.isOidc;
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.clearCookie('jwt'); // удаляем устаревший cookie если остался от старой версии
if (isOidc && idToken) {
return res.redirect(auth.buildLogoutUrl(idToken));
}
res.redirect('/login');
});
});
return router;
}
module.exports = { createRouter, safeReturn };
-230
View File
@@ -1,230 +0,0 @@
/**
* src/routes/entries.js — основные маршруты работы с белым списком IP.
*
* Маршруты:
* GET / — главная страница: список адресов (admin видит все компании)
* POST /add — добавление новой записи
* POST /edit/:id — редактирование существующей записи
* POST /delete/:id — удаление записи
*
* Все маршруты требуют авторизации (auth.middleware уже применён в server.js выше).
* POST-маршруты дополнительно проверяются:
* - mutationLimiter: не более 30 запросов/мин с одного IP
* - doubleCsrfProtection: проверка CSRF-токена из скрытого поля формы
*
* Admin vs user логика:
* Admin (isAdmin === true, clientId === ADMIN_CLIENT_ID):
* - На главной видит все компании, может переключаться через ?company=<id>
* - В POST-запросах принимает company_id из тела формы
* Обычный пользователь:
* - Видит только свою компанию (из JWT-токена)
* - company_id из тела формы игнорируется (нельзя переключиться)
*
* @param {{ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl, requireAdmin }} deps
* @returns {import('express').Router}
*/
'use strict';
const { Router } = require('express');
function createRouter({ q, doubleCsrfProtection, mutationLimiter, generateCsrfToken, backUrl }) {
const router = Router();
// ── GET / ─────────────────────────────────────────────────────────────────────
// Главная страница: список активных CIDR для компании.
// Admin: видит все компании + переключатель ?company=<числовой PK>.
// User: только своя компания.
router.get('/', async (req, res) => {
const { clientId, companyName, isAdmin, allClientIds, activeClientId } = req.user;
// ── Переключатель компании для обычного пользователя ─────────────────
// ?switchTo=WZ02001 — меняет активную компанию в сессии (только если есть в allClientIds)
const switchTo = (req.query.switchTo || '').trim();
let effectiveClientId = clientId;
let effectiveCompanyName = companyName;
if (!isAdmin && switchTo && allClientIds && allClientIds.includes(switchTo)) {
effectiveClientId = switchTo;
effectiveCompanyName = switchTo; // companyName обновится из БД через getOrCreateCompany
req.session.user.clientId = switchTo;
req.session.user.activeClientId = switchTo;
}
const csrfToken = generateCsrfToken(req, res);
try {
if (isAdmin) {
// ── Ветка admin ───────────────────────────────────────────────────────
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, // dropdown-переключатель всех компаний в БД
selectedCompany,
allClientIds: null, // admin использует companies (не clientIds)
activeClientId: null,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
}
// ── Ветка обычного пользователя ────────────────────────────────────────
const company = await q.getOrCreateCompany(effectiveClientId, effectiveCompanyName);
const limit = await q.getLimit(company);
const entries = await q.listEntries(company.id);
// Если у пользователя несколько компаний — готовим список для переключателя
const hasMultiple = allClientIds && allClientIds.length > 1;
res.render('index', {
entries,
limit,
used: entries.length,
user: req.user,
isAdmin: false,
companies: null,
selectedCompany: company,
allClientIds: hasMultiple ? allClientIds : null,
activeClientId: effectiveClientId,
error: req.query.error || null,
message: req.query.message || null,
wasNormalized: req.query.wasNormalized === '1',
csrfToken,
});
} catch (e) {
console.error('GET / error:', e);
res.render('index', {
entries: [], limit: 15, used: 0,
user: req.user, isAdmin,
companies: null, selectedCompany: null,
allClientIds: null, activeClientId: null,
error: 'Ошибка загрузки данных: ' + e.message,
message: null,
wasNormalized: false,
csrfToken,
});
}
});
// ── POST /add ─────────────────────────────────────────────────────────────────
// Добавление новой записи в белый список.
// createEntry: валидирует CIDR, проверяет лимит, пишет в audit_log (транзакция).
router.post('/add', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user;
let company;
try {
// Определяем компанию: admin — из скрытого поля company_id,
// обычный пользователь — только своя (из токена, нельзя подменить).
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);
}
// createEntry возвращает { entry, wasNormalized }
// wasNormalized=true если IP был нормализован (напр. 192.168.1.1/24 → 192.168.1.0/24)
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) {
// Ошибки: лимит исчерпан, дубликат, невалидный CIDR, etc.
const cid = company ? company.id : req.body.company_id;
res.redirect(backUrl(isAdmin, cid, { error: e.message }));
}
});
// ── POST /edit/:id ────────────────────────────────────────────────────────────
// Обновление существующей записи.
// updateEntry: валидирует CIDR, проверяет принадлежность записи компании,
// пишет в audit_log (транзакция).
router.post('/edit/:id', mutationLimiter, doubleCsrfProtection, async (req, res) => {
const { value, comment } = req.body;
const { clientId, companyName, email, isAdmin } = req.user;
// parseInt с radix=10 — защита от октальных и hex-значений
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 }));
}
});
// ── POST /delete/:id ─────────────────────────────────────────────────────────
// Удаление записи.
// deleteEntry: проверяет принадлежность записи компании, пишет в audit_log.
router.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 }));
}
});
return router;
}
module.exports = { createRouter };
-25
View File
@@ -1,25 +0,0 @@
// ВРЕМЕННЫЙ публичный endpoint GET /exp — без авторизации, все компании.
// TODO: удалить этот файл + строку в server.js когда /export станет публичным (ТЗ п. 4.7)
'use strict';
const { Router } = require('express');
const { exportLimiter } = require('../middleware/rateLimit');
const { aggregateCIDRs } = require('../validators');
const q = require('../queries');
const router = Router();
router.get('/exp', exportLimiter, async (req, res) => {
try {
const cidrs = await q.getExportCIDRs(null); // null = все компании
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('/exp error:', e);
res.status(500).send('Export error');
}
});
module.exports = router;
-81
View File
@@ -1,81 +0,0 @@
/**
* src/routes/export.js — маршрут GET /export (требует авторизации).
*
* Назначение: отдаёт агрегированный список активных CIDR в виде текстового файла.
*
* Изоляция по ролям:
* - Обычный пользователь → только CIDR своей компании.
* - Администратор → все компании (или фильтр по ?company=<id>).
*
* Особенности:
* - Требует авторизации: регистрируется ПОСЛЕ auth.middleware в server.js.
* - Ограничен exportLimiter: не более 20 запросов/мин с одного IP.
* - aggregateCIDRs объединяет перекрывающиеся диапазоны → минимальный набор.
*
* Паттерн «фабрика»:
* Вместо прямых require(зависимостей) получаем их через параметр deps.
* Это позволяет легко подменять зависимости в тестах (mock q, mock aggregateCIDRs).
*
* @param {{ q: object, exportLimiter: Function, aggregateCIDRs: Function }} deps
* @returns {import('express').Router}
*/
'use strict';
const { Router } = require('express');
function createRouter({ q, exportLimiter, aggregateCIDRs }) {
const router = Router();
// GET /export — агрегированный whitelist CIDR в текстовом виде.
// Требует авторизации (регистрируется ПОСЛЕ auth.middleware в server.js).
// Изоляция по ролям:
// - Обычный пользователь → только CIDR своей компании.
// - Администратор → все компании, или фильтр по ?company=<id>.
// exportLimiter ограничивает частоту запросов (20/мин).
router.get('/export', exportLimiter, async (req, res) => {
try {
let companyId = null;
if (!req.user.isAdmin) {
// Обычный пользователь: только его компания.
// ?client_id= для мульти-компании
const requestedId = (req.query.client_id || '').trim();
const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId))
? requestedId
: req.user.clientId;
const company = await q.getOrCreateCompany(effectiveClientId, effectiveClientId);
companyId = company.id;
} else if (req.query.company) {
// Администратор с необязательным фильтром по компании.
const parsed = parseInt(req.query.company, 10);
if (Number.isFinite(parsed) && parsed > 0) companyId = parsed;
}
// Администратор без ?company → companyId остаётся null → все компании.
const cidrs = await q.getExportCIDRs(companyId);
// Суммаризация: объединяем пересекающиеся и смежные диапазоны.
// Например: 192.168.0.0/25 + 192.168.0.128/25 → 192.168.0.0/24
const aggregated = aggregateCIDRs(cidrs);
// Content-Type: text/plain — чтобы браузер не пытался разбирать как HTML.
// ?view=1 → inline (просмотр в браузере), иначе → attachment (скачивание)
// ?filename=X → своё имя файла, по умолчанию white-list.txt
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
const fname = req.query.filename || 'white-list.txt';
const disp = req.query.view === '1' ? 'inline' : 'attachment';
res.setHeader('Content-Disposition', `${disp}; filename="${fname}"`);
// Один CIDR на строку. Если список пустой — возвращаем пустую строку (не ошибку).
res.send(aggregated.join('\n') + (aggregated.length ? '\n' : ''));
} catch (e) {
console.error('Export error:', e);
res.status(500).send('Export error');
}
});
return router;
}
module.exports = { createRouter };
-26
View File
@@ -1,26 +0,0 @@
'use strict';
require('dotenv').config();
const supertest = require('supertest');
const { start } = require('../server');
(async () => {
const app = await start();
const agent = supertest.agent(app);
const r1 = await agent.get('/dev-login');
console.log('GET /dev-login status:', r1.status);
const m = r1.text.match(/name="_csrf"\s+value="([^"]+)"/);
console.log('csrf found:', !!m, m ? m[1].slice(0, 20) + '...' : '');
if (!m) { process.exit(1); }
const r2 = await agent.post('/dev-login').type('form').send({ preset: 'test', _csrf: m[1] });
console.log('POST /dev-login status:', r2.status, 'location:', r2.headers.location);
console.log('set-cookie:', r2.headers['set-cookie']);
const r3 = await agent.get('/');
console.log('GET / status:', r3.status, 'location:', r3.headers.location);
if (r3.status === 200) console.log('snippet:', r3.text.slice(0, 300));
process.exit(0);
})().catch(e => { console.error(e.message, e.stack); process.exit(1); });
+330 -822
View File
File diff suppressed because it is too large Load Diff
+9 -62
View File
@@ -45,10 +45,14 @@ const modules = [
'src/validators.js', 'src/validators.js',
'src/middleware/rateLimit.js', 'src/middleware/rateLimit.js',
'src/middleware/csrf.js', 'src/middleware/csrf.js',
'src/routes/export.js', 'src/api/index.js',
'src/routes/auth.js', 'src/api/routes/entries.js',
'src/routes/entries.js', 'src/api/routes/admin.js',
'src/routes/admin.js', 'ui/index.js',
'ui/routes/auth.js',
'ui/routes/entries.js',
'ui/routes/admin.js',
'ui/routes/export.js',
'server.js', 'server.js',
]; ];
@@ -133,8 +137,7 @@ test('no overlap public', overlaps('8.8.8.0/24', '9.9.9.0/24'), false);
process.env.DEV_MODE = 'true'; process.env.DEV_MODE = 'true';
process.env.ADMIN_CLIENT_ID = 'WZ01112'; process.env.ADMIN_CLIENT_ID = 'WZ01112';
const { initAuth, requireAdmin } = require('../src/auth'); const { initAuth, requireAdmin, safeReturn } = require('../src/auth');
const { safeReturn } = require('../src/routes/auth');
const csrfMiddleware = require('../src/middleware/csrf'); const csrfMiddleware = require('../src/middleware/csrf');
(async () => { (async () => {
@@ -219,63 +222,7 @@ const { doubleCsrfProtection: dp, generateCsrfToken: gct } = csrfMiddleware.init
test('initCsrf returns doubleCsrfProtection fn', typeof dp, 'function'); test('initCsrf returns doubleCsrfProtection fn', typeof dp, 'function');
test('initCsrf returns generateCsrfToken fn', typeof gct, 'function'); test('initCsrf returns generateCsrfToken fn', typeof gct, 'function');
// ────────────────────────────────────────────────────────────────
// 7. Export auth isolation — req.user влияет на фильтр компании
// ────────────────────────────────────────────────────────────────
section('Export auth isolation');
// Тестируем логику без реального HTTP — проверяем что createRouter не бросает при require
const exportModule = require('../src/routes/export');
test('export module exports createRouter', typeof exportModule.createRouter, 'function');
// Логика изоляции: обычный пользователь получает companyId из q.getOrCreateCompany,
// admin без ?company → companyId = null (все компании).
// Проверяем через минимальный stub — без реальной БД.
let exportCompanyIdCaptured = null;
const stubQ = {
getOrCreateCompany: async (clientId) => ({ id: 42, clientId }),
getExportCIDRs: async (cid) => { exportCompanyIdCaptured = cid; return []; },
};
const exportRouter = exportModule.createRouter({
q: stubQ,
exportLimiter: (req, res, next) => next(),
aggregateCIDRs: (x) => x,
});
// Симулируем GET /export для обычного пользователя
const mockReqUser = {
user: { isAdmin: false, clientId: 'WZ01325', companyName: 'TestCo' },
query: {},
method: 'GET',
path: '/export',
};
const mockResUser = {
setHeader: () => {},
send: () => {},
};
// Найдём обработчик /export в router.stack
const exportLayer = exportRouter.stack.find(l => l.route && l.route.path === '/export');
test('export route registered', !!exportLayer, true);
if (exportLayer) {
// Запускаем handler напрямую
const handlers = exportLayer.route.stack.map(l => l.handle);
// exportLimiter (stub пропускает) + async handler
exportCompanyIdCaptured = 'NOT_CALLED';
await handlers[handlers.length - 1](mockReqUser, mockResUser, () => {});
test('user export: companyId filtered (42)', exportCompanyIdCaptured, 42);
// Симулируем GET /export для admin без ?company
exportCompanyIdCaptured = 'NOT_CALLED';
const mockReqAdmin = {
user: { isAdmin: true, clientId: 'WZ01112', companyName: 'Admin' },
query: {},
method: 'GET',
path: '/export',
};
await handlers[handlers.length - 1](mockReqAdmin, mockResUser, () => {});
test('admin export: no filter (null)', exportCompanyIdCaptured, null);
}
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Итог // Итог
+6 -8
View File
@@ -240,15 +240,13 @@ function cidr(ip) { return ip.includes('/') ? ip : ip + '/32'; }
// ══════════════════════════════════════════════════ // ══════════════════════════════════════════════════
console.log('── ТЗ 4.7: Export ──'); console.log('── ТЗ 4.7: Export ──');
{ {
// 4.7a: /exp публичный (временный обходной путь) // 4.7a: /api/v1/entries/export (требует Bearer token)
const r = await req.get('/exp');
log(r.status === 200, '4.7a: GET /exp → 200 (без авторизации)');
log((r.headers['content-type'] || '').includes('text/plain'), '4.7b: Content-Type text/plain');
// 4.7c: /exp содержит только активные записи (проверим через добавление)
const cr = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'export-test' }); const cr = await req.post('/api/v1/entries').set(bearer(tokUser)).send({ value: C.A, comment: 'export-test' });
const r2 = await req.get('/exp');
log(r2.text.includes(cidr(C.A)), '4.7c: /exp содержит добавленный CIDR'); const r = await req.get('/api/v1/entries/export').set(bearer(tokUser));
log(r.status === 200, '4.7a: GET /api/v1/entries/export → 200', `status=${r.status}`);
log((r.headers['content-type'] || '').includes('text/plain'), '4.7b: Content-Type text/plain');
log(r.text.includes(cidr(C.A)), '4.7c: export содержит добавленный CIDR');
} }
// ══════════════════════════════════════════════════ // ══════════════════════════════════════════════════
-140
View File
@@ -1,140 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dev-вход — IP WhiteList</title>
<link rel="icon" href="/favicon.png" type="image/png">
<style>
:root {
--bg: #f5f5f5; --card: #ffffff; --text: #1a1a1a; --muted: #6b7280;
--border: #d1d5db; --grey-light: #f3f4f6; --blue: #2563eb; --blue-h: #1d4ed8;
--amber: #d97706; --amber-bg: #fef3c7; --amber-border: #fde68a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
display: flex; align-items: center; justify-content: center; min-height: 100vh;
}
.login-card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,.06); width: 440px; padding: 2rem;
}
h1 { font-size: 1.2rem; margin-bottom: .25rem; }
.sub { color: var(--muted); font-size: .85rem; margin-bottom: 1.25rem; }
.warn {
padding: .75rem 1rem; background: var(--amber-bg); border: 1px solid var(--amber-border);
border-radius: 8px; font-size: .82rem; color: var(--amber); margin-bottom: 1.5rem;
}
.warn strong { display: block; margin-bottom: .25rem; font-size: .9rem; }
hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
.section-title { font-size: .75rem; font-weight: 600; color: var(--muted);
text-transform: uppercase; letter-spacing: .5px; margin-bottom: .75rem; }
.field { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .85rem; }
.field label { font-size: .8rem; font-weight: 500; color: var(--muted); text-transform: uppercase; }
.field select, .field input[type=text], .field input[type=email] {
padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 6px;
font-size: .9rem; outline: none; background: #fff; width: 100%;
}
.field select:focus, .field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.check-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .85rem; }
.check-row input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; }
.check-row label { font-size: .88rem; cursor: pointer; }
.btn {
width: 100%; padding: .6rem; border: none; border-radius: 6px; font-size: .9rem;
font-weight: 500; cursor: pointer; background: var(--blue); color: #fff;
transition: background .15s; margin-top: .25rem;
}
.btn:hover { background: var(--blue-h); }
.error { color: #dc2626; font-size: .85rem; margin-bottom: 1rem; }
.hint { font-size: .78rem; color: var(--muted); margin-top: .25rem; }
.or-divider { text-align: center; font-size: .8rem; color: var(--muted); margin: 1.25rem 0 1rem; }
</style>
</head>
<body>
<div class="login-card">
<h1>Тестовый вход</h1>
<p class="sub">Dev-backdoor — в обход Keycloak</p>
<div class="warn">
<strong>⚠ ТЕСТОВЫЙ РЕЖИМ</strong>
Этот вход создаёт произвольного пользователя без проверки пароля.
Используйте только для тестирования.
</div>
<% if (error) { %><div class="error"><%= error %></div><% } %>
<form method="POST" action="/dev-login">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<%# ── Пресеты ── %>
<p class="section-title">Быстрый выбор</p>
<div class="field">
<label>Пресет</label>
<select name="preset" id="preset-select">
<option value="">— вручную —</option>
<% users.forEach(u => { %>
<option value="<%= u.id %>"><%= u.label %> (<%= u.role === 'admin' ? 'admin' : 'user' %>)</option>
<% }) %>
</select>
</div>
<div class="or-divider">— или задайте вручную —</div>
<%# ── Кастомные поля ── %>
<p class="section-title">Произвольный пользователь</p>
<div class="field">
<label>ClientID <span style="color:#dc2626">*</span></label>
<input type="text" name="clientId" placeholder="WZ01999" autocomplete="off">
<span class="hint">WZ-номер компании в Nubes (обязателен если пресет не выбран)</span>
</div>
<div class="field">
<label>Company ID</label>
<input type="text" name="companyId" placeholder="uuid или пусто" autocomplete="off">
</div>
<div class="field">
<label>Company Name</label>
<input type="text" name="companyName" placeholder="Название компании" autocomplete="off">
</div>
<div class="field">
<label>Email</label>
<input type="email" name="email" placeholder="test@example.com" autocomplete="off">
</div>
<div class="check-row">
<input type="checkbox" name="isAdmin" id="isAdmin">
<label for="isAdmin">Администратор (isAdmin = true)</label>
</div>
<button class="btn" type="submit">Войти (dev)</button>
</form>
<hr>
<p style="font-size:.78rem;color:var(--muted);text-align:center">
<a href="/login" style="color:inherit">← Обычный вход</a>
</p>
</div>
<script>
// Если выбран пресет — очищаем кастомные поля (чтобы не путаться).
// Если пресет = "", кастомные поля активны.
document.getElementById('preset-select').addEventListener('change', function() {
const custom = ['clientId','companyId','companyName','email'];
const clear = !!this.value;
custom.forEach(id => {
const el = document.querySelector('[name=' + id + ']');
if (el) { el.disabled = clear; if (clear) el.value = ''; }
});
const chk = document.getElementById('isAdmin');
if (chk) { chk.disabled = clear; if (clear) chk.checked = false; }
});
</script>
<footer style="text-align:right;padding:.5rem 1.5rem;font-size:.72rem;color:var(--muted);">v<%= appVersion %></footer>
</body>
</html>