// Package sandbox — http_module.go // // Requires permission: api.http // // Starlark API: // // resp = http.get(url, headers={}) // resp = http.post(url, body="", headers={}) // resp = http.put(url, body="", headers={}) // resp = http.delete(url, headers={}) // resp = http.request(method, url, body="", headers={}) // // # resp is a dict: {"status": 200, "headers": {...}, "body": "..."} // // Network access rules from manifest: // // { // "network_access": { // "allow": ["api.example.com"], // allowlist mode: ONLY these domains // "block": ["evil.com"] // blocklist: additive to built-in blocks // } // } // // Security: // - Private/loopback IPs blocked (SSRF protection) // - DNS resolution checked before connect // - Response body capped at 1 MB // - 10 s default timeout (inherits context) // - Max 10 redirects, each checked for SSRF package sandbox import ( "context" "fmt" "io" "net" "net/http" "net/url" "strings" "syscall" "time" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" ) // ─── Configuration ────────────────────────── const ( httpDefaultTimeout = 10 * time.Second httpMaxResponseBody = 1 << 20 // 1 MB httpMaxRedirects = 10 ) // HTTPModuleConfig controls the HTTP module's network access policy. type HTTPModuleConfig struct { // AllowDomains: if non-empty, only these exact domains are reachable. // When set, BlockDomains is ignored (allowlist takes precedence). AllowDomains []string // BlockDomains: domains to block in addition to built-in SSRF rules. // Ignored when AllowDomains is non-empty. BlockDomains []string // AllowPrivateIPs disables the SSRF check that blocks connections to // private/loopback/link-local addresses. Use for self-hosted deployments // where extensions need to reach internal services. // Default: false (private IPs blocked). AllowPrivateIPs bool // Timeout overrides the default 10 s request timeout. // Zero means use default. Timeout time.Duration // MaxBodyBytes overrides the default 1 MB response body limit. // Zero means use default. MaxBodyBytes int64 } func (c HTTPModuleConfig) timeout() time.Duration { if c.Timeout > 0 { return c.Timeout } return httpDefaultTimeout } func (c HTTPModuleConfig) maxBody() int64 { if c.MaxBodyBytes > 0 { return c.MaxBodyBytes } return httpMaxResponseBody } // ParseNetworkAccess extracts HTTPModuleConfig from a package manifest. // Missing or malformed fields are silently ignored (no access = default policy). func ParseNetworkAccess(manifest map[string]any) HTTPModuleConfig { cfg := HTTPModuleConfig{} na, ok := manifest["network_access"] if !ok { return cfg } naMap, ok := na.(map[string]any) if !ok { return cfg } if allow, ok := naMap["allow"]; ok { cfg.AllowDomains = toStringSlice(allow) } if block, ok := naMap["block"]; ok { cfg.BlockDomains = toStringSlice(block) } return cfg } func toStringSlice(v any) []string { switch arr := v.(type) { case []any: out := make([]string, 0, len(arr)) for _, item := range arr { if s, ok := item.(string); ok && s != "" { out = append(out, strings.ToLower(s)) } } return out case []string: out := make([]string, 0, len(arr)) for _, s := range arr { if s != "" { out = append(out, strings.ToLower(s)) } } return out default: return nil } } // ─── Module Builder ───────────────────────── // BuildHTTPModule creates the "http" Starlark module with the given // network access policy. Each call to a builtin creates a fresh // request bound to the parent context. func BuildHTTPModule(ctx context.Context, cfg HTTPModuleConfig) *starlarkstruct.Module { ac := &accessControl{cfg: cfg} doRequest := func(method, rawURL, body string, headers map[string]string) (starlark.Value, error) { return executeHTTPRequest(ctx, ac, method, rawURL, body, headers, cfg.timeout(), cfg.maxBody()) } return MakeModule("http", starlark.StringDict{ "request": starlark.NewBuiltin("http.request", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var method, rawURL string var body string var hdrs *starlark.Dict if err := starlark.UnpackArgs(b.Name(), args, kwargs, "method", &method, "url", &rawURL, "body?", &body, "headers?", &hdrs, ); err != nil { return nil, err } return doRequest(strings.ToUpper(method), rawURL, body, dictToStringMap(hdrs)) }), "get": starlark.NewBuiltin("http.get", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var rawURL string var hdrs *starlark.Dict if err := starlark.UnpackArgs(b.Name(), args, kwargs, "url", &rawURL, "headers?", &hdrs, ); err != nil { return nil, err } return doRequest("GET", rawURL, "", dictToStringMap(hdrs)) }), "post": starlark.NewBuiltin("http.post", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var rawURL, body string var hdrs *starlark.Dict if err := starlark.UnpackArgs(b.Name(), args, kwargs, "url", &rawURL, "body?", &body, "headers?", &hdrs, ); err != nil { return nil, err } return doRequest("POST", rawURL, body, dictToStringMap(hdrs)) }), "put": starlark.NewBuiltin("http.put", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var rawURL, body string var hdrs *starlark.Dict if err := starlark.UnpackArgs(b.Name(), args, kwargs, "url", &rawURL, "body?", &body, "headers?", &hdrs, ); err != nil { return nil, err } return doRequest("PUT", rawURL, body, dictToStringMap(hdrs)) }), "delete": starlark.NewBuiltin("http.delete", func( thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, ) (starlark.Value, error) { var rawURL string var hdrs *starlark.Dict if err := starlark.UnpackArgs(b.Name(), args, kwargs, "url", &rawURL, "headers?", &hdrs, ); err != nil { return nil, err } return doRequest("DELETE", rawURL, "", dictToStringMap(hdrs)) }), }) } // ─── Request Execution ────────────────────── func executeHTTPRequest( ctx context.Context, ac *accessControl, method, rawURL, body string, headers map[string]string, timeout time.Duration, maxBody int64, ) (starlark.Value, error) { // Validate URL parsed, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("http.%s: invalid URL: %w", strings.ToLower(method), err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("http.%s: only http/https schemes allowed, got %q", strings.ToLower(method), parsed.Scheme) } // Check domain against access control (pre-DNS) host := parsed.Hostname() if err := ac.checkDomain(host); err != nil { return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err) } // Build request reqCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var bodyReader io.Reader if body != "" { bodyReader = strings.NewReader(body) } req, err := http.NewRequestWithContext(reqCtx, method, rawURL, bodyReader) if err != nil { return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err) } // Set headers (user-provided override defaults) req.Header.Set("User-Agent", "ChatSwitchboard-Extension/1.0") for k, v := range headers { req.Header.Set(k, v) } // SSRF-safe client with custom dialer client := ac.client() resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("http.%s: request failed: %w", strings.ToLower(method), err) } defer resp.Body.Close() // Read body with size limit limited := io.LimitReader(resp.Body, maxBody+1) respBody, err := io.ReadAll(limited) if err != nil { return nil, fmt.Errorf("http.%s: reading response: %w", strings.ToLower(method), err) } if int64(len(respBody)) > maxBody { respBody = respBody[:maxBody] // Truncated — still return what we have, don't error } return buildResponseDict(resp.StatusCode, resp.Header, string(respBody)) } // buildResponseDict converts an HTTP response into a Starlark dict: // // {"status": int, "headers": dict, "body": string} func buildResponseDict(status int, h http.Header, body string) (starlark.Value, error) { // Build headers dict (lowercase keys, first value only for simplicity) hdrDict := starlark.NewDict(len(h)) for k, vals := range h { if len(vals) > 0 { _ = hdrDict.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0])) } } resp := starlark.NewDict(3) _ = resp.SetKey(starlark.String("status"), starlark.MakeInt(status)) _ = resp.SetKey(starlark.String("headers"), hdrDict) _ = resp.SetKey(starlark.String("body"), starlark.String(body)) return resp, nil } // ─── Access Control ───────────────────────── type accessControl struct { cfg HTTPModuleConfig } // checkDomain validates a hostname against the access policy. func (ac *accessControl) checkDomain(host string) error { host = strings.ToLower(host) // Allowlist mode: only listed domains are permitted if len(ac.cfg.AllowDomains) > 0 { for _, d := range ac.cfg.AllowDomains { if host == d { return nil } } return fmt.Errorf("domain %q not in network_access allow list", host) } // Blocklist mode: check against explicit blocks for _, d := range ac.cfg.BlockDomains { if host == d { return fmt.Errorf("domain %q is blocked by network_access policy", host) } } return nil } // client builds an HTTP client with optional SSRF IP validation on connect. func (ac *accessControl) client() *http.Client { dialer := &net.Dialer{ Timeout: 5 * time.Second, } if !ac.cfg.AllowPrivateIPs { dialer.Control = ssrfControl } transport := &http.Transport{ DialContext: dialer.DialContext, TLSHandshakeTimeout: 5 * time.Second, MaxIdleConns: 1, IdleConnTimeout: 30 * time.Second, DisableKeepAlives: true, // extension requests are one-shot } return &http.Client{ Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= httpMaxRedirects { return fmt.Errorf("too many redirects (max %d)", httpMaxRedirects) } // Re-check domain on redirect to prevent SSRF via redirect host := req.URL.Hostname() if err := ac.checkDomain(host); err != nil { return err } return nil }, } } // ssrfControl is a net.Dialer.Control function that rejects connections // to private, loopback, and link-local addresses. Called after DNS // resolution, before the TCP handshake — prevents DNS rebinding attacks. func ssrfControl(network, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return fmt.Errorf("ssrf: invalid address %q: %w", address, err) } ip := net.ParseIP(host) if ip == nil { return fmt.Errorf("ssrf: could not parse IP %q", host) } if !isPublicIP(ip) { return fmt.Errorf("ssrf: connection to private/reserved address %s blocked", ip) } return nil } // isPublicIP returns true if the IP is a routable public address. // Blocks all RFC1918, loopback, link-local, multicast, and unspecified. func isPublicIP(ip net.IP) bool { // Unspecified (0.0.0.0, ::) if ip.IsUnspecified() { return false } // Loopback (127.0.0.0/8, ::1) if ip.IsLoopback() { return false } // Link-local unicast (169.254.0.0/16, fe80::/10) if ip.IsLinkLocalUnicast() { return false } // Link-local multicast if ip.IsLinkLocalMulticast() { return false } // Multicast if ip.IsMulticast() { return false } // Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7) if ip.IsPrivate() { return false } return true } // ─── Starlark Dict Helpers ────────────────── // dictToStringMap converts a Starlark dict to a Go map[string]string. // Non-string keys or values are silently skipped. func dictToStringMap(d *starlark.Dict) map[string]string { if d == nil { return nil } m := make(map[string]string, d.Len()) for _, item := range d.Items() { k, ok1 := starlark.AsString(item[0]) v, ok2 := starlark.AsString(item[1]) if ok1 && ok2 { m[k] = v } } return m }