feat: 防爆破 - 5分钟内密码错3次永久封禁IP

This commit is contained in:
cc-dan
2026-03-15 11:55:01 +00:00
parent 8112b0b89c
commit 6d5b8a98fd
3 changed files with 77 additions and 7 deletions

View File

@@ -1203,7 +1203,15 @@
document.dispatchEvent(new CustomEvent('cc-web-auth-failed'));
loginOverlay.hidden = false;
app.hidden = true;
loginError.hidden = false;
if (msg.banned) {
loginError.textContent = '该 IP 已被永久封禁';
loginError.hidden = false;
loginPassword.disabled = true;
loginForm.querySelector('button[type="submit"]').disabled = true;
} else {
loginError.textContent = '密码错误';
loginError.hidden = false;
}
}
break;

View File

@@ -1016,12 +1016,10 @@ body.session-loading-active {
.msg.user .msg-avatar { background: var(--bg-bubble-user); color: #fff; }
.msg.assistant .msg-avatar { background: var(--success); color: #fff; }
.msg-avatar svg { display: block; flex-shrink: 0; }
/* Claude avatar: transparent bg, pixel crab uses theme accent color */
/* Claude avatar: transparent bg, fixed-color pixel crab */
.msg.assistant.agent-claude .msg-avatar {
background: transparent;
color: var(--accent);
border: 1.5px solid var(--accent);
font-size: 18px;
border: none;
}
/* Codex avatar: GPT logo on green bg */
.msg.assistant.agent-codex .msg-avatar {

View File

@@ -32,6 +32,7 @@ const NOTIFY_CONFIG_PATH = path.join(CONFIG_DIR, 'notify.json');
const AUTH_CONFIG_PATH = path.join(CONFIG_DIR, 'auth.json');
const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json');
const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json');
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(LOGS_DIR, { recursive: true });
@@ -432,6 +433,50 @@ let PASSWORD = authConfig.password;
const activeTokens = new Set();
// === Anti-brute-force ===
const AUTH_FAIL_WINDOW = 5 * 60 * 1000; // 5 minutes
const AUTH_FAIL_MAX = 3;
const authFailures = new Map(); // ip -> [timestamp, ...]
let bannedIPs = new Set();
function loadBannedIPs() {
try {
if (fs.existsSync(BANNED_IPS_PATH)) {
const list = JSON.parse(fs.readFileSync(BANNED_IPS_PATH, 'utf8'));
bannedIPs = new Set(Array.isArray(list) ? list : []);
}
} catch { bannedIPs = new Set(); }
}
function saveBannedIPs() {
fs.writeFileSync(BANNED_IPS_PATH, JSON.stringify([...bannedIPs], null, 2));
}
loadBannedIPs();
function getClientIP(ws) {
const req = ws._req;
if (!req) return null;
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return req.socket?.remoteAddress || null;
}
function recordAuthFailure(ip) {
if (!ip) return false;
const now = Date.now();
let list = authFailures.get(ip) || [];
list.push(now);
list = list.filter(t => now - t < AUTH_FAIL_WINDOW);
authFailures.set(ip, list);
if (list.length >= AUTH_FAIL_MAX) {
bannedIPs.add(ip);
saveBannedIPs();
authFailures.delete(ip);
plog('WARN', 'ip_banned', { ip, reason: `${AUTH_FAIL_MAX} failed auth in ${AUTH_FAIL_WINDOW / 1000}s` });
return true;
}
return false;
}
// Pending slash command metadata: sessionId -> { kind: string }
const pendingSlashCommands = new Map();
@@ -1496,7 +1541,18 @@ const server = http.createServer((req, res) => {
// === WebSocket Server ===
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
wss.on('connection', (ws, req) => {
ws._req = req;
const clientIP = getClientIP(ws);
// Check if IP is banned
if (clientIP && bannedIPs.has(clientIP)) {
plog('WARN', 'banned_ip_rejected', { ip: clientIP });
wsSend(ws, { type: 'auth_result', success: false, banned: true });
ws.close();
return;
}
let authenticated = false;
let authToken = null;
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
@@ -1512,6 +1568,12 @@ wss.on('connection', (ws) => {
}
if (msg.type === 'auth') {
// Check ban before processing auth
if (clientIP && bannedIPs.has(clientIP)) {
wsSend(ws, { type: 'auth_result', success: false, banned: true });
ws.close();
return;
}
if (msg.password === PASSWORD || (msg.token && activeTokens.has(msg.token))) {
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
activeTokens.add(authToken);
@@ -1519,7 +1581,9 @@ wss.on('connection', (ws) => {
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
sendSessionList(ws);
} else {
wsSend(ws, { type: 'auth_result', success: false });
const justBanned = recordAuthFailure(clientIP);
wsSend(ws, { type: 'auth_result', success: false, banned: justBanned });
if (justBanned) ws.close();
}
return;
}