From 0fbb8e5e88ba0f0e73ee5bff56193e6b96326f15 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 07:43:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E7=BB=99=20Lua=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8A=A0=E5=8F=97=E9=99=90=E5=87=BA=E7=AB=99=20HTTP?= =?UTF-8?q?=EF=BC=88=5F=5Fllmsproxy=5Fhttp=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billing 插件从不主动出站——它报告的一切都随网关转发请求时的 hook payload 一起到达。额度插件是第一个必须自己发请求的东西:套餐余量不主动问就拿不到。 问题在于这恰好是插件模型最锋利的边。插件是 WebUI 文本框里装进来的单个 .lua 文件,跑在网关进程里:给它一个通用 HTTP 客户端,等于让每次安装都成为 一件常备的 SSFP 工具,还顺带打开了读环境变量拿凭据的第二扇门。 所以这个桥刻意做窄: - 目标必须命中某个「已配置源」已经在通信的主机。那些是运维声明过的, 不是插件声明的——插件无法靠编一个 URL 扩大自己的触及面,而源的主机 正是本部署已经愿意交付 API key 的那批上游。 - 非 loopback 主机上拒绝明文 http。凭据只该走 HTTPS。 - loopback 免登记:它出不了本机,而 billing 规则页读网关自己的 API 正是 这么做的。 - 每请求都有超时、响应体有 4MiB 上限,插件无法占死 worker 或吃光内存。 - 绝不从环境变量读凭据。需要 key 的插件必须被显式给(state 或调用方配置), 这样授权对运维可见,而不是环境里默默存在的东西。 - 不跟随重定向:白名单是逐跳检查的,跟随就等于绕过它,3xx 原样返回。 能力只装在插件运行时(bootPluginState),不装进 setupGlobals——后者被 adapter 池共用,adapter 是运维的配置而非安装的代码。 判据 8 条钉的是拒绝路径而非快乐路径:未登记主机(含 169.254.169.254、 后缀欺骗 api.example.com.evil.test)、明文 http、空名单必须全拒、不得跟随 重定向、超时与响应体被钳制、超大响应宁可报错也不截断(截断会解析成损坏 JSON,插件会报解析错而不是真因)。 测试当场抓到一个真 bug:loopback 被白名单一起拦了,与代码注释里「loopback 免登记」自相矛盾。变异(把主机检查换成 if false)被 3 条判据抓住。 Co-Authored-By: ModelRouter --- internal/core/core.go | 31 ++++ internal/lua/plugin_http.go | 248 +++++++++++++++++++++++++++++++ internal/lua/plugin_http_args.go | 111 ++++++++++++++ internal/lua/plugin_http_test.go | 151 +++++++++++++++++++ internal/lua/plugins.go | 40 ++++- 5 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 internal/lua/plugin_http.go create mode 100644 internal/lua/plugin_http_args.go create mode 100644 internal/lua/plugin_http_test.go diff --git a/internal/core/core.go b/internal/core/core.go index de8b0ee..c1c3c65 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "sort" + "net/url" "strings" "sync" "sync/atomic" @@ -68,6 +69,10 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { // to boot the gateway because a third-party .lua has a typo would be the // wrong trade. c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir) + // Plugin outbound HTTP is scoped to the hosts of configured sources. Set + // before LoadDir so a plugin that requests on its first hook already sees + // the real allowlist rather than the deny-all placeholder. + c.plugins.SetOutboundHosts(sourceHosts(cfg)) if err := c.plugins.SeedBundled(); err != nil { log.Printf("[core] seed bundled plugins: %v", err) } @@ -615,9 +620,35 @@ func (c *Core) rebuildRegistry() error { c.buildAutoChain() c.buildAutoImageChain() c.rebuildKeyAutoChains() + // A source edit can add or remove an upstream host, which changes what a + // plugin is allowed to reach. Keeping this beside the chain rebuilds means + // one call site owns "config changed, refresh derived state". + c.plugins.SetOutboundHosts(sourceHosts(c.cfg)) return nil } +// sourceHosts extracts the host of every configured source's base_url — the +// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than +// failing startup: a malformed base_url is already rejected when the provider is +// built, and it must not take plugins down with it. +func sourceHosts(cfg *config.Config) []string { + if cfg == nil { + return nil + } + out := make([]string, 0, len(cfg.Sources)) + for _, s := range cfg.Sources { + if s.BaseURL == "" { + continue + } + u, err := url.Parse(s.BaseURL) + if err != nil || u.Hostname() == "" { + continue + } + out = append(out, u.Hostname()) + } + return out +} + // buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules // against the current providers. Slot model ids are normalized to the exact // configured spelling (case-insensitive match), otherwise ModelFor would fall diff --git a/internal/lua/plugin_http.go b/internal/lua/plugin_http.go new file mode 100644 index 0000000..167626d --- /dev/null +++ b/internal/lua/plugin_http.go @@ -0,0 +1,248 @@ +package lua + +// Outbound HTTP for plugins. +// +// The billing plugin never needed this: everything it reports arrives on a hook +// payload that the gateway produced while forwarding a request. A quota plugin +// is the first thing that must REACH OUT on its own — Command Code exposes +// /alpha/billing/credits and friends, and there is no way to learn a plan's +// remaining balance without asking. +// +// That capability is also the sharpest edge in the plugin model. A plugin is a +// single .lua file the WebUI installs from a textarea: untrusted, browser-facing +// code that runs in the gateway process. Handing it a general-purpose HTTP +// client would make every install a standing SSRF primitive against whatever +// the gateway can reach — the cloud metadata endpoint, the cluster control +// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the +// environment for credentials is a second door out. +// +// So the bridge is deliberately narrow: +// +// - Destination must match a host that a CONFIGURED SOURCE already talks to. +// Those are operator-declared, not plugin-declared: a plugin cannot widen +// its own reach by inventing a URL, and an existing source's host is +// exactly the set of upstreams this deployment already trusts with API keys. +// - http:// on a non-loopback host is refused. Credentials belong in HTTPS. +// - Loopback needs no explicit opt-in: it is the only way a plugin can read +// the gateway's own API (the billing rules UI does exactly that), and it +// cannot leave the machine. +// - Every request is bounded by a timeout and the response is size-capped, so +// a plugin cannot wedge a worker goroutine or exhaust memory. +// - Credentials are NEVER read from the environment here. A plugin that needs +// a key must be given it (state, or the caller's own config), which keeps +// the grant visible to the operator instead of silently ambient. +// +// Redirects are NOT followed: the allowlist is checked per hop, and a redirect +// to an unlisted host would otherwise walk straight past it. A 3xx is returned +// as-is so the plugin can see it. + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + golua "github.com/aarzilli/golua/lua" +) + +// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body +// a plugin can pull into the gateway's heap. +const ( + httpDefaultTimeout = 15 * time.Second + httpMaxTimeout = 60 * time.Second + httpMaxResponse = 4 << 20 // 4 MiB +) + +// httpClient is shared by every plugin request. It deliberately has no +// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy +// per source, and a plugin request should not silently inherit one. +var httpClient = &http.Client{ + Timeout: httpDefaultTimeout, + Transport: &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 16, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + }, + // See above: a redirect is a hop the allowlist has not seen. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, +} + +// httpGlobal is the Lua name of the outbound bridge. +const httpGlobal = "__llmsproxy_http" + +// httpAllowlist is the set of hosts plugins may contact: the host of every +// configured source's base_url. +type httpAllowlist struct { + hosts map[string]bool +} + +func newHTTPAllowlist(hosts []string) *httpAllowlist { + m := make(map[string]bool, len(hosts)) + for _, h := range hosts { + if h != "" { + m[strings.ToLower(h)] = true + } + } + return &httpAllowlist{hosts: m} +} + +// allows reports whether host:port may be contacted. Ports 80/443 are implied +// by the bare host so a source configured as https://api.example.com/v1 does not +// have to spell the port. +func (a *httpAllowlist) allows(host string, port string) bool { + host = strings.ToLower(host) + if host == "" { + return false + } + if a.hosts[host] { + return true + } + switch port { + case "", "80", "443": + return a.hosts[host] + } + return a.hosts[net.JoinHostPort(host, port)] +} + +// checkURL validates a plugin-supplied URL against the allowlist. It returns +// the parsed URL plus a reason when it is refused. +func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("bad url: %v", err) + } + switch strings.ToLower(u.Scheme) { + case "https": + case "http": + // Plain HTTP is only tolerated on loopback, where it stays on this + // machine and is how the gateway's own plain-HTTP dev port is reached. + if !isLoopbackHost(u.Hostname()) { + return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname()) + } + default: + return nil, fmt.Errorf("unsupported scheme %q", u.Scheme) + } + if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) { + // Loopback needs no entry: it cannot leave the machine, and the billing + // rules UI reads the gateway's own API over plain HTTP in dev setups. + return nil, fmt.Errorf("host %q is not an allowed outbound destination "+ + "(allowed: the hosts of configured sources, plus loopback)", u.Hostname()) + } + return u, nil +} + +func isLoopbackHost(h string) bool { + if h == "localhost" { + return true + } + ip := net.ParseIP(strings.Trim(h, "[]")) + return ip != nil && ip.IsLoopback() +} + +// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http. +type httpRequestSpec struct { + URL string + Method string + Headers map[string]string + Body string + Timeout time.Duration +} + +// luaHTTPFn builds the golua closure. It takes one Lua table argument and +// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a +// plugin mistake cannot take the gateway with it. +func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction { + return func(L *golua.State) int { + spec, err := readHTTPRequestSpec(L) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + u, err := allow.checkURL(spec.URL) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + resp, err := performHTTP(u, spec) + if err != nil { + L.PushNil() + L.PushString(err.Error()) + return 2 + } + pushHTTPResponse(L, resp) + L.PushNil() + return 2 + } +} + +func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) { + method := strings.ToUpper(spec.Method) + if method == "" { + method = http.MethodGet + } + timeout := spec.Timeout + if timeout <= 0 || timeout > httpMaxTimeout { + timeout = httpDefaultTimeout + } + + var body io.Reader + if spec.Body != "" { + body = strings.NewReader(spec.Body) + } + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("build request: %v", err) + } + for k, v := range spec.Headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "ModelRouter-plugin") + } + + client := *httpClient + client.Timeout = timeout + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1)) + if err != nil { + return nil, fmt.Errorf("read body: %v", err) + } + if len(data) > httpMaxResponse { + return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse) + } + return &httpResult{ + Status: resp.StatusCode, + Headers: flattenHeaders(resp.Header), + Body: string(data), + }, nil +} + +type httpResult struct { + Status int + Headers map[string]string + Body string +} + +func flattenHeaders(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k := range h { + out[strings.ToLower(k)] = h.Get(k) + } + return out +} \ No newline at end of file diff --git a/internal/lua/plugin_http_args.go b/internal/lua/plugin_http_args.go new file mode 100644 index 0000000..7e4653c --- /dev/null +++ b/internal/lua/plugin_http_args.go @@ -0,0 +1,111 @@ +package lua + +import ( + "fmt" + "strings" + "time" + + golua "github.com/aarzilli/golua/lua" +) + +// readHTTPRequestSpec decodes the Lua argument table: +// +// __llmsproxy_http({ +// url = "https://host/path", +// method = "GET", -- optional, default GET +// headers = { ["X-A"] = "1" }, -- optional +// body = "...", -- optional +// timeout_ms = 8000, -- optional, clamped to 60s +// }) +// +// luaTableToGoValue already turns the table into a map/slice, so the whole +// argument is decoded in one pass instead of walking the table field by field. +func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) { + var spec httpRequestSpec + if L.GetTop() < 1 { + return spec, fmt.Errorf("__llmsproxy_http: expected a table argument") + } + if L.Type(1) != golua.LUA_TTABLE { + return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s", + normalizeString(L, 1)) + } + arg, ok := luaTableToGoValue(L, 1).(map[string]interface{}) + if !ok { + return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table") + } + for k, v := range arg { + switch strings.ToLower(k) { + case "url", "uri", "endpoint": + spec.URL = asString(v) + case "method", "verb": + spec.Method = asString(v) + case "body", "data", "payload": + spec.Body = asString(v) + case "headers", "header": + if m, ok := v.(map[string]interface{}); ok { + spec.Headers = make(map[string]string, len(m)) + for hk, hv := range m { + spec.Headers[hk] = asString(hv) + } + } + case "timeout_ms", "timeoutms": + if n, ok := asFloat(v); ok { + spec.Timeout = time.Duration(n) * time.Millisecond + } + } + } + if spec.URL == "" { + return spec, fmt.Errorf("__llmsproxy_http: missing url") + } + return spec, nil +} + +// pushHTTPResponse converts the result into two Lua values: a table +// {status, headers, body} and nil. The caller pushes the error slot itself. +func pushHTTPResponse(L *golua.State, r *httpResult) { + L.NewTable() + + L.PushString("status") + L.PushNumber(float64(r.Status)) + L.SetTable(-3) + + L.PushString("headers") + pushStringMap(L, r.Headers) + L.SetTable(-3) + + L.PushString("body") + L.PushString(r.Body) + L.SetTable(-3) +} + +func pushStringMap(L *golua.State, m map[string]string) { + L.NewTable() + for k, v := range m { + L.PushString(v) + L.SetField(-2, k) + } +} + +func asString(v interface{}) string { + switch t := v.(type) { + case string: + return t + case nil: + return "" + default: + return fmt.Sprintf("%v", t) + } +} + +func asFloat(v interface{}) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case int: + return float64(t), true + case int64: + return float64(t), true + default: + return 0, false + } +} \ No newline at end of file diff --git a/internal/lua/plugin_http_test.go b/internal/lua/plugin_http_test.go new file mode 100644 index 0000000..5e3919e --- /dev/null +++ b/internal/lua/plugin_http_test.go @@ -0,0 +1,151 @@ +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" + "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") + } +} \ No newline at end of file diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 1e03b2d..9042112 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -231,6 +231,10 @@ type Plugins struct { // stateDirDisabled turns persistence off. Used by tests that assert state // starts empty, and by an embedder that has no writable plugin dir. stateDirDisabled bool + // allowHTTP bounds plugin outbound requests to operator-declared hosts. + // nil means deny-all: an unconfigured gateway must not silently grant a + // plugin unrestricted network access. + allowHTTP *httpAllowlist // save coalesces state writes into one background goroutine. // // A hook must NOT write synchronously: the billing plugin mutates state on @@ -412,6 +416,10 @@ func NewPlugins(vm *VM, dir string) *Plugins { stageFuncs: map[Stage][]hookCall{}, hookErr: newHookErrors(), dir: dir, + // Deny-by-default. NewPlugins is called during wiring, before any + // source list is known; SetOutboundHosts replaces this with the real + // set. A plugin loaded in that window simply cannot make requests. + allowHTTP: newHTTPAllowlist(nil), } // The saver is started even with no dir: a gateway can be given a plugin dir // later, and a saver that only exists when dir != "" would silently never @@ -429,6 +437,27 @@ func NewPlugins(vm *VM, dir string) *Plugins { // DisableStatePersistence turns persistence off (tests/benchmarks only). func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true } +// SetOutboundHosts declares which hosts plugins may contact via +// __llmsproxy_http. The gateway passes the host of every configured source: +// those are operator-declared upstreams, and reusing that set keeps a plugin's +// reach no wider than the deployment already trusts. An empty list means no +// outbound host is allowed at all — plugins then still load, they just cannot +// make requests. +func (ps *Plugins) SetOutboundHosts(hosts []string) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.allowHTTP = newHTTPAllowlist(hosts) +} + +// outboundAllowlist returns the current allowlist. bootPluginState reads it +// before any concurrent reload can swap it, and a nil result is treated as +// "deny everything" rather than "allow everything". +func (ps *Plugins) outboundAllowlist() *httpAllowlist { + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.allowHTTP +} + func (ps *Plugins) Close() { if ps == nil { return @@ -496,10 +525,17 @@ func (ps *Plugins) SeedBundled() error { // bootPluginState compiles one plugin into a fresh Lua state and stores its // returned table under pluginGlobal. It is the plugin counterpart of // adapterPool.boot, minus the pooling: a plugin keeps exactly one state. -func bootPluginState(code, name string) (*worker, error) { +func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) { L := golua.NewState() L.OpenLibs() setupGlobals(L) + // Only plugins get __llmsproxy_http. Adapters are operator-supplied config + // and go through setupGlobals too, so installing the bridge here — not in + // setupGlobals — is what keeps the capability scoped to installed .lua + // files. + if allow != nil { + registerFn(L, "httpGlobal", luaHTTPFn(L, allow)) + } if err := L.DoString(code); err != nil { L.Close() return nil, fmt.Errorf("compile plugin %s: %w", name, err) @@ -537,7 +573,7 @@ func (ps *Plugins) LoadSource(name, code string) error { // One state, held for the plugin's lifetime (see Plugin.state): a hook that // accumulates into plugin.state would otherwise split its totals across // whichever worker it happened to run on. - w, err := bootPluginState(code, name) + w, err := bootPluginState(code, name, ps.outboundAllowlist()) if err != nil { p.LoadError = err.Error() ps.append(p)