diff --git a/.env.example b/.env.example index 36b06d9..b3b6c10 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/_check_ip.js b/_check_ip.js new file mode 100644 index 0000000..47d5ec2 --- /dev/null +++ b/_check_ip.js @@ -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); diff --git a/config.js b/config.js index 45f25e5..ed8eb4d 100644 --- a/config.js +++ b/config.js @@ -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')), diff --git a/server.js b/server.js index 5e7a9e3..3867087 100644 --- a/server.js +++ b/server.js @@ -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: '保存失败' }); } });