security(review): fix invite bypass via smuggled seed flag, login rate limit, server-side logout, attachment preview XSS hardening
- /auth/register: strip client-supplied seed flag (PoC confirmed it bypassed invite code, MAX_USERS quota AND first-user auto-admin, granting admin without any credential); demo-rebuild internal path unaffected - /auth/login: per-IP+username of 5 consecutive failures gets a 10-minute cooldown against in-memory counter (brute-force guard) - /auth/logout: new endpoint revokes the current Bearer token server-side; frontend calls it (best-effort) before local clearToken on sidebar logout - attachment docx/xlsx preview: sanitize generated HTML (strip scripts, event handlers, dangerous protocols) before dangerouslySetInnerHTML render, preventing token theft via malicious previewed documents PoC suite: seed smuggling rejected, 429 on 6th login attempt, token dead after logout, per-user isolation intact (403/401/404 as before); integration 61/61 green
This commit is contained in:
@@ -183,6 +183,8 @@ const MIME = {
|
||||
|
||||
// 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库)
|
||||
const userCommands = new Map();
|
||||
const loginFails = new Map(); // 登录防爆破计数:key = IP|username,连续失败进入冷却
|
||||
|
||||
const gzipCache = new Map(); // 指纹资产 gzip 预压缩缓存
|
||||
let demoUserId;
|
||||
function commandsFor(userId) {
|
||||
@@ -237,9 +239,24 @@ const server = http.createServer(async (req, res) => {
|
||||
const body = await readJson(req);
|
||||
if (route === '/auth/register') {
|
||||
if (!REGISTER_ENABLED) return send(403, false, '注册已关闭');
|
||||
// 加固:seed 是服务端内部播种标志(demo-rebuild 内部调用),客户端请求体一律剥离,
|
||||
// 防止走私该标志绕过邀请码 / MAX_USERS 名额 / 首个用户自动升管理员
|
||||
delete body.seed;
|
||||
return send(200, true, register(body));
|
||||
}
|
||||
return send(200, true, login(body));
|
||||
// 登录限速:同 IP+同用户名连续失败 5 次 → 该组合冷却 10 分钟(内存计数,重启即清)
|
||||
const lmKey = String(req.socket.remoteAddress || '') + '|' + String(body.username || '').trim().toLowerCase();
|
||||
const lmF = loginFails.get(lmKey);
|
||||
if (lmF && lmF.until > Date.now()) return send(429, false, '尝试次数过多,请 10 分钟后再试');
|
||||
try {
|
||||
const out = login(body);
|
||||
loginFails.delete(lmKey);
|
||||
return send(200, true, out);
|
||||
} catch (e) {
|
||||
const n = (loginFails.get(lmKey)?.n || 0) + 1;
|
||||
loginFails.set(lmKey, { n, until: n >= 5 ? Date.now() + 10 * 60_000 : 0 });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 管理(仅 is_admin 登录会话) ----
|
||||
@@ -391,6 +408,13 @@ const server = http.createServer(async (req, res) => {
|
||||
try { auth = authUser(req); }
|
||||
catch (e) { return send(e.code || 401, false, e.message); }
|
||||
|
||||
if (req.method === 'POST' && route === '/auth/logout') {
|
||||
// 服务端吊销当前 Bearer token(前端本地清除前调用;跨设备「登出其一」)
|
||||
const tk = (req.headers['authorization'] || '').slice(7).trim();
|
||||
if (tk) getDb().prepare('DELETE FROM tokens WHERE token=?').run(tk);
|
||||
return send(200, true, { ok: true });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && route === '/auth/me') {
|
||||
const row = getDb().prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(auth.user_id);
|
||||
return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', is_admin: !!row?.is_admin, device_id: auth.device_id });
|
||||
|
||||
Reference in New Issue
Block a user