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:
root
2026-10-17 21:18:55 +08:00
parent 3613b9f5e1
commit 17ab5f0390
20 changed files with 416 additions and 358 deletions
+25 -1
View File
@@ -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 });