mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。
UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。
覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。
过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。
角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。
不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。
有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。
插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。
roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。
**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。
写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。
readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。
readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。
去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。
生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
This commit is contained in:
193
internal/lua/plugin_visibility_test.go
Normal file
193
internal/lua/plugin_visibility_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package lua
|
||||
|
||||
// Plugin visibility: a plugin page is not automatically public data.
|
||||
//
|
||||
// The billing page renders GLOBAL cost across every source and the quota page
|
||||
// renders upstream balances. Both were served to any authenticated key because
|
||||
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
|
||||
// key pulled the 18KB billing state and read the source names and the $8.86
|
||||
// total straight out of it.
|
||||
//
|
||||
// These tests pin the DENIALS. A visibility feature whose tests only assert that
|
||||
// an admin still sees everything passes while the leak is wide open.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAudienceAllows(t *testing.T) {
|
||||
cases := []struct {
|
||||
want, role string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"", "user", true, "unset means everyone"},
|
||||
{"all", "user", true, "explicit all"},
|
||||
{"admin", "admin", true, "exact match"},
|
||||
{"admin", "user", false, "admin-only excludes user"},
|
||||
{"user", "user", true, "user-only admits user"},
|
||||
{"user", "admin", false, "user-only excludes admin"},
|
||||
{"admin,user", "user", true, "list admits a listed role"},
|
||||
{" admin , user ", "user", true, "whitespace is not significant"},
|
||||
{"ADMIN", "user", false, "case is normalised"},
|
||||
{"*", "user", false, `"*" must NOT be a synonym for all`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := audienceAllows(c.want, c.role); got != c.ok {
|
||||
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
|
||||
c.want, c.role, got, c.ok, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
|
||||
// One admin-only plugin, one open plugin: the filter must drop exactly one
|
||||
// and leave the other intact, or a plugin author has no way to ship a mixed
|
||||
// set.
|
||||
const adminOnly = `
|
||||
local P = {}
|
||||
P.name = "adminonly"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
|
||||
return P
|
||||
`
|
||||
const open = `
|
||||
local P = {}
|
||||
P.name = "openpage"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
||||
return P
|
||||
`
|
||||
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
|
||||
if err := ps.LoadSource(name, src); err != nil {
|
||||
t.Fatalf("load %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
admin := ps.UIForRole("admin")
|
||||
if len(admin.Pages) != 2 {
|
||||
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
|
||||
}
|
||||
|
||||
user := ps.UIForRole("user")
|
||||
for _, pg := range user.Pages {
|
||||
if pg.PageID == "secret" {
|
||||
t.Fatal("a user key can see the admin-only page — this is the leak")
|
||||
}
|
||||
}
|
||||
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
|
||||
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
|
||||
}
|
||||
}
|
||||
|
||||
// The operator must be able to impose visibility the plugin did not choose —
|
||||
// that is the whole point of the override, since a bundled .lua cannot be edited
|
||||
// without being overwritten on the next upgrade.
|
||||
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const open = `
|
||||
local P = {}
|
||||
P.name = "openpage"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("openpage", open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ps.UIForRole("user").Pages) != 1 {
|
||||
t.Fatal("precondition: the page should start visible to user")
|
||||
}
|
||||
|
||||
ps.SetPluginVisibility("openpage", "admin")
|
||||
if got := ps.UIForRole("user").Pages; len(got) != 0 {
|
||||
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
|
||||
}
|
||||
if ps.VisibilityOverride("openpage") != "admin" {
|
||||
t.Error("the override should be reportable so /api/plugins can show it")
|
||||
}
|
||||
|
||||
// Clearing restores the plugin's own declaration.
|
||||
ps.SetPluginVisibility("openpage", "")
|
||||
if got := ps.UIForRole("user").Pages; len(got) != 1 {
|
||||
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A page may narrow visibility below the plugin default, so one plugin can ship
|
||||
// a public summary beside an admin-only detail page.
|
||||
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const mixed = `
|
||||
local P = {}
|
||||
P.name = "mixed"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = {
|
||||
pages = {
|
||||
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
|
||||
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
|
||||
},
|
||||
}
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("mixed", mixed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := ps.UIForRole("user")
|
||||
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
|
||||
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
|
||||
}
|
||||
if len(ps.UIForRole("admin").Pages) != 2 {
|
||||
t.Error("admin should see both pages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStateVisibleTo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const adminOnly = `
|
||||
local P = {}
|
||||
P.name = "adminonly"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.state = { secret = "x" }
|
||||
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The data has to follow the page: a hidden UI with an open state endpoint is
|
||||
// the same leak with one more step.
|
||||
if ps.StateVisibleTo("adminonly", "user") {
|
||||
t.Error("user must not read an admin-only plugin's state")
|
||||
}
|
||||
if !ps.StateVisibleTo("adminonly", "admin") {
|
||||
t.Error("admin must still read it")
|
||||
}
|
||||
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
|
||||
// not start answering 403.
|
||||
if !ps.StateVisibleTo("nosuchplugin", "user") {
|
||||
t.Error("an unknown plugin name must not become forbidden; that would change " +
|
||||
"behaviour for plugins that simply are not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func pageIDs(pages []*UIPage) []string {
|
||||
out := make([]string, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
if p != nil {
|
||||
out = append(out, p.PageID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user