Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 4 additions & 123 deletions app/controlplane/plugins/sdk/v1/httpclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,16 @@
package sdk

import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"

"github.com/chainloop-dev/chainloop/pkg/netguard"
)

// ErrBlockedTarget is returned when a request is refused because its
// destination is not allowed by the client's network policy.
var ErrBlockedTarget = errors.New("blocked outbound request")
var ErrBlockedTarget = netguard.ErrBlockedTarget

const (
dialTimeout = 10 * time.Second
Expand Down Expand Up @@ -83,126 +82,8 @@ func NewHTTPClient(opts HTTPClientOptions) *http.Client {
}

if opts.PublicTargetsOnly {
transport.DialContext = publicOnlyDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext)

// Through a proxy the only address this client connects to is the
// proxy's own, which leaves the destination unchecked and the guard
// above unenforced, so a public-only client never uses one.
transport.Proxy = nil
netguard.RestrictTransport(transport)
}

return &http.Client{Timeout: opts.Timeout, Transport: transport}
}

type resolveFunc func(ctx context.Context, host string) ([]net.IPAddr, error)

type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)

// publicOnlyDialContext wraps dial so that a connection is only made to a
// publicly routable address.
//
// The check runs here, at dial time, rather than against the URL, for two
// reasons. It sees the address the connection will actually use, so a host
// name that resolves to an allowed address for a check and to a blocked one
// for the connection cannot slip through: the dial targets the very IP that
// was validated. And because every redirect hop opens its own connection,
// the whole chain is covered, not just the URL the caller supplied.
func publicOnlyDialContext(resolve resolveFunc, dial dialFunc) dialFunc {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("%w: malformed address %q", ErrBlockedTarget, addr)
}

ips, err := resolve(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolving %q: %w", host, err)
}

if len(ips) == 0 {
return nil, fmt.Errorf("%w: %q did not resolve to any address", ErrBlockedTarget, host)
}

// A host that answers with a mix of public and non-public addresses is
// refused outright, so that retrying cannot land on the blocked one.
for _, ip := range ips {
if !isPubliclyRoutable(ip.IP) {
return nil, fmt.Errorf("%w: %q resolves to non-public address %s", ErrBlockedTarget, host, ip.IP)
}
}

var lastErr error
for _, ip := range ips {
conn, err := dial(ctx, network, net.JoinHostPort(ip.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}

return nil, lastErr
}
}

// blockedNets holds the ranges that are not publicly routable but that the
// checks net.IP offers do not already cover.
var blockedNets = []*net.IPNet{
mustParseCIDR("0.0.0.0/8"), // RFC 6890 "this network"
mustParseCIDR("100.64.0.0/10"), // RFC 6598 shared address space (CGNAT)
mustParseCIDR("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
mustParseCIDR("192.0.2.0/24"), // RFC 5737 documentation
mustParseCIDR("198.18.0.0/15"), // RFC 2544 benchmarking
mustParseCIDR("198.51.100.0/24"), // RFC 5737 documentation
mustParseCIDR("203.0.113.0/24"), // RFC 5737 documentation
mustParseCIDR("240.0.0.0/4"), // RFC 1112 reserved
mustParseCIDR("::/96"), // RFC 4291 IPv4-compatible, deprecated
mustParseCIDR("64:ff9b::/96"), // RFC 6052 NAT64
mustParseCIDR("100::/64"), // RFC 6666 discard-only
mustParseCIDR("2001::/32"), // RFC 4380 Teredo
mustParseCIDR("2001:2::/48"), // RFC 5180 benchmarking
mustParseCIDR("2001:db8::/32"), // RFC 3849 documentation
mustParseCIDR("2002::/16"), // RFC 3056 6to4
}

// isPubliclyRoutable reports whether ip is an address on the public internet.
func isPubliclyRoutable(ip net.IP) bool {
if ip == nil {
return false
}

// Covers the unspecified address, loopback, link-local addresses (and with
// them the cloud metadata endpoints), multicast and the IPv4 broadcast
// address.
if !ip.IsGlobalUnicast() {
return false
}

// RFC 1918 ranges and RFC 4193 unique local addresses.
if ip.IsPrivate() {
return false
}

// An IPv4 address reached through an IPv6 form is judged as the IPv4
// address it carries. The checks above already do this; the ranges below
// are written in their IPv4 form, so narrow the address first.
if v4 := ip.To4(); v4 != nil {
ip = v4
}

for _, blocked := range blockedNets {
if blocked.Contains(ip) {
return false
}
}

return true
}

func mustParseCIDR(cidr string) *net.IPNet {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
panic(fmt.Sprintf("sdk: invalid CIDR %q: %v", cidr, err))
}

return network
}
201 changes: 0 additions & 201 deletions app/controlplane/plugins/sdk/v1/httpclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@
package sdk

