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