security: fix 7 vulnerabilities from Opus code review

- [A01] /export: mount after auth.middleware, filter by company for non-admin
- [A02] OIDC: verify issuer, audience, select key by kid; preload JWKS at startup
- [A07] session fixation: session.regenerate() in POST /login, GET /callback, POST /dev-login
- [A10] open redirect: safeReturn() helper validates returnTo (blocks //evil.com, https:// etc)
- [A05] CSRF: getSessionIdentifier uses req.sessionID instead of dead req.cookies.jwt
- [A05] fail-fast: reject default SESSION_SECRET / CSRF_SECRET in NODE_ENV=production
- [A05] DEV_MODE=true blocked in production

Tests: 68 PASS, 0 FAIL (was 50, added 18 new security-focused tests)
This commit is contained in:
2026-05-30 15:25:57 +03:00
parent ed6e9c4464
commit a5170a062c
6 changed files with 249 additions and 42 deletions
+39 -4
View File
@@ -53,16 +53,29 @@ let mockJwks = null;
let cachedJwks = null; // OIDC public keys
// ── initAuth ────────────────────────────────────────────────────────────────
function initAuth() {
async function initAuth() {
// Читаем здесь — не на уровне модуля, чтобы тесты могли менять process.env
// до вызова initAuth() и видеть актуальные значения.
const devMode = process.env.DEV_MODE === 'true';
const devSecret = process.env.DEV_SECRET || '';
// DEV_MODE=true недопустим в production — жёсткая защита от случайного деплоя.
if (process.env.NODE_ENV === 'production' && devMode) {
throw new Error('DEV_MODE=true запрещён в production (NODE_ENV=production)');
}
const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET);
if (isOidc) {
console.log('[auth] OIDC — Keycloak', KC_BASE_URL);
// Предзагрузка JWKS при старте — избегаем задержки на первом запросе.
// При ошибке логируем предупреждение: JWKS будет загружен в exchangeCode().
try {
cachedJwks = JSON.parse(await httpGet(OIDC.certs));
console.log('[auth] JWKS loaded:', cachedJwks.keys.length, 'ключей');
} catch (e) {
console.warn('[auth] JWKS preload failed:', e.message, '— будет загружен при первом запросе');
}
} else {
console.log('[auth] MOCK — local RS256 key');
mockKeyPair = crypto.generateKeyPairSync('rsa', {
@@ -165,6 +178,7 @@ async function exchangeCode(code) {
const data = JSON.parse(raw);
if (!data.access_token) throw new Error('No access_token: ' + raw.slice(0, 200));
// cachedJwks загружается в initAuth(); при провале preload — подгружаем здесь.
if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs));
const payload = verifyOidcToken(data.access_token);
@@ -200,10 +214,31 @@ function verifyMockToken(token) {
function verifyOidcToken(token) {
if (!cachedJwks) throw new Error('JWKS not loaded yet');
const key = cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig');
if (!key) throw new Error('No RSA sig key in JWKS');
// Декодируем заголовок токена для выбора ключа по kid.
// base64url безопаснее base64: обрабатывает '-' и '_' без паддинга.
const headerRaw = token.split('.')[0];
let header;
try {
header = JSON.parse(Buffer.from(headerRaw, 'base64url').toString());
} catch (e) {
throw new Error('Malformed JWT header');
}
const kid = header.kid;
// Ищем ключ по kid (точный match) или первый RSA-sig ключ (если kid не указан).
const key = kid
? cachedJwks.keys.find(k => k.kid === kid && k.kty === 'RSA' && k.use === 'sig')
: cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig');
if (!key) throw new Error('No matching RSA sig key in JWKS' + (kid ? ` (kid=${kid})` : ''));
const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
return jwt.verify(token, pem, { algorithms: ['RS256'] });
// Проверяем issuer и audience — защита от токенов других realm/клиентов (A02).
return jwt.verify(token, pem, {
algorithms: ['RS256'],
issuer: KC_BASE_URL,
audience: KC_CLIENT_ID,
});
}
function userFromPayload(payload) {
+4 -4
View File
@@ -37,10 +37,10 @@ function initCsrf() {
// ⚠️ В продакшене задать CSRF_SECRET в .env или через k8s Secret.
getSecret: () => process.env.CSRF_SECRET || 'dev-csrf-secret-change-in-prod',
// Идентификатор сессии — привязывает CSRF-токен к конкретному пользователю.
// Используем JWT-cookie: если его нет (не залогинен) — пустая строка,
// что допустимо для формы /login (она сама создаёт сессию).
getSessionIdentifier: (req) => req.cookies.jwt || '',
// Идентификатор сессии — привязывает CSRF-токен к конкретной сессии пользователя.
// Используем req.sessionID (выставляется express-session до вызова CSRF middleware).
// Если сессии нет — пустая строка (допустимо для /login до установки сессии).
getSessionIdentifier: (req) => req.sessionID || '',
cookieOptions: {
// secure: true → cookie передаётся только по HTTPS. Важно в продакшене.
+49 -11
View File
@@ -25,6 +25,20 @@
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 }) {
const router = Router();
@@ -57,7 +71,7 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
return res.redirect('/login?error=' + encodeURIComponent('Пользователь не найден'));
}
req.session.user = {
const userData = {
email: user.email,
clientId: user.clientId,
companyId: user.companyId,
@@ -65,7 +79,16 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
isAdmin: user.role === 'admin',
};
res.redirect(req.query.returnTo || '/');
// 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-режим) ──────────────────────────────────────
@@ -87,12 +110,19 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
try {
const { user, idToken, refreshToken } = await auth.exchangeCode(code);
req.session.user = user;
req.session.idToken = idToken;
req.session.refreshToken = refreshToken;
const returnTo = req.session.returnTo || '/';
delete req.session.returnTo;
res.redirect(returnTo);
// Сохраняем 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));
@@ -159,8 +189,16 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
};
}
req.session.user = user;
res.redirect('/');
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 ────────────────────────────────────────────────────────────
@@ -182,4 +220,4 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, MOCK_USER
return router;
}
module.exports = { createRouter };
module.exports = { createRouter, safeReturn };
+29 -11
View File
@@ -1,12 +1,14 @@
/**
* src/routes/export.js — публичный маршрут GET /export.
* src/routes/export.js — маршрут GET /export (требует авторизации).
*
* Назначение: отдаёт агрегированный список всех активных CIDR (все компании)
* в виде текстового файла (один адрес/подсеть на строку).
* Назначение: отдаёт агрегированный список активных CIDR в виде текстового файла.
*
* Изоляция по ролям:
* - Обычный пользователь → только CIDR своей компании.
* - Администратор → все компании (или фильтр по ?company=<id>).
*
* Особенности:
* - НЕ требует авторизации (ТЗ: «доступ ограничивается на сетевом уровне»).
* - Регистрируется ДО auth.middleware в server.js.
* - Требует авторизации: регистрируется ПОСЛЕ auth.middleware в server.js.
* - Ограничен exportLimiter: не более 20 запросов/мин с одного IP.
* - aggregateCIDRs объединяет перекрывающиеся диапазоны → минимальный набор.
*
@@ -25,15 +27,31 @@ const { Router } = require('express');
function createRouter({ q, exportLimiter, aggregateCIDRs }) {
const router = Router();
// GET /export — агрегированный whitelist всех компаний в текстовом виде.
// exportLimiter ограничивает частоту запросов (20/мин) до проверки авторизации —
// это важно, так как маршрут публичный.
// GET /export — агрегированный whitelist CIDR в текстовом виде.
// Требует авторизации (регистрируется ПОСЛЕ auth.middleware в server.js).
// Изоляция по ролям:
// - Обычный пользователь → только CIDR своей компании.
// - Администратор → все компании, или фильтр по ?company=<id>.
// exportLimiter ограничивает частоту запросов (20/мин).
router.get('/export', exportLimiter, async (req, res) => {
try {
// Получаем все активные CIDR без фильтра по компании
const cidrs = await q.getExportCIDRs();
let companyId = null;
// Суммаризация: объединяем пересекающиеся и смежные диапазоны
if (!req.user.isAdmin) {
// Обычный пользователь: только его компания.
// getOrCreateCompany — атомарная UPSERT, безопасна для параллельных вызовов.
const company = await q.getOrCreateCompany(req.user.clientId, req.user.companyName);
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);