diff --git a/server.js b/server.js index 6629ce6..6e89280 100644 --- a/server.js +++ b/server.js @@ -6,12 +6,17 @@ const q = require('./src/queries'); const app = express(); const PORT = process.env.PORT || 3000; -const DEV = process.env.DEV_MODE === 'true'; +const DEV = process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production'; + +if (DEV) console.warn('⚠ DEV_MODE активен — аутентификация отключена'); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.use(express.static(path.join(__dirname, 'public'))); -app.use(express.urlencoded({ extended: true })); +app.use(express.urlencoded({ extended: true, limit: '32kb' })); + +// ── Health (выше auth — k8s probe не должна требовать токен) ── +app.get('/healthz', (req, res) => res.send('OK')); // ── Auth middleware ── app.use((req, res, next) => { @@ -29,12 +34,11 @@ app.use((req, res, next) => { companyName: payload.company_name || payload.ClientID, }; } catch { req.user = {}; } + // Без clientId — 401, анонимы не должны делить NULL-компанию + if (!req.user.clientId) return res.status(401).send('Unauthorized'); next(); }); -// ── Health ── -app.get('/healthz', (req, res) => res.send('OK')); - // ── Главная ── app.get('/', async (req, res) => { const { clientId, companyName } = req.user; @@ -42,9 +46,14 @@ app.get('/', async (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, error: null, message: null, wasNormalized: false }); + res.render('index', { + entries, limit, used: entries.length, user: req.user, + error: req.query.error || null, + message: req.query.message || null, + wasNormalized: req.query.wasNormalized === '1', + }); } catch (e) { - res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: e.message, message: null, wasNormalized: false }); + res.render('index', { entries: [], limit: 15, used: 0, user: req.user, error: 'Ошибка загрузки', message: null, wasNormalized: false }); } }); @@ -55,18 +64,13 @@ app.post('/add', async (req, res) => { try { const company = await q.getOrCreateCompany(clientId, companyName); const result = await q.createEntry(company.id, value, comment, email); - const limit = await q.getLimit(company); - const entries = await q.listEntries(company.id); - res.render('index', { - entries, limit, used: entries.length, user: req.user, - message: result.wasNormalized ? `Адрес нормализован в ${result.entry.value_cidr}` : 'Добавлено', - error: null, wasNormalized: result.wasNormalized, - }); + const message = result.wasNormalized + ? `Адрес нормализован в ${result.entry.value_cidr}` + : 'Добавлено'; + const wasNormalized = result.wasNormalized ? '1' : '0'; + res.redirect(`/?message=${encodeURIComponent(message)}&wasNormalized=${wasNormalized}`); } catch (e) { - const company = await q.getOrCreateCompany(clientId, companyName).catch(() => null); - const entries = company ? await q.listEntries(company.id).catch(() => []) : []; - const limit = company ? await q.getLimit(company).catch(() => 15) : 15; - res.render('index', { entries, limit, used: entries.length, user: req.user, error: e.message, message: null, wasNormalized: false }); + res.redirect('/?error=' + encodeURIComponent(e.message)); } }); @@ -82,10 +86,12 @@ app.post('/delete/:id', async (req, res) => { } }); -// ── Экспорт ── +// ── Экспорт (только для своей компании) ── app.get('/export', async (req, res) => { try { - const cidrs = await q.getExportCIDRs(); + const { clientId, companyName } = req.user; + const company = await q.getOrCreateCompany(clientId, companyName); + const cidrs = await q.getExportCIDRs(company.id); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(cidrs.join('\n') + '\n'); } catch (e) { diff --git a/sql/schema.sql b/sql/schema.sql index 22511fe..2b8dc6b 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -4,15 +4,15 @@ CREATE TABLE IF NOT EXISTS companies ( id SERIAL PRIMARY KEY, client_id VARCHAR(64) UNIQUE NOT NULL, name VARCHAR(255), - custom_limit INTEGER DEFAULT NULL, + custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS whitelist_entries ( id SERIAL PRIMARY KEY, - company_id INTEGER NOT NULL REFERENCES companies(id), - value_cidr VARCHAR(18) NOT NULL, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE RESTRICT, + value_cidr VARCHAR(18) NOT NULL CHECK (value_cidr ~ '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'), comment VARCHAR(255), created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -22,18 +22,23 @@ CREATE TABLE IF NOT EXISTS whitelist_entries ( deleted_at TIMESTAMPTZ ); -CREATE INDEX IF NOT EXISTS idx_entries_active +-- Уникальность активного CIDR в рамках компании (защита от гонок дублей) +CREATE UNIQUE INDEX IF NOT EXISTS uq_entries_active_cidr ON whitelist_entries(company_id, value_cidr) WHERE deleted_at IS NULL; +-- Составной для списка активных записей компании +CREATE INDEX IF NOT EXISTS idx_entries_active + ON whitelist_entries(company_id, created_at DESC) WHERE deleted_at IS NULL; + CREATE TABLE IF NOT EXISTS audit_log ( id SERIAL PRIMARY KEY, user_email VARCHAR(255) NOT NULL, company_id INTEGER NOT NULL, - action VARCHAR(32) NOT NULL, + action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE')), old_value TEXT, new_value TEXT, entry_id INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_audit_company ON audit_log(company_id); +CREATE INDEX IF NOT EXISTS idx_audit_company_time ON audit_log(company_id, created_at DESC); diff --git a/src/queries.js b/src/queries.js index d00dfb7..90094dd 100644 --- a/src/queries.js +++ b/src/queries.js @@ -4,11 +4,11 @@ const { validate, overlaps } = require('./validators'); // ── Companies ── async function getOrCreateCompany(clientId, companyName) { - let res = await pool.query('SELECT * FROM companies WHERE client_id = $1', [clientId]); - if (res.rows.length > 0) return res.rows[0]; - - res = await pool.query( - 'INSERT INTO companies (client_id, name) VALUES ($1, $2) RETURNING *', + // Атомарный upsert — исключает дубли компаний при параллельных запросах + const res = await pool.query( + `INSERT INTO companies (client_id, name) VALUES ($1, $2) + ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name) + RETURNING *`, [clientId, companyName || clientId] ); return res.rows[0]; @@ -16,7 +16,8 @@ async function getOrCreateCompany(clientId, companyName) { async function getLimit(company) { const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15; - return company.custom_limit || defaultLimit; + // != null: custom_limit = 0 не должен превращаться в defaultLimit + return company.custom_limit != null ? company.custom_limit : defaultLimit; } // ── Entries ── @@ -30,100 +31,133 @@ async function listEntries(companyId, includeDeleted = false) { async function createEntry(companyId, rawValue, comment, userEmail) { const { cidr, wasNormalized } = validate(rawValue); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // Блокировка строки компании — сериализует параллельные вставки + const company = (await client.query( + 'SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId] + )).rows[0]; + if (!company) throw new Error('Компания не найдена'); - // Проверка лимита - const company = (await pool.query('SELECT * FROM companies WHERE id = $1', [companyId])).rows[0]; - const limit = await getLimit(company); - const cnt = (await pool.query( - 'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', - [companyId] - )).rows[0].c; + const limit = await getLimit(company); + const cnt = (await client.query( + 'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', + [companyId] + )).rows[0].c; + if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`); - if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`); + // Проверка дубликатов и пересечений + const existing = (await client.query( + 'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', + [companyId] + )).rows; + for (const row of existing) { + if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует'); + if (overlaps(cidr, row.value_cidr)) + throw new Error(`Пересечение с существующей записью ${row.value_cidr}`); + } - // Проверка дубликатов и пересечений - const existing = (await pool.query( - 'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL', - [companyId] - )).rows; - - for (const row of existing) { - if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует'); - if (overlaps(cidr, row.value_cidr)) - throw new Error(`Пересечение с существующей записью ${row.value_cidr}`); + const res = await client.query( + `INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by) + VALUES ($1, $2, $3, $4) RETURNING *`, + [companyId, cidr, comment || null, userEmail] + ); + await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id, client); + await client.query('COMMIT'); + return { entry: res.rows[0], wasNormalized }; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); } - - const res = await pool.query( - `INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by) - VALUES ($1, $2, $3, $4) RETURNING *`, - [companyId, cidr, comment || null, userEmail] - ); - - // Аудит - await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id); - - return { entry: res.rows[0], wasNormalized }; } async function updateEntry(entryId, companyId, rawValue, comment, userEmail) { - const old = (await pool.query( - 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', - [entryId, companyId] - )).rows[0]; - if (!old) throw new Error('Запись не найдена'); - const { cidr, wasNormalized } = validate(rawValue); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // Блокировка строки компании — сериализует параллельные обновления + await client.query('SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId]); - // Проверка пересечений (исключая саму себя) - const existing = (await pool.query( - 'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2', - [companyId, entryId] - )).rows; - for (const row of existing) { - if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует'); - if (overlaps(cidr, row.value_cidr)) - throw new Error(`Пересечение с существующей записью ${row.value_cidr}`); + const old = (await client.query( + 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', + [entryId, companyId] + )).rows[0]; + if (!old) throw new Error('Запись не найдена'); + + // Проверка пересечений (исключая саму себя) + const existing = (await client.query( + 'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2', + [companyId, entryId] + )).rows; + for (const row of existing) { + if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует'); + if (overlaps(cidr, row.value_cidr)) + throw new Error(`Пересечение с существующей записью ${row.value_cidr}`); + } + + const res = await client.query( + `UPDATE whitelist_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW() + WHERE id = $4 AND company_id = $5 RETURNING *`, + [cidr, comment || old.comment, userEmail, entryId, companyId] + ); + await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, client); + await client.query('COMMIT'); + return { entry: res.rows[0], wasNormalized }; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); } - - const res = await pool.query( - `UPDATE whitelist_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW() - WHERE id = $4 AND company_id = $5 RETURNING *`, - [cidr, comment || old.comment, userEmail, entryId, companyId] - ); - - await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId); - - return { entry: res.rows[0], wasNormalized }; } async function deleteEntry(entryId, companyId, userEmail) { - const old = (await pool.query( - 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', - [entryId, companyId] - )).rows[0]; - if (!old) throw new Error('Запись не найдена'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); - await pool.query( - 'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2', - [userEmail, entryId] - ); + const old = (await client.query( + 'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL', + [entryId, companyId] + )).rows[0]; + if (!old) throw new Error('Запись не найдена'); - await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId); + // company_id в WHERE — глубина защиты от случайной утечки между компаниями + await client.query( + 'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3', + [userEmail, entryId, companyId] + ); + await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId, client); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } } // ── Export ── -async function getExportCIDRs() { - const rows = (await pool.query( - 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL ORDER BY value_cidr' - )).rows; - return rows.map(r => r.value_cidr); +async function getExportCIDRs(companyId = null) { + let sql = 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL'; + const params = []; + if (companyId) { + sql += ' AND company_id = $1'; + params.push(companyId); + } + sql += ' ORDER BY value_cidr'; + return ((await pool.query(sql, params)).rows).map(r => r.value_cidr); } // ── Audit ── -async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId) { - await pool.query( +async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, db = pool) { + await db.query( `INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id) VALUES ($1, $2, $3, $4, $5, $6)`, [userEmail, companyId, action, oldValue, newValue, entryId || null] diff --git a/src/validators.js b/src/validators.js index eb974de..357e31a 100644 --- a/src/validators.js +++ b/src/validators.js @@ -32,13 +32,23 @@ function validate(input) { if (/[a-zA-Z]/.test(raw.replace(/\./g, '').replace(/\//g, '').replace(/\d/g, ''))) throw new Error('Некорректный формат'); + // Отсекаем множественные слэши + if ((raw.match(/\//g) || []).length > 1) { + throw new Error('Некорректный формат'); + } + // Если нет маски — добавляем /32 let cidr = raw.includes('/') ? raw : raw + '/32'; const [addr, maskStr] = cidr.split('/'); + + // Строгая проверка маски (parseInt глотает мусор: '24abc' → 24) + if (!/^\d{1,2}$/.test(maskStr)) { + throw new Error('Некорректная маска'); + } const mask = parseInt(maskStr, 10); - if (isNaN(mask) || mask < 22 || mask > 32) { + if (mask < 22 || mask > 32) { throw new Error('Маска должна быть от /22 до /32'); } @@ -58,10 +68,11 @@ function validate(input) { const wasNormalized = addr !== networkAddr; const normalized = networkAddr + '/' + mask; - // Проверка запрещённых диапазонов + // Проверка запрещённых диапазонов (overlaps ловит оба направления: + // и запись внутри запрещённого блока, и суперсеть над ним) for (const blocked of BLOCKED_RANGES) { - if (isSubnetOf(normalized, blocked)) { - throw new Error(`Диапазон ${normalized} запрещён (${blocked})`); + if (overlaps(normalized, blocked)) { + throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`); } } diff --git a/views/index.ejs b/views/index.ejs index c9439cd..f7f3b7c 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -192,7 +192,7 @@