import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -27,157 +24,6 @@ import (
"github.com/stretchr/testify/require"
)

func TestIsPubliclyRoutable(t *testing.T) {
testCases := []struct {
name string
ip string
want bool
}{
{name: "public IPv4", ip: "8.8.8.8", want: true},
{name: "public IPv6", ip: "2606:4700:4700::1111", want: true},

{name: "IPv4 loopback", ip: "127.0.0.1", want: false},
{name: "IPv6 loopback", ip: "::1", want: false},
{name: "unspecified IPv4", ip: "0.0.0.0", want: false},
{name: "unspecified IPv6", ip: "::", want: false},
{name: "IPv4 broadcast", ip: "255.255.255.255", want: false},
{name: "IPv4 multicast", ip: "224.0.0.1", want: false},

{name: "RFC 1918 10/8", ip: "10.0.0.1", want: false},
{name: "RFC 1918 172.16/12", ip: "172.16.0.1", want: false},
{name: "RFC 1918 192.168/16", ip: "192.168.1.1", want: false},
{name: "IPv6 unique local", ip: "fc00::1", want: false},

// Cloud metadata services live in the link-local range
{name: "AWS and Azure IMDS", ip: "169.254.169.254", want: false},
{name: "IPv6 link-local", ip: "fe80::1", want: false},

{name: "CGNAT shared address space", ip: "100.64.0.1", want: false},
{name: "IETF protocol assignments", ip: "192.0.0.1", want: false},
{name: "benchmarking range", ip: "198.18.0.1", want: false},
{name: "reserved 240/4", ip: "240.0.0.1", want: false},
{name: "this network 0/8", ip: "0.1.2.3", want: false},
{name: "IPv4 documentation 192.0.2/24", ip: "192.0.2.1", want: false},
{name: "IPv4 documentation 198.51.100/24", ip: "198.51.100.1", want: false},
{name: "IPv4 documentation 203.0.113/24", ip: "203.0.113.1", want: false},
{name: "IPv6 discard-only", ip: "100::1", want: false},
{name: "IPv6 benchmarking", ip: "2001:2::1", want: false},
{name: "IPv6 documentation", ip: "2001:db8::1", want: false},

// An IPv4 address reached through an IPv6 form must be judged as the
// IPv4 address it carries.
{name: "IPv4-mapped loopback", ip: "::ffff:127.0.0.1", want: false},
{name: "IPv4-mapped private", ip: "::ffff:10.0.0.1", want: false},
{name: "IPv4-mapped IMDS", ip: "::ffff:169.254.169.254", want: false},
{name: "IPv4-mapped public", ip: "::ffff:8.8.8.8", want: true},
{name: "IPv4-compatible", ip: "::10.0.0.1", want: false},

// IPv6 transition ranges embed an IPv4 address, so they are rejected
// wholesale rather than unwrapped.
{name: "NAT64", ip: "64:ff9b::a00:1", want: false},
{name: "Teredo", ip: "2001::1", want: false},
{name: "6to4", ip: "2002::1", want: false},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
require.NotNil(t, ip, "invalid test fixture %q", tc.ip)

assert.Equal(t, tc.want, isPubliclyRoutable(ip))
})
}
}

