diff --git a/db/init.sql b/db/init.sql index f593e9e..eecb0ad 100644 --- a/db/init.sql +++ b/db/init.sql @@ -6,6 +6,13 @@ CREATE TABLE IF NOT EXISTS branches ( created_at TIMESTAMPTZ DEFAULT now() ); +CREATE TABLE IF NOT EXISTS banned_ips ( + ip VARCHAR(64) PRIMARY KEY, + reason VARCHAR(100) NOT NULL, + banned_until TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + CREATE TABLE IF NOT EXISTS groups ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, diff --git a/db/migration.sql b/db/migration.sql index 941545a..4c2d413 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -4,6 +4,13 @@ CREATE TABLE IF NOT EXISTS students ( created_at TIMESTAMPTZ DEFAULT now() ); +CREATE TABLE IF NOT EXISTS banned_ips ( + ip VARCHAR(64) PRIMARY KEY, + reason VARCHAR(100) NOT NULL, + banned_until TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id); CREATE TABLE IF NOT EXISTS settings ( diff --git a/public/js/settings.js b/public/js/settings.js index 4c32eb8..27b5e2c 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -238,6 +238,28 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => { e.target.value = ''; }); +async function loadBans() { + const el = document.getElementById('bansList'); + const res = await fetch(`${API}/api/bans`, { headers: hdr() }); + if (res.status === 403) { document.getElementById('bansCard').style.display = 'none'; return; } + if (!res.ok) { el.innerHTML = 'Не удалось загрузить'; return; } + const bans = await res.json(); + if (!bans.length) { el.innerHTML = 'Активных блокировок нет'; return; } + el.innerHTML = bans.map(b => ` + + ${esc(b.ip)} — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')} + + `).join(''); +} + +document.getElementById('bansList').addEventListener('click', async e => { + const ip = e.target.closest('[data-unban]')?.dataset.unban; + if (!ip) return; + const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() }); + if (res.ok) { showToast('IP разблокирован'); loadBans(); } + else showToast('Ошибка разблокировки'); +}); + document.getElementById('backupBtn').addEventListener('click', downloadBackup); document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click()); document.getElementById('saveBtn').addEventListener('click', saveSettings); @@ -247,5 +269,6 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings); buildSidebar(document.body.dataset.page); bindDirty(); loadSettings(); + loadBans(); } })(); \ No newline at end of file diff --git a/public/settings.html b/public/settings.html index eb07e65..dec7571 100644 --- a/public/settings.html +++ b/public/settings.html @@ -138,6 +138,15 @@
+ +
+
+
+

Блокировки IP

+
+

IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.

