Add IP autoban system: banned_ips table, global ipGuard middleware, 24h bans on honeypot fill / 10 failed logins / 10 wrong share passwords; trust proxy for real client IPs behind funnel; admin API and settings UI to manage active bans

This commit is contained in:
dev
2026-09-12 00:35:51 +03:00
parent 2bebfc071c
commit 2c7ec17c8b
5 changed files with 145 additions and 1 deletions
+7
View File
@@ -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,
+7
View File
@@ -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 (
+23
View File
@@ -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 = '<span class="chip">Не удалось загрузить</span>'; return; }
const bans = await res.json();
if (!bans.length) { el.innerHTML = '<span class="chip">Активных блокировок нет</span>'; return; }
el.innerHTML = bans.map(b => `
<span class="chip">
<b>${esc(b.ip)}</b> — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
<button type="button" data-unban="${esc(b.ip)}" title="Разблокировать" style="background:none;border:none;color:var(--primary);cursor:pointer;font-weight:700">✕</button>
</span>`).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();
}
})();
+9
View File
@@ -138,6 +138,15 @@
</div>
<div class="restore-status" id="restoreStatus"></div>
</div>
<div class="settings-card" id="bansCard">
<div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div>
<h3>Блокировки IP</h3>
</div>
<p class="hint">IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.</p>
<div class="data-stats" id="bansList"><span class="chip">Загрузка…</span></div>
</div>
</div>
</div>
</div>
+99 -1
View File
@@ -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); }