This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/sandbox/http_module.go
Jeffrey Smith c2d52f50c5
All checks were successful
CI/CD / test-frontend (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m53s
CI/CD / test-sqlite (push) Successful in 2m57s
CI/CD / build-and-deploy (push) Successful in 1m13s
CI/CD / detect-changes (push) Successful in 3s
Feat v0.7.11 query http ergonomics (#65)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 23:32:17 +00:00

574 lines
16 KiB
Go

// 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={})
// resps = http.batch([{"method": "GET", "url": "..."}, ...])
//
// # 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"
"sync"
"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))
}),
"batch": starlark.NewBuiltin("http.batch", httpBatch(ctx, ac, cfg.timeout(), cfg.maxBody())),
})
}
// ─── Batch Dispatch ─────────────────────────
// httpBatch implements http.batch(requests).
// requests is a list of dicts, each with keys: method, url, body?, headers?.
// Dispatches all requests concurrently (max 10). Individual failures return
// error response dicts rather than aborting the batch.
func httpBatch(
ctx context.Context,
ac *accessControl,
timeout time.Duration,
maxBody int64,
) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var requests *starlark.List
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"requests", &requests,
); err != nil {
return nil, err
}
n := requests.Len()
if n == 0 {
return nil, fmt.Errorf("http.batch: requests list is empty")
}
if n > 10 {
return nil, fmt.Errorf("http.batch: max 10 requests, got %d", n)
}
// Parse all request specs before dispatching (fail fast on bad input).
type reqSpec struct {
method, url, body string
headers map[string]string
}
specs := make([]reqSpec, n)
for i := 0; i < n; i++ {
d, ok := requests.Index(i).(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("http.batch[%d]: each request must be a dict, got %s", i, requests.Index(i).Type())
}
method, url, body, headers, err := parseBatchRequestDict(d)
if err != nil {
return nil, fmt.Errorf("http.batch[%d]: %w", i, err)
}
specs[i] = reqSpec{method: method, url: url, body: body, headers: headers}
}
// Dispatch concurrently.
results := make([]starlark.Value, n)
var wg sync.WaitGroup
for i, spec := range specs {
wg.Add(1)
go func(idx int, s reqSpec) {
defer wg.Done()
resp, err := executeHTTPRequest(ctx, ac, s.method, s.url, s.body, s.headers, timeout, maxBody)
if err != nil {
results[idx] = buildErrorResponseDict(err.Error())
} else {
results[idx] = resp
}
}(i, spec)
}
wg.Wait()
return starlark.NewList(results), nil
}
}
// parseBatchRequestDict extracts method, url, body, headers from a Starlark dict.
func parseBatchRequestDict(d *starlark.Dict) (method, rawURL, body string, headers map[string]string, err error) {
methodVal, found, _ := d.Get(starlark.String("method"))
if !found || methodVal == starlark.None {
return "", "", "", nil, fmt.Errorf("missing required key \"method\"")
}
method, ok := starlark.AsString(methodVal)
if !ok {
return "", "", "", nil, fmt.Errorf("\"method\" must be a string, got %s", methodVal.Type())
}
method = strings.ToUpper(method)
urlVal, found, _ := d.Get(starlark.String("url"))
if !found || urlVal == starlark.None {
return "", "", "", nil, fmt.Errorf("missing required key \"url\"")
}
rawURL, ok = starlark.AsString(urlVal)
if !ok {
return "", "", "", nil, fmt.Errorf("\"url\" must be a string, got %s", urlVal.Type())
}
if bodyVal, found, _ := d.Get(starlark.String("body")); found && bodyVal != starlark.None {
body, _ = starlark.AsString(bodyVal)
}
if hdrsVal, found, _ := d.Get(starlark.String("headers")); found && hdrsVal != starlark.None {
if hdrsDict, ok := hdrsVal.(*starlark.Dict); ok {
headers = dictToStringMap(hdrsDict)
}
}
return method, rawURL, body, headers, nil
}
// buildErrorResponseDict creates an error response dict for a failed batch request.
func buildErrorResponseDict(errMsg string) starlark.Value {
resp := starlark.NewDict(3)
_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(0))
_ = resp.SetKey(starlark.String("headers"), starlark.NewDict(0))
_ = resp.SetKey(starlark.String("body"), starlark.String("error: "+errMsg))
return resp
}
// ─── 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", "Armature-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
}