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) {