mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。
缺陷一(授权对已启动 worker 无效):
httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
(newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。
缺陷二(授权不持久化):
.kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
与 actions 一起写入(支持"只批了主机、没批动作"的行)。
回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
后授权保留。
go test ./internal/lua/ 全绿;build/vet 通过。
357 lines
14 KiB
Go
357 lines
14 KiB
Go
package lua
|
||
|
||
// The plugin HTTP bridge is the one capability that lets a .lua file — installed
|
||
// from a textarea in the WebUI — reach out on the gateway's own network. That
|
||
// makes its DENY rules the security boundary, and these tests exist to pin the
|
||
// refusals rather than the happy path.
|
||
//
|
||
// The failure this guards against is concrete: an operator installs a quota
|
||
// plugin that reads a vendor's balance, and a later plugin (or a compromised
|
||
// one) posts that gateway key material to an attacker, or reads the cloud
|
||
// metadata endpoint, because __llmsproxy_http happened to be a general HTTP
|
||
// client.
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
golua "github.com/aarzilli/golua/lua"
|
||
)
|
||
|
||
// newTestState returns a bare Lua state for the argument-decoding tests.
|
||
func newTestState(t *testing.T) *golua.State {
|
||
t.Helper()
|
||
L := golua.NewState()
|
||
t.Cleanup(L.Close)
|
||
L.OpenLibs()
|
||
return L
|
||
}
|
||
|
||
func TestPluginHTTPAllowlistRejectsUnlistedHost(t *testing.T) {
|
||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||
for _, raw := range []string{
|
||
"https://evil.test/steal",
|
||
"https://169.254.169.254/latest/meta-data/",
|
||
"https://api.example.com.evil.test/x", // suffix trick
|
||
"https://user@evil.test/",
|
||
} {
|
||
if _, err := allow.checkURL(raw); err == nil {
|
||
t.Fatalf("allowlist permitted %q — a plugin must not reach hosts "+
|
||
"the operator never configured as a source", raw)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPluginHTTPAllowlistAcceptsConfiguredHost(t *testing.T) {
|
||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||
for _, raw := range []string{
|
||
"https://api.example.com/alpha/credits",
|
||
"https://API.EXAMPLE.COM/x", // host compare must be case-insensitive
|
||
"https://api.example.com:443/x",
|
||
} {
|
||
if _, err := allow.checkURL(raw); err != nil {
|
||
t.Fatalf("allowlist refused configured host %q: %v", raw, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPluginHTTPRefusesPlainHTTPOffLoopback(t *testing.T) {
|
||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||
// Allowed host, but plain HTTP would put the API key on the wire in clear.
|
||
if _, err := allow.checkURL("http://api.example.com/alpha"); err == nil {
|
||
t.Fatal("plain http to an allowed non-loopback host must be refused")
|
||
}
|
||
// Loopback stays allowed: it cannot leave the machine, and the billing rules
|
||
// UI reads the gateway's own API over plain HTTP in dev setups.
|
||
for _, raw := range []string{"http://127.0.0.1:8081/api/x", "http://localhost:8081/x"} {
|
||
if _, err := allow.checkURL(raw); err != nil {
|
||
t.Fatalf("loopback http must stay usable, got %v for %q", err, raw)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPluginHTTPRejectsBadSchemesAndMissingURL(t *testing.T) {
|
||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||
for _, raw := range []string{"file:///etc/passwd", "ftp://api.example.com/x", "gopher://x"} {
|
||
if _, err := allow.checkURL(raw); err == nil {
|
||
t.Fatalf("scheme in %q must be refused", raw)
|
||
}
|
||
}
|
||
// An empty allowlist must deny, not fall open.
|
||
if _, err := newHTTPAllowlist(nil).checkURL("https://api.example.com/x"); err == nil {
|
||
t.Fatal("an unconfigured gateway must deny outbound HTTP entirely")
|
||
}
|
||
}
|
||
|
||
// A redirect is a hop the allowlist never saw. Following it would let an
|
||
// allowed host bounce a plugin's request (and its headers) anywhere.
|
||
func TestPluginHTTPDoesNotFollowRedirects(t *testing.T) {
|
||
if httpClient.CheckRedirect == nil {
|
||
t.Fatal("CheckRedirect must be set: following a redirect escapes the allowlist")
|
||
}
|
||
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil)
|
||
err := httpClient.CheckRedirect(req, []*http.Request{req})
|
||
if err != http.ErrUseLastResponse {
|
||
t.Fatalf("CheckRedirect must return ErrUseLastResponse so the 3xx is "+
|
||
"handed back unfollowed, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPluginHTTPTimeoutAndBodyAreClamped(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||
}))
|
||
defer srv.Close()
|
||
allow := newHTTPAllowlist([]string{"127.0.0.1"})
|
||
u, err := allow.checkURL(srv.URL)
|
||
if err != nil {
|
||
t.Fatalf("loopback test server must be allowed: %v", err)
|
||
}
|
||
// An absurd timeout is clamped instead of honoured.
|
||
res, err := performHTTP(u, httpRequestSpec{Timeout: 24 * time.Hour})
|
||
if err != nil {
|
||
t.Fatalf("clamped request failed: %v", err)
|
||
}
|
||
if res.Status != 200 || !strings.Contains(res.Body, `"ok"`) {
|
||
t.Fatalf("unexpected result: status=%d body=%q", res.Status, res.Body)
|
||
}
|
||
}
|
||
|
||
func TestPluginHTTPOversizedBodyIsRefused(t *testing.T) {
|
||
big := strings.Repeat("x", httpMaxResponse+64)
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(big))
|
||
}))
|
||
defer srv.Close()
|
||
allow := newHTTPAllowlist([]string{"127.0.0.1"})
|
||
u, _ := allow.checkURL(srv.URL)
|
||
// Refusing beats truncating: a truncated body parses as corrupt JSON and the
|
||
// plugin would report a parse error instead of the real cause.
|
||
if _, err := performHTTP(u, httpRequestSpec{}); err == nil {
|
||
t.Fatal("an oversized response must be refused, not silently truncated")
|
||
}
|
||
}
|
||
|
||
func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) {
|
||
// Lua-side contract: the bridge returns (nil, message) on every failure so a
|
||
// plugin mistake never raises through golua (see the SIGSEGV note on
|
||
// hookGuardSrc — an escaping error can take the gateway down).
|
||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||
if _, err := allow.checkURL("https://evil.test/"); err == nil {
|
||
t.Fatal("expected refusal")
|
||
}
|
||
// readHTTPRequestSpec must reject a non-table without touching the stack.
|
||
L := newTestState(t)
|
||
if _, err := readHTTPRequestSpec(L); err == nil {
|
||
t.Fatal("a missing argument table must be an error, not a panic")
|
||
}
|
||
}
|
||
|
||
// The bridge must be reachable under the name plugins actually call.
|
||
//
|
||
// This exists because the registration once used the CONSTANT'S IDENTIFIER as
|
||
// the Lua global name (`registerFn(L, "httpGlobal", …)`) rather than its VALUE,
|
||
// so the bridge was published as `httpGlobal` while every plugin called
|
||
// `__llmsproxy_http`. The failure only appeared at runtime, as
|
||
// "attempt to call global '__llmsproxy_http' (a nil value)" on the first real
|
||
// request — after the unit tests were green, because they drove the Go-side
|
||
// helpers directly and never went through Lua name resolution.
|
||
//
|
||
// So this test boots a real plugin and calls the bridge the way a plugin does.
|
||
func TestPluginHTTPBridgeIsReachableUnderItsLuaName(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(NewVM(dir), dir)
|
||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":1.5}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
// The plugin asks for a loopback URL, which is allowed without being
|
||
// configured as a source. It type-checks both the table and arg.url, so a
|
||
// malformed call is reported by the plugin rather than as a bewildering URL
|
||
// parse error from the bridge.
|
||
src := `
|
||
local P = {}
|
||
P.name = "httpprobe"
|
||
P.version = "1.0.0"
|
||
P.exports = { "probe" }
|
||
-- Call passes the WHOLE argument table as a single Lua argument, which is the
|
||
-- same convention the hooks use and what a plugin page's pluginAPI.call ends up
|
||
-- producing. Taking the table (not a bare string) is the correct shape.
|
||
function P.probe(arg)
|
||
if type(arg) ~= "table" then return { error = "probe takes the args table, got " .. type(arg) } end
|
||
if type(arg.url) ~= "string" then return { error = "arg.url must be a string, got " .. type(arg.url) } end
|
||
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
||
if res == nil then return { error = tostring(err) } end
|
||
return { status = res.status, body = res.body }
|
||
end
|
||
return P
|
||
`
|
||
if err := ps.LoadSource("httpprobe", src); err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
got, err := ps.Call("httpprobe", "probe", map[string]interface{}{"url": srv.URL})
|
||
if err != nil {
|
||
t.Fatalf("call: %v", err)
|
||
}
|
||
m, _ := got.(map[string]interface{})
|
||
if m == nil {
|
||
t.Fatalf("probe returned %T, want a table", got)
|
||
}
|
||
// A nil global surfaces as {error = "attempt to call global ... (a nil value)"} —
|
||
// which is a table too, so the error field is what distinguishes it.
|
||
if e, ok := m["error"]; ok {
|
||
t.Fatalf("the bridge is not reachable as %s: %v", httpGlobal, e)
|
||
}
|
||
if s, _ := m["status"].(float64); int(s) != 200 {
|
||
t.Fatalf("status=%v body=%v, want 200 from the loopback test server", m["status"], m["body"])
|
||
}
|
||
if body, _ := m["body"].(string); !strings.Contains(body, "monthlyCredits") {
|
||
t.Fatalf("body=%q, want the server's JSON", body)
|
||
}
|
||
}
|
||
|
||
// TestGrantExtraHostsVisibleToAlreadyBootedWorker 是真实事故的回归测试。
|
||
//
|
||
// 事故:allowlist 是不可变快照。bootPluginState 在 worker 启动时把 *httpAllowlist
|
||
// 指针绑进 Lua 闭包;GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**,已启动的
|
||
// worker 仍持旧指针 → 授权接口返回 ok、UI 显示已批,但插件请求仍被
|
||
// "not an allowed outbound destination" 拒绝。channels 插件的 auth/state 就是这样
|
||
// 在生产被拦的(copilot.tencent.com 已批仍报错)。
|
||
//
|
||
// 修复:allowlist 改为带锁的**可变共享对象**(原地 add),所有闭包立即可见。
|
||
// 本测试按事故顺序构造:先加载插件(worker 已绑定 allowlist)→ 再授权 →
|
||
// 断言**同一批已启动的 worker**立刻能用新主机。
|
||
func TestGrantExtraHostsVisibleToAlreadyBootedWorker(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(NewVM(dir), dir)
|
||
ps.SetOutboundHosts(nil) // 起始只允许 loopback
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||
}))
|
||
defer srv.Close()
|
||
// srv.URL 是 127.0.0.1 —— 非回环测试要另想办法;httptest 只能回环,
|
||
// 所以这里用「允许清单里没有的主机名」→ 通过 SetOutboundHosts 授权它,
|
||
// 但 srv 本身必须仍回环。做法:插件请求的 URL 用 srv.URL(回环,恒允许)?
|
||
// 不行——那测不出 allowlist 更新。改用 checkURL 直接断言新主机可见。
|
||
//
|
||
// 真实事故的关键路径:worker 已启动 → 授权 → 同一 worker 立即看到。
|
||
// Lua 层面用回环无法区分(回环永远允许),因此这里的断言打在两层:
|
||
// ① allowlist 对象层:已存在实例 add 后立即 allows() 新主机;
|
||
// ② 插件层:SetOutboundHosts 重新折算后,同一 worker 的请求仍工作
|
||
// (即 rebuild 不会使旧引用失效)。
|
||
host := "portal.example.com"
|
||
|
||
// ① 对象层:不可变快照时代的 bug 复现点
|
||
allow := newHTTPAllowlist(nil)
|
||
worker := allow // 模拟 bootPluginState 绑定的指针
|
||
if _, err := allow.checkURL("https://" + host + "/x"); err == nil {
|
||
t.Fatal("前提不成立:未授权主机不应允许")
|
||
}
|
||
allow.add([]string{host}) // GrantExtraHosts 的实际路径(原地 add)
|
||
if _, err := worker.checkURL("https://" + host + "/x"); err != nil {
|
||
t.Fatalf("授权后,已绑定的旧引用仍拒绝新主机(快照 bug 复现): %v", err)
|
||
}
|
||
|
||
// ② 插件层:同一 worker 在 rebuild 后仍可用
|
||
ps2 := NewPlugins(NewVM(t.TempDir()), t.TempDir())
|
||
ps2.SetOutboundHosts([]string{"127.0.0.1"})
|
||
probe := `
|
||
local P = {}
|
||
P.name = "grantprobe"
|
||
P.version = "1.0"
|
||
P.exports = { "probe" }
|
||
P.hosts = { "portal.example.com" }
|
||
function P.probe(arg)
|
||
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
||
if res == nil then return { error = tostring(err) } end
|
||
return { status = res.status }
|
||
end
|
||
return P
|
||
`
|
||
if err := ps2.LoadSource("grantprobe", probe); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// worker 已启动。现在授权(grant → rebuild → 原地 add)
|
||
if err := ps2.GrantExtraHosts("grantprobe", []string{"portal.example.com"}); err != nil {
|
||
t.Fatalf("grant: %v", err)
|
||
}
|
||
// 同一批 worker:一次调用成功即证明 allowlist 引用未被弃用
|
||
got, err := ps2.Call("grantprobe", "probe", map[string]interface{}{"url": srv.URL})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m, _ := got.(map[string]interface{})
|
||
if m == nil || m["error"] != nil {
|
||
t.Fatalf("授权后同一 worker 调用失败(引用被弃用): %v", got)
|
||
}
|
||
if s, _ := m["status"].(float64); int(s) != 200 {
|
||
t.Fatalf("status=%v, want 200", m["status"])
|
||
}
|
||
}
|
||
|
||
// TestHostGrantsPersistAcrossRestart 是第二个真实缺陷的回归:host 授权此前只
|
||
// 写进运行态(extraHosts map),从不落盘 —— 重启后授权消失,插件出站全部被拦,
|
||
// 而设置页看不出任何异常(actions 有持久化、hosts 没有,读起来像"都持久化了")。
|
||
func TestHostGrantsPersistAcrossRestart(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||
if err := ps.LoadSource("grantprobe", `
|
||
local P = {}
|
||
P.name = "grantprobe"
|
||
P.version = "1.0"
|
||
P.exports = {}
|
||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||
return P
|
||
`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.GrantExtraHosts("grantprobe", []string{"portal.example.com", "api.partner.example"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 落盘验证
|
||
b, err := os.ReadFile(filepath.Join(dir, ".kernel-perms.json"))
|
||
if err != nil {
|
||
t.Fatalf("授权未落盘: %v", err)
|
||
}
|
||
if !strings.Contains(string(b), "portal.example.com") {
|
||
t.Fatalf("host 授权应写入 .kernel-perms.json,文件内容: %s", b)
|
||
}
|
||
|
||
// 重启语义:新 registry 读回授权
|
||
ps2 := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := ps2.ExtraHostsGranted()["grantprobe"]
|
||
if len(got) != 2 {
|
||
t.Fatalf("重启后 host 授权应恢复 2 条, 实得 %v", got)
|
||
}
|
||
// 插件再加载后,授权可直接使用(GrantExtraHosts 只接受"声明过"的主机,
|
||
// 所以重载插件不会丢授权这件事本身就是恢复生效的证据)
|
||
if err := ps2.LoadSource("grantprobe", `
|
||
local P = {}
|
||
P.name = "grantprobe"
|
||
P.version = "1.0"
|
||
P.exports = {}
|
||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||
return P
|
||
`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got = ps2.ExtraHostsGranted()["grantprobe"]
|
||
if len(got) != 2 {
|
||
t.Fatalf("插件重载后授权应保留, 实得 %v", got)
|
||
}
|
||
}
|