feat: 防爆破 - 5分钟内密码错3次永久封禁IP
This commit is contained in:
@@ -1203,7 +1203,15 @@
|
|||||||
document.dispatchEvent(new CustomEvent('cc-web-auth-failed'));
|
document.dispatchEvent(new CustomEvent('cc-web-auth-failed'));
|
||||||
loginOverlay.hidden = false;
|
loginOverlay.hidden = false;
|
||||||
app.hidden = true;
|
app.hidden = true;
|
||||||
|
if (msg.banned) {
|
||||||
|
loginError.textContent = '该 IP 已被永久封禁';
|
||||||
loginError.hidden = false;
|
loginError.hidden = false;
|
||||||
|
loginPassword.disabled = true;
|
||||||
|
loginForm.querySelector('button[type="submit"]').disabled = true;
|
||||||
|
} else {
|
||||||
|
loginError.textContent = '密码错误';
|
||||||
|
loginError.hidden = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -1016,12 +1016,10 @@ body.session-loading-active {
|
|||||||
.msg.user .msg-avatar { background: var(--bg-bubble-user); color: #fff; }
|
.msg.user .msg-avatar { background: var(--bg-bubble-user); color: #fff; }
|
||||||
.msg.assistant .msg-avatar { background: var(--success); color: #fff; }
|
.msg.assistant .msg-avatar { background: var(--success); color: #fff; }
|
||||||
.msg-avatar svg { display: block; flex-shrink: 0; }
|
.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 {
|
.msg.assistant.agent-claude .msg-avatar {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent);
|
border: none;
|
||||||
border: 1.5px solid var(--accent);
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
}
|
||||||
/* Codex avatar: GPT logo on green bg */
|
/* Codex avatar: GPT logo on green bg */
|
||||||
.msg.assistant.agent-codex .msg-avatar {
|
.msg.assistant.agent-codex .msg-avatar {
|
||||||
|
|||||||
68
server.js
68
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 AUTH_CONFIG_PATH = path.join(CONFIG_DIR, 'auth.json');
|
||||||
const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
|
const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
|
||||||
const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.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(SESSIONS_DIR, { recursive: true });
|
||||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||||
@@ -432,6 +433,50 @@ let PASSWORD = authConfig.password;
|
|||||||
|
|
||||||
const activeTokens = new Set();
|
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 }
|
// Pending slash command metadata: sessionId -> { kind: string }
|
||||||
const pendingSlashCommands = new Map();
|
const pendingSlashCommands = new Map();
|
||||||
|
|
||||||
@@ -1496,7 +1541,18 @@ const server = http.createServer((req, res) => {
|
|||||||
// === WebSocket Server ===
|
// === WebSocket Server ===
|
||||||
const wss = new WebSocketServer({ 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 authenticated = false;
|
||||||
let authToken = null;
|
let authToken = null;
|
||||||
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
||||||
@@ -1512,6 +1568,12 @@ wss.on('connection', (ws) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'auth') {
|
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))) {
|
if (msg.password === PASSWORD || (msg.token && activeTokens.has(msg.token))) {
|
||||||
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
|
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
|
||||||
activeTokens.add(authToken);
|
activeTokens.add(authToken);
|
||||||
@@ -1519,7 +1581,9 @@ wss.on('connection', (ws) => {
|
|||||||
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
|
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
|
||||||
sendSessionList(ws);
|
sendSessionList(ws);
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user