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) } }