diff --git a/spindle/tapclient.go b/spindle/tapclient.go --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -7,6 +7,7 @@ "errors" "fmt" "log/slog" + "net" "net/http" "net/url" "sync" @@ -22,6 +23,7 @@ "tangled.org/core/spindle/db" "tangled.org/core/spindle/git" "tangled.org/core/spindle/models" + "tangled.org/core/spindle/netguard" "tangled.org/core/tapc" "tangled.org/core/tid" "tangled.org/core/workflow" @@ -31,6 +33,23 @@ maxPendingPerRepo = 64 pendingCollabTTL = 10 * time.Minute ) + +// blobs are fetched from user controlled PDSes so protect our transport +// from dialing internal addresses +var guardedBlobClient = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: netguard.RefuseSpecialPurposeAddrs, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, +} type pendingCollabEvent struct { evt *tapc.RecordEventData @@ -576,7 +595,7 @@ } req.Header.Set("Content-Type", "application/json") - blobResp, err := http.DefaultClient.Do(req) + blobResp, err := guardedBlobClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch blob: %w", err) } diff --git a/spindle/netguard/netguard.go b/spindle/netguard/netguard.go new file mode 100644 --- /dev/null +++ b/spindle/netguard/netguard.go @@ -0,0 +1,82 @@ +// refuses outbound dials to special-purpose addresses, for anywhere +// spindle fetches user-influenced urls (workflow caches, PDS blob +// fetches) +package netguard + +import ( + "fmt" + "net" + "syscall" +) + +// https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml +// https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml +// https://datatracker.ietf.org/doc/rfc6890/ +var BlockedRoutes = []string{ + "0.0.0.0/8", // unspecified / "this network" addresses + "10.0.0.0/8", // private network + "100.64.0.0/10", // shared carrier-grade nat space + "127.0.0.0/8", // loopback + "169.254.0.0/16", // link-local / autoconfiguration + "172.16.0.0/12", // private network + "192.0.0.0/24", // ietf protocol assignments + "192.0.2.0/24", // documentation / examples + "192.88.99.0/24", // deprecated 6to4 relay anycast + "192.168.0.0/16", // private network + "198.18.0.0/15", // benchmarking / testing + "198.51.100.0/24", // documentation / examples + "203.0.113.0/24", // documentation / examples + "224.0.0.0/4", // multicast + "240.0.0.0/4", // reserved / future use, includes limited broadcast + "::/128", // unspecified address + "::1/128", // loopback + "::ffff:0:0/96", // ipv4-mapped addresses + "64:ff9b::/96", // ipv4/ipv6 translation prefix + "100::/64", // discard-only prefix + "2001::/23", // ietf protocol assignments + "2001:db8::/32", // documentation / examples + "2002::/16", // deprecated 6to4 addressing + "fc00::/7", // unique local addresses + "fe80::/10", // link-local unicast + "ff00::/8", // multicast +} + +var BlockedNets = func() []*net.IPNet { + nets := make([]*net.IPNet, 0, len(BlockedRoutes)) + for _, route := range BlockedRoutes { + _, ipnet, err := net.ParseCIDR(route) + if err != nil { + panic(fmt.Sprintf("parse blocked route %q: %v", route, err)) + } + nets = append(nets, ipnet) + } + return nets +}() + +// net.Dialer Control func rejecting blocked special-purpose addresses. +// this should run after dns resolution, so it should cover any rebinding tricks +func RefuseSpecialPurposeAddrs(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("split dial address %q: %w", address, err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("refusing to dial non-IP address %q", host) + } + bits := 128 + if ip4 := ip.To4(); ip4 != nil { + ip = ip4 + bits = 32 + } + for _, ipnet := range BlockedNets { + _, blockedBits := ipnet.Mask.Size() + if blockedBits != bits { + continue + } + if ipnet.Contains(ip) { + return fmt.Errorf("refusing to dial %s: %s is blocked", ip, ipnet) + } + } + return nil +} diff --git a/spindle/engines/microvm/networking.go b/spindle/engines/microvm/networking.go --- a/spindle/engines/microvm/networking.go +++ b/spindle/engines/microvm/networking.go @@ -6,55 +6,17 @@ _ "embed" "fmt" "log/slog" - "net" "os" "os/exec" "text/template" + + "tangled.org/core/spindle/netguard" ) -// https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml -// https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml -// https://datatracker.ietf.org/doc/rfc6890/ -var blockedNamespaceRoutes = []string{ - "0.0.0.0/8", // unspecified / "this network" addresses - "10.0.0.0/8", // private network - "100.64.0.0/10", // shared carrier-grade nat space - "127.0.0.0/8", // loopback - "169.254.0.0/16", // link-local / autoconfiguration - "172.16.0.0/12", // private network - "192.0.0.0/24", // ietf protocol assignments - "192.0.2.0/24", // documentation / examples - "192.88.99.0/24", // deprecated 6to4 relay anycast - "192.168.0.0/16", // private network - "198.18.0.0/15", // benchmarking / testing - "198.51.100.0/24", // documentation / examples - "203.0.113.0/24", // documentation / examples - "224.0.0.0/4", // multicast - "240.0.0.0/4", // reserved / future use, includes limited broadcast - "::/128", // unspecified address - "::1/128", // loopback - "::ffff:0:0/96", // ipv4-mapped addresses - "64:ff9b::/96", // ipv4/ipv6 translation prefix - "100::/64", // discard-only prefix - "2001::/23", // ietf protocol assignments - "2001:db8::/32", // documentation / examples - "2002::/16", // deprecated 6to4 addressing - "fc00::/7", // unique local addresses - "fe80::/10", // link-local unicast - "ff00::/8", // multicast -} - -var blockedNamespaceNets = func() []*net.IPNet { - nets := make([]*net.IPNet, 0, len(blockedNamespaceRoutes)) - for _, route := range blockedNamespaceRoutes { - _, ipnet, err := net.ParseCIDR(route) - if err != nil { - panic(fmt.Sprintf("parse blocked route %q: %v", route, err)) - } - nets = append(nets, ipnet) - } - return nets -}() +var ( + blockedNamespaceRoutes = netguard.BlockedRoutes + blockedNamespaceNets = netguard.BlockedNets +) //go:embed netns_wrapper.sh.tmpl var netnsWrapperTemplate string diff --git a/spindle/engines/microvm/read_cache_proxy.go b/spindle/engines/microvm/read_cache_proxy.go --- a/spindle/engines/microvm/read_cache_proxy.go +++ b/spindle/engines/microvm/read_cache_proxy.go @@ -14,10 +14,11 @@ "net/url" "strings" "sync" - "syscall" "time" "github.com/mdlayher/vsock" + + "tangled.org/core/spindle/netguard" ) const ( @@ -233,40 +234,13 @@ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, - Control: refuseSpecialPurposeAddrs, + Control: netguard.RefuseSpecialPurposeAddrs, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, -} - -// this should run after dns resolution, so it should cover any rebinding tricks -func refuseSpecialPurposeAddrs(network, address string, _ syscall.RawConn) error { - host, _, err := net.SplitHostPort(address) - if err != nil { - return fmt.Errorf("split dial address %q: %w", address, err) - } - ip := net.ParseIP(host) - if ip == nil { - return fmt.Errorf("refusing to dial non-IP address %q", host) - } - bits := 128 - if ip4 := ip.To4(); ip4 != nil { - ip = ip4 - bits = 32 - } - for _, ipnet := range blockedNamespaceNets { - _, blockedBits := ipnet.Mask.Size() - if blockedBits != bits { - continue - } - if ipnet.Contains(ip) { - return fmt.Errorf("refusing to dial %s: %s is blocked for workflow caches", ip, ipnet) - } - } - return nil } // the proxy is the cache as far as the guest is concerned, so we answer diff --git a/spindle/engines/microvm/read_cache_proxy_test.go b/spindle/engines/microvm/read_cache_proxy_test.go --- a/spindle/engines/microvm/read_cache_proxy_test.go +++ b/spindle/engines/microvm/read_cache_proxy_test.go @@ -8,6 +8,8 @@ "strings" "testing" "time" + + "tangled.org/core/spindle/netguard" ) func TestCacheProxyFallsBackOnNotFound(t *testing.T) { @@ -123,7 +125,7 @@ } func TestCacheProxyGuardAllowsPublicIPv4(t *testing.T) { - if err := refuseSpecialPurposeAddrs("tcp", "104.26.13.82:443", nil); err != nil { + if err := netguard.RefuseSpecialPurposeAddrs("tcp", "104.26.13.82:443", nil); err != nil { t.Fatalf("public IPv4 address was blocked: %v", err) } }