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 commit 0fbb8e5e88)
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user