func TestIsPubliclyRoutableRejectsNilIP(t *testing.T) {
assert.False(t, isPubliclyRoutable(nil))
}

func TestPublicOnlyDialContext(t *testing.T) {
testCases := []struct {
name string
// resolved is what DNS returns for the dialed host
resolved []string
// wantDialedTo is the address the underlying dialer must receive. The
// client has to connect to the IP that was validated, not to the
// hostname, so that a second DNS answer cannot change the target.
wantDialedTo string
wantBlocked bool
}{
{
name: "public address is dialed by IP",
resolved: []string{"8.8.8.8"},
wantDialedTo: "8.8.8.8:443",
},
{
name: "private address is blocked",
resolved: []string{"10.0.0.1"},
wantBlocked: true,
},
{
name: "metadata endpoint is blocked",
resolved: []string{"169.254.169.254"},
wantBlocked: true,
},
{
name: "blocked when any answer is non-public",
resolved: []string{"8.8.8.8", "127.0.0.1"},
wantBlocked: true,
},
{
name: "first public answer is used",
resolved: []string{"1.1.1.1", "8.8.8.8"},
wantDialedTo: "1.1.1.1:443",
},
{
name: "host without any answer is blocked",
resolved: []string{},
wantBlocked: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) {
addrs := make([]net.IPAddr, 0, len(tc.resolved))
for _, ip := range tc.resolved {
addrs = append(addrs, net.IPAddr{IP: net.ParseIP(ip)})
}
return addrs, nil
}

var dialedTo string
dial := func(_ context.Context, _, addr string) (net.Conn, error) {
dialedTo = addr
return nil, nil
}

_, err := publicOnlyDialContext(resolve, dial)(context.Background(), "tcp", "example.com:443")

if tc.wantBlocked {
require.ErrorIs(t, err, ErrBlockedTarget)
assert.Empty(t, dialedTo, "a blocked target must not be dialed")
return
}

require.NoError(t, err)
assert.Equal(t, tc.wantDialedTo, dialedTo)
})
}
}

func TestPublicOnlyDialContextResolutionFailure(t *testing.T) {
resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) {
return nil, errors.New("no such host")
}
dial := func(_ context.Context, _, _ string) (net.Conn, error) {
return nil, errors.New("dial must not be reached")
}

_, err := publicOnlyDialContext(resolve, dial)(context.Background(), "tcp", "example.com:443")
require.Error(t, err)
}

func TestNewHTTPClientPublicTargetsOnly(t *testing.T) {
// httptest listens on the loopback interface, so it stands in for any
// destination a public-only client must refuse to reach.
Expand Down Expand Up @@ -241,50 +87,3 @@ func TestNewHTTPClientProxyUse(t *testing.T) {
})
}
}

// Each redirect hop opens its own connection, so the dial-time check covers
// the whole chain: a first hop that passes cannot forward the client on to a
// blocked destination. Resolution is stubbed because every test server listens
// on the loopback interface, which no public-only client would reach at all.
func TestPublicOnlyDialContextValidatesEveryRedirectHop(t *testing.T) {
var targetRequests int
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
targetRequests++
w.WriteHeader(http.StatusOK)
}))
defer target.Close()

redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirector.Close()

// The URL the caller supplies resolves to a public address; the redirect
// target it is then sent to resolves to a private one.
var resolved int
resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) {
resolved++
if resolved == 1 {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
}
return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil
}

// The guard dials the address it validated, so point the dialer back at
// the test server on that same port to keep the test off the network.
dialer := &net.Dialer{}
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port))
}

client := &http.Client{Transport: &http.Transport{DialContext: publicOnlyDialContext(resolve, dial)}}

_, err := client.Get(redirector.URL)
require.ErrorIs(t, err, ErrBlockedTarget)
assert.Equal(t, 2, resolved, "every hop must be resolved and validated")
assert.Zero(t, targetRequests, "the redirect target must not be reached")
}
Loading
Loading