mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(plugins): 给 Lua 插件加受限出站 HTTP(__llmsproxy_http)
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 <noreply@modelrouter.dev> (cherry picked from commit0fbb8e5e88) (cherry picked from commite76e1623e4)
This commit is contained in:
@ -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
|
||||
|
||||
248
internal/lua/plugin_http.go
Normal file
248
internal/lua/plugin_http.go
Normal file
@ -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
|
||||
}
|
||||
111
internal/lua/plugin_http_args.go
Normal file
111
internal/lua/plugin_http_args.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
151
internal/lua/plugin_http_test.go
Normal file
151
internal/lua/plugin_http_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user