Changeset 0.29.1 (#196)
This commit is contained in:
448
server/sandbox/http_module.go
Normal file
448
server/sandbox/http_module.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Package sandbox — http_module.go
|
||||
//
|
||||
// v0.29.1 CS0: HTTP outbound module for Starlark extensions.
|
||||
// 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
|
||||
|
||||
// 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 SSRF-safe http.Client with IP validation on connect.
|
||||
func (ac *accessControl) client() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user