package sandbox import ( "context" "fmt" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "go.starlark.net/starlark" ) // ─── isPublicIP ───────────────────────────── func TestIsPublicIP(t *testing.T) { tests := []struct { ip string public bool }{ // Public {"8.8.8.8", true}, {"1.1.1.1", true}, {"93.184.216.34", true}, {"2607:f8b0:4004:800::200e", true}, // google IPv6 // Loopback {"127.0.0.1", false}, {"127.0.0.2", false}, {"::1", false}, // Private RFC1918 {"10.0.0.1", false}, {"10.255.255.255", false}, {"172.16.0.1", false}, {"172.31.255.255", false}, {"192.168.0.1", false}, {"192.168.1.100", false}, // Link-local {"169.254.0.1", false}, {"169.254.169.254", false}, // AWS metadata {"fe80::1", false}, // Unspecified {"0.0.0.0", false}, {"::", false}, // Multicast {"224.0.0.1", false}, {"ff02::1", false}, // Private IPv6 {"fc00::1", false}, {"fd00::1", false}, } for _, tt := range tests { ip := net.ParseIP(tt.ip) if ip == nil { t.Fatalf("failed to parse test IP %q", tt.ip) } got := isPublicIP(ip) if got != tt.public { t.Errorf("isPublicIP(%s) = %v, want %v", tt.ip, got, tt.public) } } } // ─── ParseNetworkAccess ───────────────────── func TestParseNetworkAccess(t *testing.T) { t.Run("empty manifest", func(t *testing.T) { cfg := ParseNetworkAccess(map[string]any{}) if len(cfg.AllowDomains) != 0 || len(cfg.BlockDomains) != 0 { t.Errorf("expected empty config, got allow=%v block=%v", cfg.AllowDomains, cfg.BlockDomains) } }) t.Run("allow list", func(t *testing.T) { cfg := ParseNetworkAccess(map[string]any{ "network_access": map[string]any{ "allow": []any{"API.Example.COM", "hooks.slack.com"}, }, }) if len(cfg.AllowDomains) != 2 { t.Fatalf("expected 2 allow domains, got %d", len(cfg.AllowDomains)) } if cfg.AllowDomains[0] != "api.example.com" { t.Errorf("allow[0] = %q, want %q", cfg.AllowDomains[0], "api.example.com") } }) t.Run("block list", func(t *testing.T) { cfg := ParseNetworkAccess(map[string]any{ "network_access": map[string]any{ "block": []any{"evil.com"}, }, }) if len(cfg.BlockDomains) != 1 || cfg.BlockDomains[0] != "evil.com" { t.Errorf("expected block=[evil.com], got %v", cfg.BlockDomains) } }) t.Run("malformed network_access ignored", func(t *testing.T) { cfg := ParseNetworkAccess(map[string]any{ "network_access": "not a map", }) if len(cfg.AllowDomains) != 0 { t.Errorf("expected empty allow, got %v", cfg.AllowDomains) } }) t.Run("string slice variant", func(t *testing.T) { cfg := ParseNetworkAccess(map[string]any{ "network_access": map[string]any{ "allow": []string{"a.com", "b.com"}, }, }) if len(cfg.AllowDomains) != 2 { t.Errorf("expected 2 allow domains, got %d", len(cfg.AllowDomains)) } }) t.Run("empty strings filtered", func(t *testing.T) { cfg := ParseNetworkAccess(map[string]any{ "network_access": map[string]any{ "allow": []any{"", "ok.com", ""}, }, }) if len(cfg.AllowDomains) != 1 || cfg.AllowDomains[0] != "ok.com" { t.Errorf("expected [ok.com], got %v", cfg.AllowDomains) } }) } // ─── Access Control ───────────────────────── func TestAccessControl_Allowlist(t *testing.T) { ac := &accessControl{cfg: HTTPModuleConfig{ AllowDomains: []string{"api.example.com", "hooks.slack.com"}, }} if err := ac.checkDomain("api.example.com"); err != nil { t.Errorf("allowed domain rejected: %v", err) } if err := ac.checkDomain("hooks.slack.com"); err != nil { t.Errorf("allowed domain rejected: %v", err) } if err := ac.checkDomain("evil.com"); err == nil { t.Error("unlisted domain should be rejected in allowlist mode") } if err := ac.checkDomain("API.EXAMPLE.COM"); err != nil { t.Errorf("case-insensitive match should pass: %v", err) } } func TestAccessControl_Blocklist(t *testing.T) { ac := &accessControl{cfg: HTTPModuleConfig{ BlockDomains: []string{"evil.com", "malware.org"}, }} if err := ac.checkDomain("api.example.com"); err != nil { t.Errorf("unblocked domain rejected: %v", err) } if err := ac.checkDomain("evil.com"); err == nil { t.Error("blocked domain should be rejected") } if err := ac.checkDomain("malware.org"); err == nil { t.Error("blocked domain should be rejected") } } func TestAccessControl_NoPolicy(t *testing.T) { ac := &accessControl{cfg: HTTPModuleConfig{}} if err := ac.checkDomain("anything.com"); err != nil { t.Errorf("no-policy mode should allow all public domains: %v", err) } } // ─── dictToStringMap ──────────────────────── func TestDictToStringMap(t *testing.T) { t.Run("nil dict", func(t *testing.T) { m := dictToStringMap(nil) if m != nil { t.Errorf("nil input should return nil, got %v", m) } }) t.Run("valid dict", func(t *testing.T) { d := starlark.NewDict(2) _ = d.SetKey(starlark.String("Content-Type"), starlark.String("application/json")) _ = d.SetKey(starlark.String("X-Token"), starlark.String("abc")) m := dictToStringMap(d) if m["Content-Type"] != "application/json" { t.Errorf("Content-Type = %q", m["Content-Type"]) } if m["X-Token"] != "abc" { t.Errorf("X-Token = %q", m["X-Token"]) } }) t.Run("non-string values skipped", func(t *testing.T) { d := starlark.NewDict(2) _ = d.SetKey(starlark.String("ok"), starlark.String("yes")) _ = d.SetKey(starlark.String("bad"), starlark.MakeInt(42)) m := dictToStringMap(d) if len(m) != 1 { t.Errorf("expected 1 entry (non-string skipped), got %d", len(m)) } }) } // ─── buildResponseDict ────────────────────── func TestBuildResponseDict(t *testing.T) { h := http.Header{} h.Set("Content-Type", "application/json") h.Set("X-Request-Id", "abc-123") val, err := buildResponseDict(200, h, `{"ok":true}`) if err != nil { t.Fatalf("buildResponseDict: %v", err) } d, ok := val.(*starlark.Dict) if !ok { t.Fatalf("expected *starlark.Dict, got %T", val) } // Status statusVal, found, _ := d.Get(starlark.String("status")) if !found { t.Fatal("missing 'status' key") } statusInt, _ := starlark.AsInt32(statusVal) if statusInt != 200 { t.Errorf("status = %d, want 200", statusInt) } // Body bodyVal, found, _ := d.Get(starlark.String("body")) if !found { t.Fatal("missing 'body' key") } bodyStr, _ := starlark.AsString(bodyVal) if bodyStr != `{"ok":true}` { t.Errorf("body = %q", bodyStr) } // Headers hdrsVal, found, _ := d.Get(starlark.String("headers")) if !found { t.Fatal("missing 'headers' key") } hdrs, ok := hdrsVal.(*starlark.Dict) if !ok { t.Fatalf("headers is %T, want *Dict", hdrsVal) } ctVal, found, _ := hdrs.Get(starlark.String("content-type")) if !found { t.Fatal("missing content-type header") } ct, _ := starlark.AsString(ctVal) if ct != "application/json" { t.Errorf("content-type = %q", ct) } } // ─── Integration: HTTP GET against httptest ── func TestHTTPModule_GET(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } if r.Header.Get("X-Custom") != "hello" { t.Errorf("missing custom header") } w.Header().Set("X-Test", "passed") w.WriteHeader(200) fmt.Fprint(w, `{"result":"ok"}`) })) defer srv.Close() // Extract host for allowlist host := strings.TrimPrefix(srv.URL, "http://") hostOnly := strings.Split(host, ":")[0] _ = hostOnly // httptest uses 127.0.0.1 which is blocked by SSRF // For testing, we need to use the module without SSRF checks // since httptest binds to 127.0.0.1. Test the module builder // API by calling executeHTTPRequest with a permissive client. // SSRF checks are tested separately via isPublicIP tests. resp, err := executeHTTPRequest( context.Background(), &accessControl{cfg: HTTPModuleConfig{}}, "GET", srv.URL, "", map[string]string{"X-Custom": "hello"}, 5*time.Second, httpMaxResponseBody, ) // This will fail with SSRF block because httptest is 127.0.0.1. // That's the correct behavior — verify we get the SSRF error. if err == nil { // If it somehow succeeded (e.g., test env allows loopback), check response d := resp.(*starlark.Dict) statusVal, _, _ := d.Get(starlark.String("status")) statusInt, _ := starlark.AsInt32(statusVal) if statusInt != 200 { t.Errorf("status = %d, want 200", statusInt) } } else if !strings.Contains(err.Error(), "ssrf") && !strings.Contains(err.Error(), "private") { t.Errorf("expected SSRF error for loopback, got: %v", err) } } // TestHTTPModule_SSRFBlocked verifies that private IPs are blocked // even when disguised as valid URLs. func TestHTTPModule_SSRFBlocked(t *testing.T) { targets := []string{ "http://127.0.0.1/", "http://10.0.0.1/", "http://192.168.1.1/", "http://169.254.169.254/latest/meta-data/", // AWS metadata "http://[::1]/", } ac := &accessControl{cfg: HTTPModuleConfig{}} for _, target := range targets { _, err := executeHTTPRequest( context.Background(), ac, "GET", target, "", nil, 2*time.Second, httpMaxResponseBody, ) if err == nil { t.Errorf("expected SSRF block for %s", target) } } } // TestHTTPModule_SchemeValidation verifies only http/https are allowed. func TestHTTPModule_SchemeValidation(t *testing.T) { ac := &accessControl{cfg: HTTPModuleConfig{}} schemes := []string{"ftp://example.com", "file:///etc/passwd", "gopher://evil.com"} for _, u := range schemes { _, err := executeHTTPRequest( context.Background(), ac, "GET", u, "", nil, 2*time.Second, httpMaxResponseBody, ) if err == nil { t.Errorf("expected scheme rejection for %s", u) } if !strings.Contains(err.Error(), "only http/https") { t.Errorf("wrong error for %s: %v", u, err) } } } // TestHTTPModule_DomainBlock verifies blocklist enforcement at request time. func TestHTTPModule_DomainBlock(t *testing.T) { ac := &accessControl{cfg: HTTPModuleConfig{ BlockDomains: []string{"evil.com"}, }} _, err := executeHTTPRequest( context.Background(), ac, "GET", "https://evil.com/api", "", nil, 2*time.Second, httpMaxResponseBody, ) if err == nil { t.Error("expected block for evil.com") } if !strings.Contains(err.Error(), "blocked") { t.Errorf("wrong error: %v", err) } } // TestHTTPModule_AllowlistEnforcement verifies allowlist-only mode. func TestHTTPModule_AllowlistEnforcement(t *testing.T) { ac := &accessControl{cfg: HTTPModuleConfig{ AllowDomains: []string{"api.good.com"}, }} _, err := executeHTTPRequest( context.Background(), ac, "GET", "https://api.bad.com/data", "", nil, 2*time.Second, httpMaxResponseBody, ) if err == nil { t.Error("expected rejection for unlisted domain") } if !strings.Contains(err.Error(), "not in network_access allow list") { t.Errorf("wrong error: %v", err) } } // TestHTTPModule_Timeout verifies context-based timeout. func TestHTTPModule_Timeout(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(5 * time.Second) w.WriteHeader(200) })) defer srv.Close() ac := &accessControl{cfg: HTTPModuleConfig{}} _, err := executeHTTPRequest( context.Background(), ac, "GET", srv.URL, "", nil, 100*time.Millisecond, httpMaxResponseBody, ) // Will either fail with SSRF (127.0.0.1) or timeout — both are correct. if err == nil { t.Error("expected error (SSRF or timeout)") } } // TestHTTPModule_Starlark_Integration runs the http module through the // Starlark interpreter to verify the builtins are properly wired. // Starlark has no try/except — errors from builtins propagate to Go. func TestHTTPModule_Starlark_Integration(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{ AllowDomains: []string{"only-this.example.com"}, }) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} // Call http.get against a domain NOT in the allowlist. // The builtin should return an error that propagates to Go. script := ` def test(): return http.get(url="https://blocked.example.com/api") result = test() ` _, err := sb.Exec(context.Background(), "test.star", script, modules) if err == nil { t.Fatal("expected error for blocked domain, got nil") } if !strings.Contains(err.Error(), "not in network_access allow list") { t.Errorf("expected allowlist error, got: %v", err) } } // TestHTTPModule_Starlark_POST verifies post binding accepts body/headers kwargs. // Starlark has no try/except — we verify binding by checking the error is // a network/DNS error (correct kwargs accepted) not a Starlark type error. func TestHTTPModule_Starlark_POST(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{ AllowDomains: []string{"only-this.example.com"}, }) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} // Call http.post with body and headers kwargs against an allowed domain. // Will fail at DNS/connection (domain doesn't exist), but the binding // itself must parse kwargs without error. script := ` def test(): return http.post(url="https://only-this.example.com/api", body='{"k":"v"}', headers={"Content-Type": "application/json"}) result = test() ` _, err := sb.Exec(context.Background(), "test.star", script, modules) if err == nil { t.Fatal("expected network error (domain doesn't resolve), got nil") } // Should be a network error (DNS lookup failure), NOT a Starlark // argument-parsing error like "unexpected keyword argument". errStr := err.Error() if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") { t.Errorf("binding signature broken: %v", err) } } // TestHTTPModule_Starlark_Request verifies the generic request binding. func TestHTTPModule_Starlark_Request(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{ AllowDomains: []string{"nope.invalid"}, }) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} // Call http.request with method, url, body kwargs. // Will fail at DNS, but binding must accept kwargs correctly. script := ` def test(): return http.request(method="PATCH", url="https://nope.invalid/x", body="data") result = test() ` _, err := sb.Exec(context.Background(), "test.star", script, modules) if err == nil { t.Fatal("expected network error, got nil") } errStr := err.Error() if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") { t.Errorf("binding signature broken: %v", err) } } // ─── http.batch ───────────────────────────── func TestHTTPBatch_Starlark_BlockedDomains(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{ AllowDomains: []string{"allowed.example.com"}, }) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} // Both requests target a blocked domain — should return error dicts, not throw. script := ` results = http.batch([ {"method": "GET", "url": "https://blocked.example.com/a"}, {"method": "POST", "url": "https://blocked.example.com/b", "body": "{}", "headers": {"Content-Type": "application/json"}}, ]) count = len(results) ` result, err := sb.Exec(context.Background(), "test.star", script, modules) if err != nil { t.Fatalf("script error: %v", err) } countVal, ok := result.Globals["count"].(starlark.Int) if !ok { t.Fatalf("count is %T, want starlark.Int", result.Globals["count"]) } v, _ := countVal.Int64() if v != 2 { t.Errorf("got %d results, want 2", v) } // Verify each result is an error dict with status=0 results := result.Globals["results"].(*starlark.List) for i := 0; i < results.Len(); i++ { d, ok := results.Index(i).(*starlark.Dict) if !ok { t.Fatalf("result[%d] is %T, want *starlark.Dict", i, results.Index(i)) } statusVal, found, _ := d.Get(starlark.String("status")) if !found { t.Fatalf("result[%d] missing 'status' key", i) } statusInt, _ := starlark.AsInt32(statusVal) if statusInt != 0 { t.Errorf("result[%d] status = %d, want 0 (error)", i, statusInt) } bodyVal, _, _ := d.Get(starlark.String("body")) bodyStr, _ := starlark.AsString(bodyVal) if !strings.Contains(bodyStr, "error:") { t.Errorf("result[%d] body should contain 'error:', got %q", i, bodyStr) } } } func TestHTTPBatch_EmptyList(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{}) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} _, err := sb.Exec(context.Background(), "test.star", `results = http.batch([])`, modules) if err == nil { t.Fatal("expected error for empty list") } if !strings.Contains(err.Error(), "empty") { t.Errorf("unexpected error: %v", err) } } func TestHTTPBatch_TooMany(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{}) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} script := ` def run(): specs = [] for i in range(11): specs.append({"method": "GET", "url": "https://x.com/"}) return http.batch(specs) results = run() ` _, err := sb.Exec(context.Background(), "test.star", script, modules) if err == nil { t.Fatal("expected error for >10 requests") } if !strings.Contains(err.Error(), "max 10") { t.Errorf("unexpected error: %v", err) } } func TestHTTPBatch_MissingMethod(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{}) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} _, err := sb.Exec(context.Background(), "test.star", `results = http.batch([{"url": "https://x.com/"}])`, modules) if err == nil { t.Fatal("expected error for missing method") } if !strings.Contains(err.Error(), "method") { t.Errorf("unexpected error: %v", err) } } func TestHTTPBatch_MissingURL(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{}) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} _, err := sb.Exec(context.Background(), "test.star", `results = http.batch([{"method": "GET"}])`, modules) if err == nil { t.Fatal("expected error for missing url") } if !strings.Contains(err.Error(), "url") { t.Errorf("unexpected error: %v", err) } } func TestHTTPBatch_NotDict(t *testing.T) { mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{}) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} _, err := sb.Exec(context.Background(), "test.star", `results = http.batch(["not a dict"])`, modules) if err == nil { t.Fatal("expected error for non-dict element") } if !strings.Contains(err.Error(), "dict") { t.Errorf("unexpected error: %v", err) } } func TestHTTPBatch_MixedResults(t *testing.T) { // One allowed domain (will fail at DNS but not at allowlist), one blocked. mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{ AllowDomains: []string{"allowed.invalid"}, }) sb := New(DefaultConfig()) modules := map[string]starlark.Value{"http": mod} script := ` results = http.batch([ {"method": "GET", "url": "https://allowed.invalid/a"}, {"method": "GET", "url": "https://blocked.invalid/b"}, ]) ` result, err := sb.Exec(context.Background(), "test.star", script, modules) if err != nil { t.Fatalf("script error: %v", err) } results := result.Globals["results"].(*starlark.List) if results.Len() != 2 { t.Fatalf("got %d results, want 2", results.Len()) } // Both should be error dicts (first = DNS failure, second = allowlist block) // but neither should have caused the whole batch to fail. for i := 0; i < 2; i++ { d, ok := results.Index(i).(*starlark.Dict) if !ok { t.Fatalf("result[%d] is %T, want *starlark.Dict", i, results.Index(i)) } statusVal, _, _ := d.Get(starlark.String("status")) statusInt, _ := starlark.AsInt32(statusVal) if statusInt != 0 { t.Errorf("result[%d] expected error (status 0), got %d", i, statusInt) } } }