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:
@@ -6,6 +6,13 @@ CREATE TABLE IF NOT EXISTS branches (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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 (
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ CREATE TABLE IF NOT EXISTS students (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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);
|
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
|||||||
@@ -238,6 +238,28 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
|||||||
e.target.value = '';
|
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('backupBtn').addEventListener('click', downloadBackup);
|
||||||
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
|
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
|
||||||
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||||
@@ -247,5 +269,6 @@ document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
|||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
bindDirty();
|
bindDirty();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadBans();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -138,6 +138,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,65 @@ function invalidateShare() { cacheDrop('share:payload:'); }
|
|||||||
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
||||||
function invalidateAll() { cacheStore.clear(); }
|
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({
|
const apiLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 300,
|
max: 300,
|
||||||
@@ -114,6 +173,7 @@ app.use(helmet({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(ipGuard);
|
||||||
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
||||||
const THUMB_WIDTH = 480;
|
const THUMB_WIDTH = 480;
|
||||||
let sharp = null;
|
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`);
|
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() {
|
async function ensureEntryPhotosTable() {
|
||||||
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
||||||
id SERIAL PRIMARY KEY,
|
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 { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]);
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
|
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
||||||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||||||
}
|
}
|
||||||
const valid = await bcrypt.compare(password, user.password_hash || '');
|
const valid = await bcrypt.compare(password, user.password_hash || '');
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
||||||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||||||
}
|
}
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
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));
|
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) ---
|
// --- Users (admin only) ---
|
||||||
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
||||||
const { rows } = await pool.query(
|
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);
|
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
||||||
return res.status(401).json({ error: 'Неверный пароль' });
|
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;
|
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
||||||
if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' });
|
if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' });
|
||||||
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
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 conditions = ['e.deleted_at IS NULL'];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -2558,6 +2652,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|||||||
if (website) {
|
if (website) {
|
||||||
photos.forEach(removeUpload);
|
photos.forEach(removeUpload);
|
||||||
projectFiles.forEach(removeUpload);
|
projectFiles.forEach(removeUpload);
|
||||||
|
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
|
||||||
return res.status(400).json({ error: 'Spam detected' });
|
return res.status(400).json({ error: 'Spam detected' });
|
||||||
}
|
}
|
||||||
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
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 ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
||||||
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
||||||
try { await ensureAuditTable(); } catch (err) { console.error('Audit 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 ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
||||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||||
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||||
|
|||||||
Reference in New Issue
Block a user