mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。
## 为什么不是 VNC 为主路径(实测参考插件后的结论)
grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:
puppeteer / playwright / chromium / headless / vnc / selenium → 0 处
它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。
不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。
VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。
## 登录会话抽象(plugin_login.go)
四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。
- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)
## 关键阻碍与修法:登录主机不在出站白名单里
**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。
修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。
## 设置页
插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。
后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。
开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。
测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
295 lines
9.2 KiB
Go
295 lines
9.2 KiB
Go
package lua
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件验证 R4-e:插件驱动的登录会话 + 登录 host 授权。
|
||
//
|
||
// 用户决策是「多流程抽象」(link-poll / callback / paste / VNC 逃生舱)。
|
||
// 这里断言的是**框架**:会话生命周期、跨插件隔离、nonce 校验、host 授权
|
||
// 与出站白名单的联动。各平台的具体流程属于插件业务。
|
||
|
||
func loginPlugins(t *testing.T) (*VM, *Plugins, string) {
|
||
return runtimePlugins(t)
|
||
}
|
||
|
||
const loginProbe = `
|
||
local plugin = { name = "loginprobe", version = "1.0" }
|
||
plugin.exports = { "begin_link", "poll", "start_callback", "cancel_one", "resolve_ok", "read_other" }
|
||
plugin.auth_hosts = { "auth.example.com", "Accounts.Google.com:443" }
|
||
|
||
function plugin.begin_link()
|
||
local s, err = __llmsproxy_login.begin({
|
||
platform = "example", flow = "link-poll",
|
||
url = "https://auth.example.com/device", user_code = "WDJB-MJHT",
|
||
})
|
||
return { s = s, err = err }
|
||
end
|
||
|
||
function plugin.poll(args)
|
||
local s, err = __llmsproxy_login.get(args.id)
|
||
return { s = s, err = err }
|
||
end
|
||
|
||
function plugin.start_callback()
|
||
local s, err = __llmsproxy_login.begin({ platform = "example", flow = "callback" })
|
||
return { s = s, err = err }
|
||
end
|
||
|
||
function plugin.cancel_one(args)
|
||
local ok = __llmsproxy_login.cancel(args.id)
|
||
return { ok = ok }
|
||
end
|
||
|
||
function plugin.resolve_ok(args)
|
||
local ok, err = __llmsproxy_login.resolve(args.id, "completed", "alice", "")
|
||
return { ok = ok, err = err }
|
||
end
|
||
|
||
function plugin.read_other(args)
|
||
local s = __llmsproxy_login.get(args.id)
|
||
return { got = s }
|
||
end
|
||
return plugin
|
||
`
|
||
|
||
// TestLoginSessionLifecycle:创建 → 轮询 → 完成。
|
||
func TestLoginSessionLifecycle(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, err := ps.Call("loginprobe", "begin_link", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m := out.(map[string]interface{})
|
||
if m["err"] != nil {
|
||
t.Fatalf("begin 失败: %v", m["err"])
|
||
}
|
||
if m["s"] == nil {
|
||
t.Fatalf("begin 返回 nil session, err=%v", m["err"])
|
||
}
|
||
s := m["s"].(map[string]interface{})
|
||
if s["state"] != "pending" {
|
||
t.Errorf("新会话应为 pending, 实得 %v", s["state"])
|
||
}
|
||
if s["url"] != "https://auth.example.com/device" {
|
||
t.Errorf("URL 未保留: %v", s["url"])
|
||
}
|
||
if s["user_code"] != "WDJB-MJHT" {
|
||
t.Errorf("设备码未保留: %v", s["user_code"])
|
||
}
|
||
id := s["id"].(string)
|
||
|
||
// 轮询到 pending
|
||
out, _ = ps.Call("loginprobe", "poll", map[string]interface{}{"id": id})
|
||
got := out.(map[string]interface{})["s"].(map[string]interface{})
|
||
if got["state"] != "pending" {
|
||
t.Errorf("轮询应为 pending, 实得 %v", got["state"])
|
||
}
|
||
// 后续读取不得带 nonce(只在 begin 时给)
|
||
if _, hasNonce := got["nonce"]; hasNonce {
|
||
t.Error("后续读取不应暴露 nonce(CSRF 令牌)")
|
||
}
|
||
|
||
// 完成
|
||
out, _ = ps.Call("loginprobe", "resolve_ok", map[string]interface{}{"id": id})
|
||
if out.(map[string]interface{})["ok"] != true {
|
||
t.Fatalf("resolve 失败: %#v", out)
|
||
}
|
||
out, _ = ps.Call("loginprobe", "poll", map[string]interface{}{"id": id})
|
||
fin := out.(map[string]interface{})["s"].(map[string]interface{})
|
||
if fin["state"] != "completed" {
|
||
t.Errorf("应为 completed, 实得 %v", fin["state"])
|
||
}
|
||
if fin["account"] != "alice" {
|
||
t.Errorf("账号标签应保留: %v", fin["account"])
|
||
}
|
||
}
|
||
|
||
// TestLoginSessionIsolatedPerPlugin:插件读不到别人的会话(且与"不存在"不可区分)。
|
||
func TestLoginSessionIsolatedPerPlugin(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
other := `
|
||
local plugin = { name = "other", version = "1.0" }
|
||
plugin.exports = { "read_other" }
|
||
function plugin.read_other(args)
|
||
local s = __llmsproxy_login.get(args.id)
|
||
return { got = s }
|
||
end
|
||
return plugin
|
||
`
|
||
if err := ps.LoadSource("other", other); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, _ := ps.Call("loginprobe", "begin_link", map[string]interface{}{})
|
||
id := out.(map[string]interface{})["s"].(map[string]interface{})["id"].(string)
|
||
|
||
out, err := ps.Call("other", "read_other", map[string]interface{}{"id": id})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if out.(map[string]interface{})["got"] != nil {
|
||
t.Fatal("★ 插件 B 读到了插件 A 的登录会话")
|
||
}
|
||
}
|
||
|
||
// TestLoginCallbackNonce:callback 流程的 nonce 必须能匹配,且只在 begin 时给出。
|
||
func TestLoginCallbackNonce(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, _ := ps.Call("loginprobe", "start_callback", map[string]interface{}{})
|
||
s := out.(map[string]interface{})["s"].(map[string]interface{})
|
||
nonce, _ := s["nonce"].(string)
|
||
if nonce == "" {
|
||
t.Fatal("callback 流程必须在 begin 时给出 nonce")
|
||
}
|
||
// 内核侧能按 nonce 找到会话(回调路由用它)
|
||
sess, ok := ps.MatchCallbackNonce(nonce)
|
||
if !ok {
|
||
t.Fatal("nonce 应能匹配到会话")
|
||
}
|
||
if sess.ID != s["id"].(string) {
|
||
t.Errorf("匹配到错误的会话: %s vs %s", sess.ID, s["id"])
|
||
}
|
||
// 空 nonce 不得匹配任何东西(否则回调可绕过校验)
|
||
if _, ok := ps.MatchCallbackNonce(""); ok {
|
||
t.Error("空 nonce 不应匹配")
|
||
}
|
||
}
|
||
|
||
// TestLoginHostsGateOutbound 是本次发现的**关键阻碍**的回归测试:
|
||
// 登录主机不是源,默认被出站白名单拒绝;只有声明 + 批准后才放行。
|
||
func TestLoginHostsGateOutbound(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 声明被读取(normalizeHost 会去端口、小写)
|
||
declared := ps.PluginAuthHosts("loginprobe")
|
||
if len(declared) != 2 {
|
||
t.Fatalf("应读到 2 个声明 host, 实得 %#v", declared)
|
||
}
|
||
want := map[string]bool{"auth.example.com": true, "accounts.google.com": true}
|
||
for _, h := range declared {
|
||
if !want[h] {
|
||
t.Errorf("声明 host 未规范化: %q", h)
|
||
}
|
||
}
|
||
|
||
// 未批准 → 出站被拒
|
||
if _, err := ps.vm.Transform("noop", "x", "{}"); err == nil {
|
||
_ = err // 不关心,仅为占位
|
||
}
|
||
allow := ps.outboundAllowlist()
|
||
if allow.allows("auth.example.com", "") {
|
||
t.Fatal("★ 未批准的登录 host 不应在放行名单里")
|
||
}
|
||
|
||
// 批准后放行
|
||
if err := ps.GrantAuthHosts("loginprobe", []string{"auth.example.com"}); err != nil {
|
||
t.Fatalf("批准失败: %v", err)
|
||
}
|
||
allow = ps.outboundAllowlist()
|
||
if !allow.allows("auth.example.com", "") {
|
||
t.Fatal("批准后应放行")
|
||
}
|
||
if allow.allows("accounts.google.com", "") {
|
||
t.Error("未逐个批准的 host 不应被连带放行")
|
||
}
|
||
}
|
||
|
||
// TestGrantUndeclaredAuthHostRejected:只能批准插件声明过的 host。
|
||
func TestGrantUndeclaredAuthHostRejected(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.GrantAuthHosts("loginprobe", []string{"evil.example.net"}); err == nil {
|
||
t.Fatal("★ 批准未声明的 host 应被拒(否则声明只是装饰)")
|
||
}
|
||
}
|
||
|
||
// TestLoginExpiry:超时未完成的会话变 expired(读取时判定,无需后台定时器)。
|
||
func TestLoginExpiry(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, _ := ps.Call("loginprobe", "begin_link", map[string]interface{}{})
|
||
id := out.(map[string]interface{})["s"].(map[string]interface{})["id"].(string)
|
||
|
||
// 人为把过期时间提前
|
||
ps.logins().mu.Lock()
|
||
ps.logins().byID[id].ExpiresAt = time.Now().Add(-time.Second)
|
||
ps.logins().mu.Unlock()
|
||
|
||
out, _ = ps.Call("loginprobe", "poll", map[string]interface{}{"id": id})
|
||
s := out.(map[string]interface{})["s"].(map[string]interface{})
|
||
if s["state"] != "expired" {
|
||
t.Fatalf("过期会话应为 expired, 实得 %v", s["state"])
|
||
}
|
||
}
|
||
|
||
// TestVNCRequiresOptIn:VNC 逃生舱默认关闭。
|
||
func TestVNCRequiresOptIn(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
code := `
|
||
local plugin = { name = "vncprobe", version = "1.0" }
|
||
plugin.exports = { "begin_vnc" }
|
||
function plugin.begin_vnc()
|
||
local s, err = __llmsproxy_login.begin({ platform = "x", flow = "vnc" })
|
||
return { s = s, err = err }
|
||
end
|
||
return plugin
|
||
`
|
||
if err := ps.LoadSource("vncprobe", code); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, err := ps.Call("vncprobe", "begin_vnc", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m := out.(map[string]interface{})
|
||
if m["err"] == nil || m["s"] != nil {
|
||
t.Fatal("★ VNC 流程默认应被拒绝")
|
||
}
|
||
t.Logf("默认拒绝信息: %v", m["err"])
|
||
|
||
// 运维显式开启后可用
|
||
ps.SetVNCEnabled(true)
|
||
out, _ = ps.Call("vncprobe", "begin_vnc", map[string]interface{}{})
|
||
if out.(map[string]interface{})["s"] == nil {
|
||
t.Fatal("开启后 VNC 应可用")
|
||
}
|
||
}
|
||
|
||
// TestUnknownLoginFlowRejected:拼错的流程名必须被拒。
|
||
func TestUnknownLoginFlowRejected(t *testing.T) {
|
||
_, ps, _ := loginPlugins(t)
|
||
code := `
|
||
local plugin = { name = "badflow", version = "1.0" }
|
||
plugin.exports = { "go" }
|
||
function plugin.go()
|
||
local s, err = __llmsproxy_login.begin({ platform = "x", flow = "carrier-pigeon" })
|
||
return { s = s, err = err }
|
||
end
|
||
return plugin
|
||
`
|
||
if err := ps.LoadSource("badflow", code); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, _ := ps.Call("badflow", "go", map[string]interface{}{})
|
||
if out.(map[string]interface{})["err"] == nil {
|
||
t.Fatal("未知流程名应被拒")
|
||
}
|
||
}
|