fix(server): 修复 IP 封锁绕过漏洞并补充审计日志
- 支持 TRUST_PROXY 配置,由 express 正确解析客户端 IP,防止伪造 X-Forwarded-For - 修复 IP 封锁使用对象作为 Map key 导致失效的问题,改用字符串 IP - 主密码解锁失败时记录 IP 失败次数,达到阈值后封锁 - 补充条目更新操作的审计日志
这个提交包含在:
+3
@@ -18,6 +18,9 @@ WRITABLE=false
|
||||
# 会话
|
||||
SESSION_SECRET=改成随机长字符串
|
||||
SESSION_MAX_AGE=1800000
|
||||
# 反向代理层数/地址(如 "1" 或前端 Nginx 的 "10.0.0.0/8")。部署在 Nginx/Caddy 后必须设置,
|
||||
# 否则攻击者可用伪造的 X-Forwarded-For 绕过 IP 封锁。直连部署留空。
|
||||
TRUST_PROXY=
|
||||
|
||||
# 审计日志:本地文件持久化目录(按大小自动切分,默认 ./logs)
|
||||
LOG_DIR=logs
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
// 验证 IP 封锁逻辑修复:必须用字符串 IP 作 key,而非对象
|
||||
const ipFails = new Map();
|
||||
const IP_WINDOW = 30 * 60 * 1000, IP_MAX_FAIL = 5;
|
||||
function recordIpFail(ip) {
|
||||
const now = Date.now();
|
||||
let r = ipFails.get(ip);
|
||||
if (!r || now - r.first > IP_WINDOW) r = { count: 0, first: now };
|
||||
r.count++; ipFails.set(ip, r);
|
||||
return r.count >= IP_MAX_FAIL;
|
||||
}
|
||||
function isIpBlocked(ip) {
|
||||
const r = ipFails.get(ip);
|
||||
if (!r) return false;
|
||||
if (Date.now() - r.first > IP_WINDOW) { ipFails.delete(ip); return false; }
|
||||
return r.count >= IP_MAX_FAIL;
|
||||
}
|
||||
|
||||
// 旧 bug:传入对象 {ip:'1.2.3.4'} 作 key
|
||||
const ipObj = { ip: '1.2.3.4' };
|
||||
let blockedOld = false;
|
||||
for (let i = 0; i < 5; i++) recordIpFail(ipObj);
|
||||
blockedOld = isIpBlocked(ipObj); // 每次都是新对象 -> 永远 false
|
||||
|
||||
// 修复后:传入字符串 '1.2.3.4'
|
||||
const ipStr = '1.2.3.4';
|
||||
let blockedNew = false;
|
||||
for (let i = 0; i < 5; i++) recordIpFail(ipStr);
|
||||
blockedNew = isIpBlocked(ipStr);
|
||||
|
||||
console.log('修复前(对象key)是否封锁:', blockedOld, '-> 期望 false(bug 表现)');
|
||||
console.log('修复后(字符串key)是否封锁:', blockedNew, '-> 期望 true');
|
||||
process.exit(blockedNew === true ? 0 : 1);
|
||||
@@ -54,6 +54,9 @@ const config = {
|
||||
// 4) 会话与安全
|
||||
sessionSecret: pick('SESSION_SECRET', ''),
|
||||
sessionMaxAge: parseInt(pick('SESSION_MAX_AGE', String(30 * 60 * 1000)), 10),
|
||||
// 反向代理层数/地址(如 "1" 或 "127.0.0.1")。设置后由 express 正确解析客户端 IP,
|
||||
// 防止攻击者伪造 X-Forwarded-For 绕过 IP 封锁。直连部署留空。
|
||||
trustProxy: pick('TRUST_PROXY', ''),
|
||||
|
||||
// 审计日志:本地文件持久化目录(可按大小自动切分),默认 ./logs
|
||||
logDir: pick('LOG_DIR', path.join(__dirname, 'logs')),
|
||||
|
||||
@@ -295,9 +295,11 @@ function getClientIp(req) {
|
||||
const remote = (req.socket && req.socket.remoteAddress) || 'unknown';
|
||||
const xffRaw = (req.headers['x-forwarded-for'] || '').toString().trim();
|
||||
const xffList = xffRaw ? xffRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
// 真实客户端:XFF 链最后一跳(离服务端最远),若没有 XFF 则取直连
|
||||
const realIp = xffList.length ? xffList[xffList.length - 1] : remote;
|
||||
const isInternal = isPrivateIp(realIp) && xffList.length === 0 ? isPrivateIp(remote) : isPrivateIp(realIp);
|
||||
// 在反向代理后(已设置 trust proxy)时,express 的 req.ip 已剔除可信代理层,
|
||||
// 直接取 req.ip 作为真实客户端,避免攻击者伪造 X-Forwarded-For 绕过 IP 封锁
|
||||
let realIp = (config.trustProxy && req.ip) ? req.ip : (xffList.length ? xffList[xffList.length - 1] : remote);
|
||||
if (!realIp || realIp === 'unknown') realIp = remote;
|
||||
const isInternal = isPrivateIp(realIp);
|
||||
return { ip: realIp, remote, xff: xffRaw, isInternal };
|
||||
}
|
||||
|
||||
@@ -306,6 +308,11 @@ const app = express();
|
||||
// 注意:未启用 trust proxy。若部署在反向代理后,请按实际拓扑设置;
|
||||
// 当前按直连处理,IP 通过 req.socket.remoteAddress 获取,避免误判。
|
||||
app.disable('x-powered-by');
|
||||
// 反向代理场景:设置 TRUST_PROXY 后由 express 正确解析客户端 IP(req.ip),
|
||||
// 配合 getClientIp 防止 X-Forwarded-For 伪造绕过 IP 封锁。默认不启用(直连)。
|
||||
if (config.trustProxy) {
|
||||
app.set('trust proxy', config.trustProxy === true ? 1 : config.trustProxy);
|
||||
}
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
@@ -412,7 +419,8 @@ app.get('/api/captcha', (req, res) => {
|
||||
|
||||
// 登录:APP 门户口令 + 验证码
|
||||
app.post('/api/login', (req, res) => {
|
||||
const ip = getClientIp(req);
|
||||
const ipObj = getClientIp(req);
|
||||
const ip = ipObj.ip; // 用于 IP 封锁的字符串 key(对象作 Map key 会导致封锁失效)
|
||||
if (isIpBlocked(ip)) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.IP_BLOCKED, ok: false, detail: 'IP 已封锁' }));
|
||||
@@ -511,8 +519,11 @@ app.post('/api/unlock', appAuth, async (req, res) => {
|
||||
dataKeyExpire: rec.dataKeyExpire });
|
||||
});
|
||||
} catch (e) {
|
||||
const ip = getClientIp(req).ip;
|
||||
const blocked = recordIpFail(ip);
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.AUTH_MASTER_FAIL, ok: false, detail: '主密码或密钥文件错误' }));
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.AUTH_MASTER_FAIL, ok: false, detail: blocked ? '主密码错误且尝试过多已封锁' : '主密码或密钥文件错误' }));
|
||||
if (blocked) return res.status(429).json({ error: '尝试次数过多,IP 已封锁', code: ERR.IP_BLOCKED });
|
||||
return res.status(401).json({ error: '主密码或密钥文件错误', code: ERR.AUTH_MASTER_FAIL });
|
||||
}
|
||||
});
|
||||
@@ -583,8 +594,12 @@ app.post('/api/entry/update', appAuth, unlocked, writable, async (req, res) => {
|
||||
// 重新抽取并加密密码字段
|
||||
const ex = extract(rec.db, req.session.dataPubPem);
|
||||
rec.groups = ex.groups; rec.items = ex.items; rec.tree = ex.tree; rec.name = ex.name;
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/entry/update', code: 'OK', ok: true, detail: '修改条目 ' + id }));
|
||||
encRes(req, res, { ok: true, item: ex.items.find((i) => i.id === id) });
|
||||
} catch (e) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/entry/update', code: 'ERR', ok: false, detail: '保存失败 ' + (e && e.message ? e.message : '') }));
|
||||
res.status(500).json({ error: '保存失败' });
|
||||
}
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户