- Rename Go module switchboard-core → armature (155+ files) - Rename Docker image → gobha/armature - Rename K8s resources, secrets, deployments - Rename Prometheus metrics switchboard_* → armature_* - Rename env vars SWITCHBOARD_ADMIN_* → ARMATURE_ADMIN_* - Rename DB names switchboard_core* → armature* - Update all frontend branding, notification templates, docs - Update CI scripts, e2e tests, Keycloak realm, nginx conf - Rename scripts/switchboard-ca.sh → scripts/armature-ca.sh - Rename k8s/switchboard.yaml → k8s/armature.yaml - Rename chart alerting/dashboard files - Fix: DockerHub push uses env: binding for secret injection - Helm chart updated (name, labels, template functions, dashboard, alerting) - Replace favicon/icon assets with Armature brand No functional changes. Pure mechanical rename + CI fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
456 lines
12 KiB
Go
456 lines
12 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={})
|
|
//
|
|
// # 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", "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
|
|
}
|