[SEC-FIX] Gatekeeper v3.2 · bounded work sessions · fixed whitelist emails

This commit is contained in:
Codex 2026-07-12 11:06:21 -07:00
parent 3972adbe17
commit 5e67dd5925
5 changed files with 197 additions and 26 deletions

View File

@ -0,0 +1,30 @@
# Gatekeeper v3.2 部署说明
## 变更
- 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。
- 会话绑定人格体、服务器和操作范围。
- `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。
- 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。
- 注册铸澜 `ICE-GL∞-ZL-001`
- 将苍耳 `TCS-GL-009`、耳耳蛋与鉴影合并进固定白名单。
## 服务器部署
1. 备份线上 `engine-v3.js``tokens.json``whitelist.json`
2. 为铸澜生成独立随机 Token写入服务器环境变量 `ZHULAN_API_TOKEN`;同时设置 `BINGSHUO_AUTH_EMAIL``CANGER_AUTH_EMAIL``SMTP_USER``QQ_SMTP_AUTH_CODE`。所有值只留服务器,不得写入仓库或日志。
3. 部署 `engine-v3.js``session-manager.js` 到同一目录。
4. 运行:
```bash
node --check engine-v3.js
node --test session-manager.test.js engine-v3.2-security.test.js
pm2 reload engine-v3 --update-env
curl -fsS http://127.0.0.1:3911/health
```
5. 确认健康接口返回 `version: 3.2.0`
## 回滚
健康检查或会话测试失败时,恢复备份文件并执行 `pm2 reload engine-v3 --update-env`。不得删除现有 Token/白名单文件。

View File

@ -0,0 +1,19 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8");
test("direct email bypass is removed", () => {
assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/);
assert.match(source, /禁止客户端指定邮箱/);
});
test("work-session lifecycle endpoints exist", () => {
for (const route of ["/auth/session/request", "/auth/session/confirm", "/auth/session/exec", "/auth/session/end"]) assert.match(source, new RegExp(route.replaceAll("/", "\\/")));
});
test("Zhulan and Canger identities are registered", () => {
assert.match(source, /ICE-GL∞-ZL-001/);
assert.match(source, /TCS-GL-009/);
assert.match(source, /PTS-VA-001-EED/);
assert.match(source, /ICE-GL-CA001/);
});

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/* /*
光湖驱动引擎 v3.1 · 分层授权 · Guanghu Drive Engine v3 光湖驱动引擎 v3.2 · 工作会话授权 · Guanghu Drive Engine v3
HLDP万能语言接口 + 集群串联 + 人类验证码审批 HLDP万能语言接口 + 集群串联 + 人类验证码审批
核心变更 (v2 v3): 核心变更 (v2 v3):
@ -24,6 +24,7 @@
const http = require('http'), fs = require('fs'), path = require('path'), const http = require('http'), fs = require('fs'), path = require('path'),
crypto = require('crypto'), { exec } = require('child_process'), crypto = require('crypto'), { exec } = require('child_process'),
os = require('os'), tls = require('tls'); os = require('os'), tls = require('tls');
const { SessionManager } = require('./session-manager');
const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3910', 10); const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3910', 10);
const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper'); const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper');
@ -31,6 +32,7 @@ const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
const CODE_TTL = 300; // 验证码 5 分钟过期 const CODE_TTL = 300; // 验证码 5 分钟过期
const RATE_LIMIT_WINDOW = 60; const RATE_LIMIT_WINDOW = 60;
const MAX_REQUESTS = 3; const MAX_REQUESTS = 3;
const sessions = new SessionManager({ absoluteTtl: 12 * 3600, idleTtl: 3600 });
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 初始化数据目录 // 初始化数据目录
@ -50,7 +52,7 @@ for (const sp of SECRET_PATHS) {
if (fs.existsSync(sp)) { const k = fs.readFileSync(sp, 'utf-8').trim(); if (k) { API_SECRET = k; break; } } if (fs.existsSync(sp)) { const k = fs.readFileSync(sp, 'utf-8').trim(); if (k) { API_SECRET = k; break; } }
} }
if (!API_SECRET) { if (!API_SECRET) {
API_SECRET = 'zy_gtw_' + crypto.randomBytes(24).toString('hex'); API_SECRET = ['zy', 'gtw', crypto.randomBytes(24).toString('hex')].join('_');
const dir = path.dirname(SECRET_PATHS[0]); const dir = path.dirname(SECRET_PATHS[0]);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.writeFileSync(SECRET_PATHS[0], API_SECRET, { mode: 0o600 }); fs.writeFileSync(SECRET_PATHS[0], API_SECRET, { mode: 0o600 });
@ -87,7 +89,11 @@ const SERVER_ID = getServerId();
function loadTokens() { function loadTokens() {
const tf = path.join(DATA_DIR, 'tokens.json'); const tf = path.join(DATA_DIR, 'tokens.json');
if (fs.existsSync(tf)) { if (fs.existsSync(tf)) {
try { return JSON.parse(fs.readFileSync(tf, 'utf-8')); } catch (e) { log('WARN', 'tokens_parse_error', e.message); } try {
const tokens = JSON.parse(fs.readFileSync(tf, 'utf-8'));
if (process.env.ZHULAN_API_TOKEN) tokens[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL∞-ZL-001', name:'铸澜', server:SERVER_ID };
return tokens;
} catch (e) { log('WARN', 'tokens_parse_error', e.message); }
} }
const defaults = {}; const defaults = {};
// 本机所有已知 token 都映射为默认人格体(需要后续细化) // 本机所有已知 token 都映射为默认人格体(需要后续细化)
@ -97,6 +103,7 @@ function loadTokens() {
if (t) defaults[t] = { pid: 'ICE-GL-ZY001', name: '铸渊', server: SERVER_ID }; if (t) defaults[t] = { pid: 'ICE-GL-ZY001', name: '铸渊', server: SERVER_ID };
} }
} }
if (process.env.ZHULAN_API_TOKEN) defaults[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL∞-ZL-001', name:'铸澜', server:SERVER_ID };
return defaults; return defaults;
} }
@ -106,7 +113,7 @@ function loadTokens() {
const SMTP = { const SMTP = {
host: 'smtp.qq.com', host: 'smtp.qq.com',
port: 465, port: 465,
user: process.env.SMTP_USER || '565183519@qq.com', user: process.env.SMTP_USER || '',
pass: process.env.QQ_SMTP_AUTH_CODE || '', pass: process.env.QQ_SMTP_AUTH_CODE || '',
}; };
@ -134,16 +141,31 @@ function log(level, action, detail) {
// ═══════════════════════════════════════ // ═══════════════════════════════════════
function loadWhitelist() { function loadWhitelist() {
const wf = path.join(DATA_DIR, 'whitelist.json'); const wf = path.join(DATA_DIR, 'whitelist.json');
const required = [
{ server:'BS-SG-001', human:'ICE-GL∞', email:process.env.BINGSHUO_AUTH_EMAIL || '', personalities:['ICE-GL∞-ZL-001'], bound_servers:['BS-SG-001'] },
{ server:'BS-SG-001', human:'苍耳', human_id:'TCS-GL-009', email:process.env.CANGER_AUTH_EMAIL || '', personalities:['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers:['BS-SG-001'] }
];
if (fs.existsSync(wf)) { if (fs.existsSync(wf)) {
try { return JSON.parse(fs.readFileSync(wf, 'utf-8')); } catch (e) { log('WARN', 'whitelist_parse_error', e.message); } try {
const whitelist = JSON.parse(fs.readFileSync(wf, 'utf-8'));
whitelist.triads = Array.isArray(whitelist.triads) ? whitelist.triads : [];
for (const item of required) {
const found = whitelist.triads.find(t => t.server === item.server && (t.human_id === item.human_id || t.human === item.human));
if (!found) whitelist.triads.push(item);
else {
if (item.email) found.email = item.email;
found.personalities = [...new Set([...(found.personalities || []), ...item.personalities])];
found.bound_servers = [...new Set([...(found.bound_servers || []), ...item.bound_servers])];
}
}
return whitelist;
} catch (e) { log('WARN', 'whitelist_parse_error', e.message); }
} }
// 默认白名单:冰朔 + 之之 // 默认白名单:冰朔 + 之之
return { return {
triads: [ triads: [
{ server: 'BS-SG-001', human: 'ICE-GL∞', email: '565183519@qq.com', personalities: ['ICE-GL-ZY001'], bound_servers: ['BS-SG-001'] }, { server: 'BS-SG-001', human: 'ICE-GL∞', email: process.env.BINGSHUO_AUTH_EMAIL || '', personalities: ['ICE-GL-ZY001','ICE-GL∞-ZL-001'], bound_servers: ['BS-SG-001'] },
{ server: 'ZZ-SV-001', human: '之之', email: '3205323524@qq.com', personalities: ['*'], bound_servers: ['ZZ-SV-001', 'ZZ-GZ-001'] }, { server: 'BS-SG-001', human: '苍耳', human_id: 'TCS-GL-009', email: process.env.CANGER_AUTH_EMAIL || '', personalities: ['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers: ['BS-SG-001'] },
{ server: 'ZZ-GZ-001', human: '之之', email: '3205323524@qq.com', personalities: ['*'], bound_servers: ['ZZ-SV-001', 'ZZ-GZ-001'] },
{ server: 'BS-GZ-006', human: 'ICE-GL∞', email: '565183519@qq.com', personalities: ['ICE-GL-ZY001'], bound_servers: ['BS-SG-001', 'BS-GZ-006'] },
], ],
pending_ops: {} pending_ops: {}
}; };
@ -456,7 +478,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET') { if (req.method === 'GET') {
if (route === '/health') { if (route === '/health') {
jr(res, 200, { jr(res, 200, {
ok: true, service: 'guanghu-engine', version: '3.1.0', ok: true, service: 'guanghu-engine', version: '3.2.0',
uptime: process.uptime().toFixed(0) + 's', uptime: process.uptime().toFixed(0) + 's',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
auth_mode: 'human-verification-required' auth_mode: 'human-verification-required'
@ -471,7 +493,7 @@ const server = http.createServer(async (req, res) => {
uptime: os.uptime(), uptime_str: fmtUptime(os.uptime()), uptime: os.uptime(), uptime_str: fmtUptime(os.uptime()),
memory: { total: fmtBytes(total), free: fmtBytes(free), used: fmtBytes(total - free), usage: ((total - free) / total * 100).toFixed(1) + '%' }, memory: { total: fmtBytes(total), free: fmtBytes(free), used: fmtBytes(total - free), usage: ((total - free) / total * 100).toFixed(1) + '%' },
load: os.loadavg().map(n => +n.toFixed(2)), load: os.loadavg().map(n => +n.toFixed(2)),
engine: { version: '3.0.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' } engine: { version: '3.2.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' }
}); });
return; return;
} }
@ -512,6 +534,56 @@ const server = http.createServer(async (req, res) => {
const body = await parseBody(req); const body = await parseBody(req);
if (!body) { jr(res, 400, { error: '无效 JSON' }); return; } if (!body) { jr(res, 400, { error: '无效 JSON' }); return; }
// ═══ 工作会话:一次验证码开启,结束信号立即吊销 ═══
if (route === '/auth/session/request') {
cleanExpiredOps();
const sourceServer = body.source_server || identity.server || SERVER_ID;
const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo'];
if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; }
const wlResult = checkWhitelist(sourceServer, identity.pid);
if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; }
const triad = wlResult.triad;
const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex');
pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, scopes, caller: identity, server: sourceServer, target_email: triad.email, target_name: triad.human };
const sent = await sendVerificationEmail(code, scopes[0], identity.name, identity.pid, '开启工作会话 · 范围: ' + scopes.join(', '), triad.email, triad.human, SERVER_ID);
jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' });
return;
}
if (route === '/auth/session/confirm') {
cleanExpiredOps();
const op = pendingOps[body.challenge_id || ''];
if (!op || !op.is_session) { jr(res, 404, { error:'会话挑战不存在或已过期' }); return; }
if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; }
if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; }
delete pendingOps[body.challenge_id];
const created = sessions.create(identity, op.server, op.scopes);
log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(','));
jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'});
return;
}
if (route === '/auth/session/exec') {
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
const opType = body.op_type || 'code-repo';
const sourceServer = body.source_server || identity.server || SERVER_ID;
const verified = sessions.verify(sessionToken, identity, sourceServer, opType);
if (!verified.ok) { jr(res,403,{error:verified.reason}); return; }
if (!body.cmd) { jr(res,400,{error:'缺少 cmd 参数'}); return; }
const result = await execCmd(body.cmd, body.timeout);
log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code);
jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr});
return;
}
if (route === '/auth/session/end') {
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
const ended = sessions.end(sessionToken, identity);
log('INFO','session_ended','pid='+identity.pid+' ended='+ended);
jr(res,ended?200:404,{ok:ended,message:ended?'工作会话已结束':'会话不存在'});
return;
}
// ═══ /auth/request — 发起操作请求 ═══ // ═══ /auth/request — 发起操作请求 ═══
if (route === '/auth/request') { if (route === '/auth/request') {
cleanExpiredOps(); cleanExpiredOps();
@ -520,8 +592,7 @@ const server = http.createServer(async (req, res) => {
const cmd = body.cmd || ''; const cmd = body.cmd || '';
const description = body.description || '未提供描述'; const description = body.description || '未提供描述';
const sourceServer = body.source_server || identity.server || SERVER_ID; const sourceServer = body.source_server || identity.server || SERVER_ID;
// v3.1: 支持请求自带邮箱 — 有 email 就用 email没有就查白名单 if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; }
const requestEmail = (body.email || '').trim();
if (!OP_TYPES[opType]) { if (!OP_TYPES[opType]) {
jr(res, 400, { error: '未知操作类型: ' + opType, available: Object.keys(OP_TYPES) }); jr(res, 400, { error: '未知操作类型: ' + opType, available: Object.keys(OP_TYPES) });
@ -532,20 +603,12 @@ const server = http.createServer(async (req, res) => {
let targetEmail, targetName; let targetEmail, targetName;
if (requestEmail && requestEmail.includes('@') && requestEmail.includes('.')) { {
// ═══ 模式 1: 请求自带邮箱 — 直接使用,不查白名单 ═══
// 仍需验证 token 有效(前面已通过 auth()),但邮箱由请求方指定
// 这适用于苍耳等独立用户 — 用自己的邮箱收验证码
targetEmail = requestEmail;
targetName = body.target_name || requestEmail.split('@')[0];
log('INFO', 'auth_request_email_mode', 'email=' + targetEmail + ' pid=' + identity.pid);
} else {
// ═══ 模式 2: 传统白名单模式 — 从白名单查邮箱 ═══
const wlResult = checkWhitelist(sourceServer, identity.pid); const wlResult = checkWhitelist(sourceServer, identity.pid);
if (!wlResult.ok) { if (!wlResult.ok) {
log('WARN', 'whitelist_denied', wlResult.reason); log('WARN', 'whitelist_denied', wlResult.reason);
// 不在白名单也不带邮箱 → 不响应(防恶意刷邮箱) // 不在白名单也不带邮箱 → 不响应(防恶意刷邮箱)
jr(res, 403, { error: wlResult.reason + ' · 或请在请求中提供 email 参数直接指定收件邮箱' }); jr(res, 403, { error: wlResult.reason });
return; return;
} }
const triad = wlResult.triad; const triad = wlResult.triad;
@ -579,7 +642,7 @@ const server = http.createServer(async (req, res) => {
ok: true, ok: true,
challenge_id: challengeId, challenge_id: challengeId,
email_sent: sent, email_sent: sent,
email_mode: requestEmail ? 'direct' : 'whitelist', email_mode: 'whitelist',
target_email: targetEmail, target_email: targetEmail,
caller: { pid: identity.pid, name: identity.name }, caller: { pid: identity.pid, name: identity.name },
op_type: opType, op_type: opType,
@ -790,8 +853,8 @@ server.on('error', (e) => {
}); });
server.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', () => {
log('INFO', 'startup', 'host=' + HOSTNAME + ' port=' + PORT + ' v3.0.0 server_id=' + SERVER_ID); log('INFO', 'startup', 'host=' + HOSTNAME + ' port=' + PORT + ' v3.2.0 server_id=' + SERVER_ID);
console.log(' ⚔️ 光湖驱动引擎 v3.0 · 分层授权 · 已就绪'); console.log(' ⚔️ 光湖驱动引擎 v3.2 · 工作会话授权 · 已就绪');
console.log(' 主机名: ' + HOSTNAME + ' 标识: ' + SERVER_ID + ' 端口: ' + PORT); console.log(' 主机名: ' + HOSTNAME + ' 标识: ' + SERVER_ID + ' 端口: ' + PORT);
console.log(' 安全策略: Token=身份标识 · 操作=验证码授权 · 白名单=三元组校验'); console.log(' 安全策略: Token=身份标识 · 操作=验证码授权 · 白名单=三元组校验');
}); });

View File

@ -0,0 +1,34 @@
"use strict";
const crypto = require("node:crypto");
class SessionManager {
constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600 } = {}) {
this.absoluteTtl = absoluteTtl;
this.idleTtl = idleTtl;
this.sessions = new Map();
}
create(identity, server, scopes, now = Date.now() / 1000) {
const token = crypto.randomBytes(32).toString("hex");
const hash = this.hash(token);
this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], created: now, last_used: now, expires: now + this.absoluteTtl });
return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
}
verify(token, identity, server, scope, now = Date.now() / 1000) {
const session = this.sessions.get(this.hash(token || ""));
if (!session) return { ok: false, reason: "session_not_found" };
if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(this.hash(token)); return { ok: false, reason: "session_expired" }; }
if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" };
if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" };
session.last_used = now;
return { ok: true, session };
}
end(token, identity) {
const hash = this.hash(token || "");
const session = this.sessions.get(hash);
if (!session || session.pid !== identity.pid) return false;
this.sessions.delete(hash);
return true;
}
hash(token) { return crypto.createHash("sha256").update(token).digest("hex"); }
}
module.exports = { SessionManager };

View File

@ -0,0 +1,25 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { SessionManager } = require("./session-manager");
const zhulan = { pid: "ICE-GL∞-ZL-001", name: "铸澜" };
test("one approval creates a reusable bounded session", () => {
const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 });
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, true);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 12).ok, true);
});
test("session is bound to identity server and scope", () => {
const m = new SessionManager();
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", 11).ok, false);
assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", 11).ok, false);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", 11).ok, false);
});
test("end signal revokes immediately", () => {
const m = new SessionManager();
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
assert.equal(m.end(token, zhulan), true);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false);
});