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:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user