+
Загрузка…
+
diff --git a/server.js b/server.js index aa18971..dbd237b 100644 --- a/server.js +++ b/server.js @@ -67,6 +67,65 @@ function invalidateShare() { cacheDrop('share:payload:'); } function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); } function invalidateAll() { cacheStore.clear(); } +const BAN_TTL_MS = 24 * 60 * 60 * 1000; +const FAIL_WINDOW_MS = 15 * 60 * 1000; +const banMemory = new Map(); +const failMemory = new Map(); + +function ipOf(req) { + return String(req.ip || req.socket?.remoteAddress || 'unknown').slice(0, 64); +} + +async function banIP(req, reason, ms) { + const ip = ipOf(req); + const until = new Date(Date.now() + ms); + banMemory.set(ip, { reason, banned_until: until.toISOString() }); + await pool.query( + 'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3', + [ip, reason, until.toISOString()] + ); + await logAudit(req, 'ip.ban', { ip, reason }); + console.log(`IP banned: ${ip} (${reason})`); +} + +function ipGuard(req, res, next) { + const entry = banMemory.get(ipOf(req)); + if (entry && new Date(entry.banned_until) > new Date()) { + return res.status(403).json({ error: 'Доступ заблокирован' }); + } + next(); +} + +function recordFailure(req, kind, limit, ms) { + const ip = ipOf(req); + const now = Date.now(); + let entry = failMemory.get(kind + ':' + ip); + if (!entry || entry.resetAt <= now) { + entry = { count: 0, resetAt: now + FAIL_WINDOW_MS }; + failMemory.set(kind + ':' + ip, entry); + } + entry.count += 1; + if (entry.count >= limit) { + failMemory.delete(kind + ':' + ip); + return banIP(req, kind, ms).catch(err => console.error('Ban error:', err)); + } + return Promise.resolve(); +} + +async function loadBans() { + const { rows } = await pool.query('SELECT ip, reason, banned_until FROM banned_ips WHERE banned_until > now()'); + const active = new Set(); + for (const r of rows) { + active.add(r.ip); + banMemory.set(r.ip, { reason: r.reason, banned_until: r.banned_until }); + } + for (const key of banMemory.keys()) { + if (!active.has(key)) banMemory.delete(key); + } +} + +app.set('trust proxy', 'loopback'); + const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 300, @@ -114,6 +173,7 @@ app.use(helmet({ } })); app.use(express.json({ limit: '1mb' })); +app.use(ipGuard); const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs'); const THUMB_WIDTH = 480; let sharp = null; @@ -495,6 +555,15 @@ async function ensureBranchesTable() { await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`); } +async function ensureBannedIpsTable() { + await pool.query(`CREATE TABLE IF NOT EXISTS banned_ips ( + ip VARCHAR(64) PRIMARY KEY, + reason VARCHAR(100) NOT NULL, + banned_until TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() + )`); +} + async function ensureEntryPhotosTable() { await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos ( id SERIAL PRIMARY KEY, @@ -591,10 +660,12 @@ app.post('/api/auth/login', apiLimiter, async (req, res) => { const { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]); const user = rows[0]; if (!user || !user.is_active) { + await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS); return res.status(401).json({ error: 'Неверный логин или пароль' }); } const valid = await bcrypt.compare(password, user.password_hash || ''); if (!valid) { + await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS); return res.status(401).json({ error: 'Неверный логин или пароль' }); } const token = crypto.randomBytes(32).toString('hex'); @@ -613,6 +684,25 @@ app.get('/api/auth/me', requireAuth, async (req, res) => { res.json(safeUser(req.user)); }); +app.get('/api/bans', requireAuth, requireAdmin, async (_, res) => { + const { rows } = await pool.query( + 'SELECT ip, reason, banned_until, created_at FROM banned_ips WHERE banned_until > now() ORDER BY banned_until DESC' + ); + res.json(rows); +}); + +app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => { + const ip = String(req.params.ip || '').trim(); + if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) { + return res.status(400).json({ error: 'Некорректный IP' }); + } + await pool.query('DELETE FROM banned_ips WHERE ip = $1', [ip]); + banMemory.delete(ip); + failMemory.forEach((_, key) => { if (key.endsWith(':' + ip)) failMemory.delete(key); }); + await logAudit(req, 'ip.unban', { ip }); + res.json({ ok: true }); +}); + // --- Users (admin only) --- app.get('/api/users', requireAuth, requireAdmin, async (_, res) => { const { rows } = await pool.query( @@ -1452,6 +1542,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => { } const valid = await bcrypt.compare(providedPassword, l.access_password_hash); if (!valid) { + await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS); return res.status(401).json({ error: 'Неверный пароль' }); } } @@ -1538,7 +1629,10 @@ app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) const providedPassword = req.headers['x-share-password'] || req.query.password; if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' }); const valid = await bcrypt.compare(providedPassword, l.access_password_hash); - if (!valid) return res.status(401).json({ error: 'Неверный пароль' }); + if (!valid) { + await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS); + return res.status(401).json({ error: 'Неверный пароль' }); + } } const conditions = ['e.deleted_at IS NULL']; const params = []; @@ -2558,6 +2652,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => { if (website) { photos.forEach(removeUpload); projectFiles.forEach(removeUpload); + await recordFailure(req, 'honeypot', 1, BAN_TTL_MS); return res.status(400).json({ error: 'Spam detected' }); } if (!student_name?.trim() || !group_id || !description?.trim()) { @@ -3080,6 +3175,9 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); } try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); } try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); } + try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); } + try { await loadBans(); } catch (err) { console.error('Load bans:', err); } + setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref(); try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); } try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); } try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }