From 6d5b8a98fdeb9c4b28ce0bd29c88b11772f84d17 Mon Sep 17 00:00:00 2001 From: cc-dan Date: Sun, 15 Mar 2026 11:55:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=98=B2=E7=88=86=E7=A0=B4=20-=205?= =?UTF-8?q?=E5=88=86=E9=92=9F=E5=86=85=E5=AF=86=E7=A0=81=E9=94=993?= =?UTF-8?q?=E6=AC=A1=E6=B0=B8=E4=B9=85=E5=B0=81=E7=A6=81IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/app.js | 10 ++++++- public/style.css | 6 ++--- server.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/public/app.js b/public/app.js index 8b3049d..5cedb27 100644 --- a/public/app.js +++ b/public/app.js @@ -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; diff --git a/public/style.css b/public/style.css index 7c8b2ce..5aed2b0 100644 --- a/public/style.css +++ b/public/style.css @@ -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 { diff --git a/server.js b/server.js index 23b593c..6147933 100644 --- a/server.js +++ b/server.js @@ -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; }