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
+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); }