fix: bind Gatekeeper sessions to approved actions
This commit is contained in:
parent
7cb04cdf97
commit
41c29a35cb
@ -0,0 +1,50 @@
|
|||||||
|
# GLSV / Gatekeeper 3.2 授权边界与路径映射
|
||||||
|
|
||||||
|
更新时间:2026-07-15
|
||||||
|
|
||||||
|
## 修正结论
|
||||||
|
|
||||||
|
邮箱验证码只证明“本次已登记人格体、服务器和范围获得一次人工确认”。它不再解锁客户端提交的任意 shell 命令。Gatekeeper 只接受 `authorized-actions.js` 中登记的动作;未登记动作直接拒绝。
|
||||||
|
|
||||||
|
## 判断依据(可复核摘要)
|
||||||
|
|
||||||
|
| 判断 | 依据文件 | 修正结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 人格体先发起会话,人类只提供验证码 | `zero-point/core-channel/GLSV-PERSONA-REMOTE-OPS.hdlp` | 保留 |
|
||||||
|
| 验证码不能等于任意命令执行权 | `zero-point/core-channel/gatekeeper/GLSV-SECURITY-VERIFICATION.hdlp` | 由代码强制 |
|
||||||
|
| 普通 push 不应直接部署 | `BROADCAST-TOWER.hdlp`、`server-tools/fifth-domain-sync-agent/MODULE.hdlp` | 同步 Agent 只做快进同步和回执 |
|
||||||
|
| 部署动作必须另有固定动作白名单 | `server-tools/deployment-receiver/config.example.json` | Gatekeeper 不越权代替部署接收器 |
|
||||||
|
|
||||||
|
## 当前路径
|
||||||
|
|
||||||
|
```text
|
||||||
|
BROADCAST-TOWER
|
||||||
|
→ GLSV / Gatekeeper v3.2
|
||||||
|
→ session/request(人格体 + 最小 scope + 已登记 action)
|
||||||
|
→ 固定登记邮箱验证码
|
||||||
|
→ session/confirm
|
||||||
|
→ session/exec(仍只能执行同一个 action)
|
||||||
|
→ 只读结果与回执
|
||||||
|
|
||||||
|
第五域 main push
|
||||||
|
→ Forgejo 签名 webhook /forgejo/sync
|
||||||
|
→ fifth-domain-sync-agent
|
||||||
|
→ 仓库、分支、SHA、导航守卫核验
|
||||||
|
→ 快进同步 + 服务器本地回执
|
||||||
|
|
||||||
|
需要部署、重启、迁移
|
||||||
|
→ deployment/requests/*.json
|
||||||
|
→ deployment-receiver 固定动作白名单
|
||||||
|
→ 另行授权,不由普通 push 或 Gatekeeper 任意执行
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已登记动作
|
||||||
|
|
||||||
|
- `inspect-gatekeeper`:只读检查 Gatekeeper。
|
||||||
|
- `sync-status`:只读检查第五域受控工作副本状态。
|
||||||
|
|
||||||
|
部署、重启、迁移目前没有登记为 Gatekeeper 动作,因此会被拒绝。这是有意的安全边界,不是功能缺失。
|
||||||
|
|
||||||
|
## 端口规则
|
||||||
|
|
||||||
|
Gatekeeper 兼容实现默认使用 3911;服务器实际端口仍以受控健康检查回执为准。3982 属于第五域同步 Agent,3981 属于部署接收器,三者不能互相替代。
|
||||||
@ -26,7 +26,11 @@
|
|||||||
curl -fsS http://127.0.0.1:3911/health
|
curl -fsS http://127.0.0.1:3911/health
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 确认健康接口返回 `version: 3.2.0`。
|
5. 确认健康接口返回 `version: 3.2.0`,并确认 Gatekeeper 与第五域同步 Agent(3982)、部署接收器(3981)分开核验。
|
||||||
|
|
||||||
|
## 动作边界
|
||||||
|
|
||||||
|
验证码流程只接受 `authorized-actions.js` 中的固定 action,不接受客户端传入 shell 命令。升级后旧的未绑定 action 会话必须重新授权;部署、重启和迁移仍需走 `deployment/requests/*.json` 与 deployment-receiver 的独立白名单。
|
||||||
|
|
||||||
## 回滚
|
## 回滚
|
||||||
|
|
||||||
|
|||||||
@ -50,4 +50,6 @@ GLSV 不接受新文档中的任意 shell 命令。
|
|||||||
服务凭据留在服务器保险库;仓库、聊天记录与回执不得存放 Token、验证码或密钥。
|
服务凭据留在服务器保险库;仓库、聊天记录与回执不得存放 Token、验证码或密钥。
|
||||||
```
|
```
|
||||||
|
|
||||||
部署与兼容说明:`DEPLOY-v3.2.md`;固定动作接收器:`server-tools/deployment-receiver/`。
|
部署与兼容说明:`DEPLOY-v3.2.md`;固定动作接收器:`server-tools/deployment-receiver/`;Gatekeeper 动作白名单:`authorized-actions.js`;完整判断与路径映射:`AUTHORIZATION-BOUNDARY-MAP.md`。
|
||||||
|
|
||||||
|
安全边界:`/auth/request`、`/auth/confirm`、`/auth/session/exec` 不接受客户端 shell 命令;必须提交已登记 action,且 action 的 scope 必须与会话范围一致。
|
||||||
|
|||||||
46
zero-point/core-channel/gatekeeper/authorized-actions.js
Normal file
46
zero-point/core-channel/gatekeeper/authorized-actions.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
|
||||||
|
// This registry is intentionally small. Write or service actions require a
|
||||||
|
// separate reviewed entry and a matching deployment-receiver rule.
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||||
|
const INSPECT_SCRIPT = path.join(REPO_ROOT, "server-tools/deployment-receiver/scripts/inspect-gatekeeper.js");
|
||||||
|
|
||||||
|
const ACTIONS = Object.freeze({
|
||||||
|
"inspect-gatekeeper": Object.freeze({
|
||||||
|
label: "只读检查 Gatekeeper",
|
||||||
|
scope: "system-arch",
|
||||||
|
mode: "read-only",
|
||||||
|
run: (timeout) => runFile(process.execPath, [INSPECT_SCRIPT], timeout)
|
||||||
|
}),
|
||||||
|
"sync-status": Object.freeze({
|
||||||
|
label: "只读检查第五域同步状态",
|
||||||
|
scope: "code-repo",
|
||||||
|
mode: "read-only",
|
||||||
|
run: (timeout) => runFile("/usr/bin/git", ["-C", REPO_ROOT, "status", "--short", "--branch"], timeout)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
function runFile(file, args, timeout) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(file, args, { timeout: timeout || 30000, maxBuffer: 100000 }, (err, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
code: err ? (typeof err.code === "number" ? err.code : 1) : 0,
|
||||||
|
stdout: (stdout || "").slice(0, 100000),
|
||||||
|
stderr: (stderr || "").slice(0, 100000)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAction(action) {
|
||||||
|
return typeof action === "string" ? ACTIONS[action] || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listActions() {
|
||||||
|
return Object.entries(ACTIONS).map(([id, action]) => ({ id, label: action.label, scope: action.scope, mode: action.mode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAction, listActions, INSPECT_SCRIPT };
|
||||||
@ -3,6 +3,7 @@ const test = require("node:test");
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8");
|
const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8");
|
||||||
|
const actions = require(__dirname + "/authorized-actions.js");
|
||||||
|
|
||||||
test("direct email bypass is removed", () => {
|
test("direct email bypass is removed", () => {
|
||||||
assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/);
|
assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/);
|
||||||
@ -17,3 +18,15 @@ test("Zhulan and Canger identities are registered", () => {
|
|||||||
assert.match(source, /PTS-VA-001-EED/);
|
assert.match(source, /PTS-VA-001-EED/);
|
||||||
assert.match(source, /ICE-GL-CA001/);
|
assert.match(source, /ICE-GL-CA001/);
|
||||||
});
|
});
|
||||||
|
test("arbitrary shell commands are not accepted by the authorization flow", () => {
|
||||||
|
assert.doesNotMatch(source, /execCmd\(body\.cmd/);
|
||||||
|
assert.doesNotMatch(source, /execCmd\(op\.cmd/);
|
||||||
|
assert.match(source, /必须指定已登记 action/);
|
||||||
|
assert.deepEqual(actions.listActions().map((item) => item.id), ["inspect-gatekeeper", "sync-status"]);
|
||||||
|
assert.equal(actions.getAction("not-registered"), null);
|
||||||
|
});
|
||||||
|
test("Gatekeeper and sync-agent ports are separated", () => {
|
||||||
|
assert.match(source, /process\.argv\[2\] \|\| '3911'/);
|
||||||
|
const syncConfig = fs.readFileSync(__dirname + "/../../../server-tools/fifth-domain-sync-agent/config.example.json", "utf8");
|
||||||
|
assert.match(syncConfig, /"listen_port": 3982/);
|
||||||
|
});
|
||||||
|
|||||||
@ -22,11 +22,12 @@
|
|||||||
═══════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
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'),
|
||||||
os = require('os'), tls = require('tls');
|
os = require('os'), tls = require('tls');
|
||||||
const { SessionManager } = require('./session-manager');
|
const { SessionManager } = require('./session-manager');
|
||||||
|
const { getAction, listActions } = require('./authorized-actions');
|
||||||
|
|
||||||
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] || '3911', 10);
|
||||||
const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper');
|
const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper');
|
||||||
const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
|
const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
|
||||||
const CODE_TTL = 300; // 验证码 5 分钟过期
|
const CODE_TTL = 300; // 验证码 5 分钟过期
|
||||||
@ -120,7 +121,7 @@ const SMTP = {
|
|||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// 运行时状态
|
// 运行时状态
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
const pendingOps = {}; // { challenge_id: { code, expires, op_type, cmd, caller, target_email, target_name, server } }
|
const pendingOps = {}; // { challenge_id: { code, expires, op_type, action, caller, target_email, target_name, server } }
|
||||||
const rateLimitMap = {}; // { ip: [timestamp, ...] }
|
const rateLimitMap = {}; // { ip: [timestamp, ...] }
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@ -221,17 +222,10 @@ function fmtBytes(b) {
|
|||||||
return (b / 1073741824).toFixed(1) + ' GB';
|
return (b / 1073741824).toFixed(1) + ' GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
function execCmd(cmd, timeout) {
|
function runAuthorizedAction(actionId, timeout) {
|
||||||
return new Promise((resolve) => {
|
const action = getAction(actionId);
|
||||||
const t = timeout || CMD_TIMEOUT;
|
if (!action) return Promise.resolve({ code: 126, stdout: '', stderr: 'action_not_allowed' });
|
||||||
const child = exec(cmd, { timeout: t, maxBuffer: MAX_OUTPUT, shell: '/bin/bash' }, (err, stdout, stderr) => {
|
return action.run(timeout);
|
||||||
resolve({
|
|
||||||
code: err ? (err.code || 1) : 0,
|
|
||||||
stdout: (stdout || '').slice(0, MAX_OUTPUT),
|
|
||||||
stderr: (stderr || '').slice(0, MAX_OUTPUT)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@ -541,12 +535,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
cleanExpiredOps();
|
cleanExpiredOps();
|
||||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||||
const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo'];
|
const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo'];
|
||||||
|
const action = getAction(body.action);
|
||||||
|
if (!action) { jr(res, 400, { error: '必须指定已登记 action', available: listActions() }); return; }
|
||||||
|
if (!scopes.includes(action.scope)) { jr(res, 400, { error: 'action 与 scopes 不匹配', required_scope: action.scope }); return; }
|
||||||
if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; }
|
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);
|
const wlResult = checkWhitelist(sourceServer, identity.pid);
|
||||||
if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; }
|
if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; }
|
||||||
const triad = wlResult.triad;
|
const triad = wlResult.triad;
|
||||||
const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex');
|
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 };
|
pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, action: body.action, 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);
|
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:'验证码已发送到固定登记邮箱' });
|
jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' });
|
||||||
return;
|
return;
|
||||||
@ -559,7 +556,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; }
|
if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; }
|
||||||
if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; }
|
if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; }
|
||||||
delete pendingOps[body.challenge_id];
|
delete pendingOps[body.challenge_id];
|
||||||
const created = sessions.create(identity, op.server, op.scopes);
|
const created = sessions.create(identity, op.server, op.scopes, [op.action]);
|
||||||
log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(','));
|
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:'工作会话已开启'});
|
jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'});
|
||||||
return;
|
return;
|
||||||
@ -569,10 +566,12 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
|
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
|
||||||
const opType = body.op_type || 'code-repo';
|
const opType = body.op_type || 'code-repo';
|
||||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||||
const verified = sessions.verify(sessionToken, identity, sourceServer, opType);
|
const action = getAction(body.action);
|
||||||
|
const verified = sessions.verify(sessionToken, identity, sourceServer, opType, body.action);
|
||||||
if (!verified.ok) { jr(res,403,{error:verified.reason}); return; }
|
if (!verified.ok) { jr(res,403,{error:verified.reason}); return; }
|
||||||
if (!body.cmd) { jr(res,400,{error:'缺少 cmd 参数'}); return; }
|
if (!action) { jr(res,400,{error:'必须指定已登记 action',available:listActions()}); return; }
|
||||||
const result = await execCmd(body.cmd, body.timeout);
|
if (action.scope !== opType) { jr(res,400,{error:'action 与 op_type 不匹配',required_scope:action.scope}); return; }
|
||||||
|
const result = await runAuthorizedAction(body.action, body.timeout);
|
||||||
log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code);
|
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});
|
jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr});
|
||||||
return;
|
return;
|
||||||
@ -591,7 +590,6 @@ const server = http.createServer(async (req, res) => {
|
|||||||
cleanExpiredOps();
|
cleanExpiredOps();
|
||||||
|
|
||||||
const opType = body.op_type || 'code-repo';
|
const opType = body.op_type || 'code-repo';
|
||||||
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;
|
||||||
if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; }
|
if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; }
|
||||||
@ -601,7 +599,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cmd) { jr(res, 400, { error: '缺少 cmd 参数' }); return; }
|
const action = getAction(body.action);
|
||||||
|
if (!action) { jr(res, 400, { error: '必须指定已登记 action', available: listActions() }); return; }
|
||||||
|
if (action.scope !== opType) { jr(res, 400, { error: 'action 与 op_type 不匹配', required_scope: action.scope }); return; }
|
||||||
|
|
||||||
let targetEmail, targetName;
|
let targetEmail, targetName;
|
||||||
|
|
||||||
@ -627,7 +627,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
code: code,
|
code: code,
|
||||||
expires: now + CODE_TTL,
|
expires: now + CODE_TTL,
|
||||||
op_type: opType,
|
op_type: opType,
|
||||||
cmd: cmd,
|
action: body.action,
|
||||||
caller: { pid: identity.pid, name: identity.name },
|
caller: { pid: identity.pid, name: identity.name },
|
||||||
target_email: targetEmail,
|
target_email: targetEmail,
|
||||||
target_name: targetName,
|
target_name: targetName,
|
||||||
@ -635,7 +635,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 发送验证码邮件
|
// 发送验证码邮件
|
||||||
const cmdPreview = cmd.length > 200 ? cmd.slice(0, 200) + '...' : cmd;
|
const cmdPreview = 'action=' + body.action;
|
||||||
const sent = await sendVerificationEmail(code, opType, identity.name, identity.pid, cmdPreview, targetEmail, targetName, SERVER_ID);
|
const sent = await sendVerificationEmail(code, opType, identity.name, identity.pid, cmdPreview, targetEmail, targetName, SERVER_ID);
|
||||||
|
|
||||||
log('INFO', 'auth_request', 'challenge=' + challengeId.slice(0, 16) + ' op=' + opType + ' email_sent=' + sent);
|
log('INFO', 'auth_request', 'challenge=' + challengeId.slice(0, 16) + ' op=' + opType + ' email_sent=' + sent);
|
||||||
@ -645,7 +645,6 @@ const server = http.createServer(async (req, res) => {
|
|||||||
challenge_id: challengeId,
|
challenge_id: challengeId,
|
||||||
email_sent: sent,
|
email_sent: sent,
|
||||||
email_mode: 'whitelist',
|
email_mode: 'whitelist',
|
||||||
target_email: targetEmail,
|
|
||||||
caller: { pid: identity.pid, name: identity.name },
|
caller: { pid: identity.pid, name: identity.name },
|
||||||
op_type: opType,
|
op_type: opType,
|
||||||
op_label: OP_TYPES[opType].label,
|
op_label: OP_TYPES[opType].label,
|
||||||
@ -692,12 +691,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
delete pendingOps[challengeId];
|
delete pendingOps[challengeId];
|
||||||
log('INFO', 'auth_confirm_ok', challengeId.slice(0, 16) + ' op=' + op.op_type + ' caller=' + op.caller.pid);
|
log('INFO', 'auth_confirm_ok', challengeId.slice(0, 16) + ' op=' + op.op_type + ' caller=' + op.caller.pid);
|
||||||
|
|
||||||
const result = await execCmd(op.cmd, body.timeout);
|
const result = await runAuthorizedAction(op.action, body.timeout);
|
||||||
|
|
||||||
jr(res, 200, {
|
jr(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
executed: true,
|
executed: true,
|
||||||
op_type: op.op_type,
|
op_type: op.op_type,
|
||||||
|
action: op.action,
|
||||||
caller: op.caller,
|
caller: op.caller,
|
||||||
exit_code: result.code,
|
exit_code: result.code,
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
@ -775,7 +775,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
// ═══ /exec — 旧接口兼容(自动转发到验证码流程) ═══
|
// ═══ /exec — 旧接口兼容(自动转发到验证码流程) ═══
|
||||||
if (route === '/exec') {
|
if (route === '/exec') {
|
||||||
if (!body.cmd) { jr(res, 400, { error: '缺少 cmd 参数' }); return; }
|
if (!body.action) { jr(res, 400, { error: '缺少 action 参数' }); return; }
|
||||||
|
|
||||||
// v3 安全策略: /exec 不再直接执行,返回提示要求走验证码流程
|
// v3 安全策略: /exec 不再直接执行,返回提示要求走验证码流程
|
||||||
log('WARN', 'exec_blocked_v3', 'caller=' + identity.pid + ' 尝试绕过验证码调用 /exec');
|
log('WARN', 'exec_blocked_v3', 'caller=' + identity.pid + ' 尝试绕过验证码调用 /exec');
|
||||||
@ -783,9 +783,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
jr(res, 403, {
|
jr(res, 403, {
|
||||||
error: 'Gatekeeper v3 安全策略: /exec 已停用 · 请使用分层授权流程',
|
error: 'Gatekeeper v3 安全策略: /exec 已停用 · 请使用分层授权流程',
|
||||||
required_flow: 'POST /auth/request → 邮箱验证码 → POST /auth/confirm',
|
required_flow: 'POST /auth/request → 邮箱验证码 → POST /auth/confirm',
|
||||||
help: '将原有的 /exec 请求改为: ① POST /auth/request (相同 cmd + op_type) → ② 查收邮箱验证码 → ③ POST /auth/confirm (challenge_id + code)',
|
help: '将原有的 /exec 请求改为: ① POST /auth/request (action + op_type) → ② 查收邮箱验证码 → ③ POST /auth/confirm (challenge_id + code)',
|
||||||
example: {
|
example: {
|
||||||
step1: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/request -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"cmd":"' + body.cmd.slice(0, 100) + '","op_type":"code-repo"}\'',
|
step1: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/request -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"action":"sync-status","op_type":"code-repo"}\'',
|
||||||
step2: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/confirm -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"challenge_id":"<从step1返回>","code":"<邮箱验证码>"}\''
|
step2: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/confirm -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"challenge_id":"<从step1返回>","code":"<邮箱验证码>"}\''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -831,7 +831,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
log('WARN', 'file_write_blocked_v3', '文件写入需要验证码授权');
|
log('WARN', 'file_write_blocked_v3', '文件写入需要验证码授权');
|
||||||
jr(res, 403, {
|
jr(res, 403, {
|
||||||
error: 'Gatekeeper v3: 文件写入需要验证码授权',
|
error: 'Gatekeeper v3: 文件写入需要验证码授权',
|
||||||
required_flow: 'POST /auth/request { cmd: "write file to ' + (body.path || '?') + '", op_type: "system-arch" } → 验证码 → /auth/confirm'
|
required_flow: 'POST /auth/request { action: "已登记动作", op_type: "system-arch" } → 验证码 → /auth/confirm'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,20 +11,23 @@ class SessionManager {
|
|||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
create(identity, server, scopes, now = Date.now() / 1000) {
|
create(identity, server, scopes, actions = [], now = Date.now() / 1000) {
|
||||||
|
if (typeof actions === "number") { now = actions; actions = []; }
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
const hash = this.hash(token);
|
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 });
|
this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], actions: [...new Set(actions)], created: now, last_used: now, expires: now + this.absoluteTtl });
|
||||||
this.persist();
|
this.persist();
|
||||||
return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
|
return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
|
||||||
}
|
}
|
||||||
verify(token, identity, server, scope, now = Date.now() / 1000) {
|
verify(token, identity, server, scope, action = "", now = Date.now() / 1000) {
|
||||||
|
if (typeof action === "number") { now = action; action = ""; }
|
||||||
const hash = this.hash(token || "");
|
const hash = this.hash(token || "");
|
||||||
const session = this.sessions.get(hash);
|
const session = this.sessions.get(hash);
|
||||||
if (!session) return { ok: false, reason: "session_not_found" };
|
if (!session) return { ok: false, reason: "session_not_found" };
|
||||||
if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(hash); this.persist(); return { ok: false, reason: "session_expired" }; }
|
if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(hash); this.persist(); return { ok: false, reason: "session_expired" }; }
|
||||||
if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" };
|
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" };
|
if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" };
|
||||||
|
if (!Array.isArray(session.actions) || !session.actions.includes(action)) return { ok: false, reason: "session_action_denied" };
|
||||||
session.last_used = now;
|
session.last_used = now;
|
||||||
this.persist();
|
this.persist();
|
||||||
return { ok: true, session };
|
return { ok: true, session };
|
||||||
|
|||||||
@ -9,22 +9,22 @@ const zhulan = { pid: "ICE-GL-ZL-001", name: "铸澜" };
|
|||||||
|
|
||||||
test("one approval creates a reusable bounded session", () => {
|
test("one approval creates a reusable bounded session", () => {
|
||||||
const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 });
|
const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 });
|
||||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
|
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 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", "sync-status", 11).ok, true);
|
||||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 12).ok, true);
|
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 12).ok, true);
|
||||||
});
|
});
|
||||||
test("session is bound to identity server and scope", () => {
|
test("session is bound to identity server and scope", () => {
|
||||||
const m = new SessionManager();
|
const m = new SessionManager();
|
||||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
|
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
|
||||||
assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", 11).ok, false);
|
assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", "sync-status", 11).ok, false);
|
||||||
assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", 11).ok, false);
|
assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", "sync-status", 11).ok, false);
|
||||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", 11).ok, false);
|
assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", "sync-status", 11).ok, false);
|
||||||
});
|
});
|
||||||
test("end signal revokes immediately", () => {
|
test("end signal revokes immediately", () => {
|
||||||
const m = new SessionManager();
|
const m = new SessionManager();
|
||||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
|
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
|
||||||
assert.equal(m.end(token, zhulan), true);
|
assert.equal(m.end(token, zhulan), true);
|
||||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false);
|
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 11).ok, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("session survives process reload without persisting plaintext token", () => {
|
test("session survives process reload without persisting plaintext token", () => {
|
||||||
@ -33,12 +33,12 @@ test("session survives process reload without persisting plaintext token", () =>
|
|||||||
try {
|
try {
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const first = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
|
const first = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
|
||||||
const created = first.create(zhulan, "BS-SG-001", ["code-repo"], now);
|
const created = first.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], now);
|
||||||
const onDisk = fs.readFileSync(stateFile, "utf8");
|
const onDisk = fs.readFileSync(stateFile, "utf8");
|
||||||
assert.equal(onDisk.includes(created.token), false);
|
assert.equal(onDisk.includes(created.token), false);
|
||||||
|
|
||||||
const reloaded = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
|
const reloaded = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
|
||||||
assert.equal(reloaded.verify(created.token, zhulan, "BS-SG-001", "code-repo", now + 1).ok, true);
|
assert.equal(reloaded.verify(created.token, zhulan, "BS-SG-001", "code-repo", "sync-status", now + 1).ok, true);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user