Files
ipwhitelist-app/tests/run-tests.js
T
naeel a5170a062c 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)
2026-05-30 15:25:57 +03:00

292 lines
14 KiB
JavaScript

'use strict';
/**
* tests/run-tests.js — smoke/unit тесты без внешних зависимостей.
* Запускать: node tests/run-tests.js
* БД не нужна.
*/
let pass = 0, fail = 0;
function test(name, actual, expected) {
if (actual === expected) {
console.log(' PASS ' + name);
pass++;
} else {
console.error(' FAIL ' + name + ': got ' + JSON.stringify(actual) + ', want ' + JSON.stringify(expected));
fail++;
}
}
function testThrows(name, fn) {
try {
fn();
console.error(' FAIL ' + name + ': expected throw, got no error');
fail++;
} catch (e) {
console.log(' PASS ' + name + ' (throws: ' + e.message + ')');
pass++;
}
}
function section(title) {
console.log('\n── ' + title + ' ──');
}
// ────────────────────────────────────────────────────────────────
// 1. Модули загружаются без ошибок
// ────────────────────────────────────────────────────────────────
section('Загрузка модулей');
const modules = [
'src/config.js',
'src/auth.js',
'src/queries.js',
'src/validators.js',
'src/middleware/rateLimit.js',
'src/middleware/csrf.js',
'src/routes/export.js',
'src/routes/auth.js',
'src/routes/entries.js',
'src/routes/admin.js',
'server.js',
];
for (const m of modules) {
try {
require('../' + m);
test('require ' + m, true, true);
} catch (e) {
test('require ' + m, false, true);
}
}
// ────────────────────────────────────────────────────────────────
// 2. src/config.js
// ────────────────────────────────────────────────────────────────
section('config.js');
const { MOCK_USERS, backUrl } = require('../src/config');
test('MOCK_USERS.length === 3', MOCK_USERS.length, 3);
test('admin clientId', MOCK_USERS.some(u => u.clientId === 'WZ01112'), true);
test('all have companyId', MOCK_USERS.every(u => !!u.companyId), true);
test('admin backUrl has company', backUrl(true, 5, {}).includes('company=5'), true);
test('user backUrl has no company', backUrl(false, 5, {}).includes('company='), false);
test('extra propagated', backUrl(true, 5, { error: 'foo' }).includes('error=foo'), true);
test('empty → /', backUrl(false, null, {}), '/');
// ────────────────────────────────────────────────────────────────
// 3. src/validators.js
// ────────────────────────────────────────────────────────────────
section('validators.js');
const { aggregateCIDRs, validate, overlaps } = require('../src/validators');
// aggregateCIDRs
test('single /32', aggregateCIDRs(['203.0.114.1/32']).join(), '203.0.114.1/32');
test('adjacent /32 → /31', aggregateCIDRs(['8.8.8.0/32', '8.8.8.1/32']).join(), '8.8.8.0/31');
test('subnet passthrough /24', aggregateCIDRs(['1.2.3.0/24']).join(), '1.2.3.0/24');
test('empty → []', aggregateCIDRs([]).length, 0);
// validate — корректные входные данные
try {
const r = validate('8.8.8.8/32');
test('valid /32 cidr', r.cidr, '8.8.8.8/32');
test('valid /32 wasNormalized', r.wasNormalized, false);
} catch (e) { test('valid /32', false, true); }
try {
const r = validate('8.8.8.8'); // без маски → /32
test('no-mask → /32', r.cidr, '8.8.8.8/32');
} catch (e) { test('no-mask → /32', false, true); }
try {
const r = validate('1.2.3.7/24'); // нормализация host-битов
test('normalize host bits', r.cidr, '1.2.3.0/24');
test('wasNormalized=true', r.wasNormalized, true);
} catch (e) { test('normalize host bits', false, true); }
try {
const r = validate('4.4.0.0/22'); // минимальная допустимая маска
test('min mask /22', r.cidr, '4.4.0.0/22');
} catch (e) { test('min mask /22', false, true); }
// validate — некорректные (должны бросать)
testThrows('empty throws', () => validate(''));
testThrows('private 10.x throws', () => validate('10.1.2.3/32'));
testThrows('private 192.168 throws', () => validate('192.168.1.0/24'));
testThrows('mask /8 throws', () => validate('1.2.3.4/8'));
testThrows('mask /21 throws', () => validate('1.2.3.0/21'));
testThrows('bad IP throws', () => validate('999.0.0.1/32'));
testThrows('IPv6 throws', () => validate('::1'));
// overlaps
test('overlap subnet in block', overlaps('10.0.1.0/24', '10.0.0.0/8'), true);
test('overlap exact match', overlaps('192.168.0.0/24', '192.168.0.0/24'), true);
test('no overlap public', overlaps('8.8.8.0/24', '9.9.9.0/24'), false);
// ────────────────────────────────────────────────────────────────
// 4. src/auth.js — session middleware (async, без JWT, без БД)
// ────────────────────────────────────────────────────────────────
process.env.DEV_MODE = 'true';
process.env.ADMIN_CLIENT_ID = 'WZ01112';
const { initAuth, requireAdmin } = require('../src/auth');
const { safeReturn } = require('../src/routes/auth');
const csrfMiddleware = require('../src/middleware/csrf');
(async () => {
section('auth.js (session middleware)');
try {
const auth = await initAuth();
test('middleware is function', typeof auth.middleware, 'function');
test('jwksHandler present (mock mode)', typeof auth.jwksHandler, 'function');
test('devLoginEnabled when DEV_MODE', auth.devLoginEnabled, true);
test('isOidc false (no KC creds)', auth.isOidc, false);
// ── Пользователь из session ──
const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', companyName: 'Test', email: 'test@test.local', isAdmin: false };
const reqSess = { headers: {}, cookies: {}, session: { user: mockUser } };
let nextCalled = false;
auth.middleware(reqSess, {}, () => { nextCalled = true; });
test('session user: next() called', nextCalled, true);
test('session user: req.user set', !!reqSess.user, true);
test('session user: clientId match', reqSess.user.clientId, 'WZ01325');
// ── Admin user из session ──
const adminUser = { clientId: 'WZ01112', isAdmin: true };
const reqAdmin = { headers: {}, cookies: {}, session: { user: adminUser } };
let adminNext = false;
auth.middleware(reqAdmin, {}, () => { adminNext = true; });
test('admin session: next() called', adminNext, true);
test('admin session: isAdmin=true', reqAdmin.user.isAdmin, true);
// ── Нет аутентификации → редирект ──
let redirectUrl = null;
const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' };
auth.middleware(reqNone, { redirect: (url) => { redirectUrl = url; } }, () => {});
test('no auth: redirected to /login', redirectUrl !== null && redirectUrl.startsWith('/login'), true);
// ── requireAdmin: admin проходит ──
let adminPassed = false;
requireAdmin({ user: { isAdmin: true } }, {}, () => { adminPassed = true; });
test('requireAdmin: admin passes', adminPassed, true);
// ── requireAdmin: не-admin блокируется ──
let blockedStatus = null;
requireAdmin({ user: { isAdmin: false } }, {
status: (s) => { blockedStatus = s; return { send: () => {} }; },
}, () => {});
test('requireAdmin: non-admin blocked (403)', blockedStatus, 403);
} catch (e) {
console.error('auth.js FATAL:', e.message);
fail++;
}
// ────────────────────────────────────────────────────────────────
// 5. safeReturn — защита от open redirect (A01)
// ────────────────────────────────────────────────────────────────
section('safeReturn (open redirect protection)');
test('normal path allowed', safeReturn('/dashboard'), '/dashboard');
test('root / allowed', safeReturn('/'), '/');
test('path with query allowed', safeReturn('/edit?id=5'), '/edit?id=5');
test('//evil.com blocked', safeReturn('//evil.com'), '/');
test('https://evil.com blocked', safeReturn('https://evil.com'), '/');
test('http://evil.com blocked', safeReturn('http://evil.com'), '/');
test('undefined → /', safeReturn(undefined), '/');
test('null → /', safeReturn(null), '/');
test('empty string → /', safeReturn(''), '/');
test('//ok.com/path blocked', safeReturn('//ok.com/path'), '/');
test('nested path allowed', safeReturn('/admin/users'), '/admin/users');
// ────────────────────────────────────────────────────────────────
// 6. CSRF middleware — getSessionIdentifier использует req.sessionID
// ────────────────────────────────────────────────────────────────
section('CSRF getSessionIdentifier');
// Проверяем что csrf.js экспортирует объект с initCsrf
test('csrf module exports initCsrf', typeof csrfMiddleware.initCsrf, 'function');
// Проверяем поведение getSessionIdentifier через тестовые значения
// Функция в модуле читает req.sessionID (не req.cookies.jwt)
const { doubleCsrfProtection: dp, generateCsrfToken: gct } = csrfMiddleware.initCsrf();
test('initCsrf returns doubleCsrfProtection fn', typeof dp, '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);
}
// ────────────────────────────────────────────────────────────────
// Итог
// ────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════');
console.log(pass + ' PASS, ' + fail + ' FAIL');
console.log('══════════════════════════════');
process.exit(fail > 0 ? 1 : 0);
})().catch(e => {
console.error('\nTest runner fatal error:', e.message);
process.exit(1);
});