diff --git a/app/controlplane/plugins/sdk/v1/httpclient.go b/app/controlplane/plugins/sdk/v1/httpclient.go index 29ef48a8c..7aa8430de 100644 --- a/app/controlplane/plugins/sdk/v1/httpclient.go +++ b/app/controlplane/plugins/sdk/v1/httpclient.go @@ -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 @@ -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 -} diff --git a/app/controlplane/plugins/sdk/v1/httpclient_test.go b/app/controlplane/plugins/sdk/v1/httpclient_test.go index f4f0498c0..2d0980510 100644 --- a/app/controlplane/plugins/sdk/v1/httpclient_test.go +++ b/app/controlplane/plugins/sdk/v1/httpclient_test.go @@ -16,9 +16,6 @@ package sdk import ( - "context" - "errors" - "net" "net/http" "net/http/httptest" "testing" @@ -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. @@ -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") -} diff --git a/pkg/netguard/netguard.go b/pkg/netguard/netguard.go new file mode 100644 index 000000000..7030c99be --- /dev/null +++ b/pkg/netguard/netguard.go @@ -0,0 +1,194 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package netguard restricts outbound HTTP connections to publicly routable +// destinations. +package netguard + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" +) + +// ErrBlockedTarget is returned when a request is refused because its +// destination is not publicly routable. +var ErrBlockedTarget = errors.New("blocked outbound request") + +const ( + dialTimeout = 10 * time.Second + dialKeepAlive = 30 * time.Second +) + +// ResolveFunc resolves a host name into the addresses it points to. +type ResolveFunc func(ctx context.Context, host string) ([]net.IPAddr, error) + +// DialFunc opens a connection to addr, in the form of net.Dialer.DialContext. +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +// RestrictTransport makes t connect to publicly routable destinations only: +// loopback, private ranges, link-local addresses (where cloud metadata +// services live) and the IPv6 transition ranges that embed an IPv4 address are +// all refused with ErrBlockedTarget. +// +// It also drops any proxy, which would otherwise be the only address the +// transport connects to and would leave the destination unchecked. +// +// t is modified in place and returned. A nil t starts from a clone of +// http.DefaultTransport. +func RestrictTransport(t *http.Transport) *http.Transport { + if t == nil { + t = http.DefaultTransport.(*http.Transport).Clone() + } + + // The transport's own dialer is replaced rather than wrapped, since it may + // ignore the address it is given and connect somewhere else entirely. Its + // TLS dialers are dropped too: net/http prefers them over DialContext for + // https requests, so they would bypass the check. + dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive} + t.DialContext = PublicOnlyDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext) + t.DialTLSContext = nil + t.DialTLS = nil //nolint:staticcheck // deprecated, but still honored by net/http + t.Proxy = nil + + return t +} + +// Transport returns a process-wide transport that only connects to publicly +// routable destinations, so that its connections are pooled and reused across +// requests. See RestrictTransport. +var Transport = sync.OnceValue(func() *http.Transport { + return RestrictTransport(nil) +}) + +// NewHTTPClient returns a client that only connects to publicly routable +// destinations, backed by Transport. +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, Transport: Transport()} +} + +// 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("64:ff9b:1::/48"), // RFC 8215 local-use 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("netguard: invalid CIDR %q: %v", cidr, err)) + } + + return network +} diff --git a/pkg/netguard/netguard_test.go b/pkg/netguard/netguard_test.go new file mode 100644 index 000000000..493f9170f --- /dev/null +++ b/pkg/netguard/netguard_test.go @@ -0,0 +1,364 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package netguard + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + publicIP = "8.8.8.8" + // Cloud metadata services listen on this link-local address + metadataIP = "169.254.169.254" +) + +func TestIsPubliclyRoutable(t *testing.T) { + testCases := []struct { + name string + ip string + want bool + }{ + {name: "public IPv4", ip: publicIP, 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: metadataIP, 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: "local-use NAT64", ip: "64:ff9b:1::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{publicIP}, + 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{metadataIP}, + wantBlocked: true, + }, + { + name: "blocked when any answer is non-public", + resolved: []string{publicIP, "127.0.0.1"}, + wantBlocked: true, + }, + { + name: "first public answer is used", + resolved: []string{"1.1.1.1", publicIP}, + 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) +} + +// A host name whose answer changes between connections is judged on each +// answer, and the connection goes to the very address that was judged, so a +// name that first points at a public address cannot later be used to reach a +// private one. +func TestPublicOnlyDialContextChangingDNSAnswer(t *testing.T) { + answers := []string{publicIP, metadataIP} + var lookups int + resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) { + ip := answers[lookups] + lookups++ + return []net.IPAddr{{IP: net.ParseIP(ip)}}, nil + } + + var dialed []string + dial := func(_ context.Context, _, addr string) (net.Conn, error) { + dialed = append(dialed, addr) + return nil, nil + } + + dialContext := PublicOnlyDialContext(resolve, dial) + + _, err := dialContext(context.Background(), "tcp", "rebind.example.com:443") + require.NoError(t, err) + + _, err = dialContext(context.Background(), "tcp", "rebind.example.com:443") + require.ErrorIs(t, err, ErrBlockedTarget) + + assert.Equal(t, []string{"8.8.8.8:443"}, dialed) +} + +// A transport may carry its own TLS dialers, which net/http uses for https +// requests in place of DialContext. A restricted transport must not keep any +// of them, or https destinations would go unchecked. +func TestRestrictTransportDropsTLSDialers(t *testing.T) { + var requests int + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Ignores the address it is given and connects to the test server + dialTLS := func(ctx context.Context, network, _ string) (net.Conn, error) { + dialer := &tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true}} // #nosec G402 -- test server + return dialer.DialContext(ctx, network, server.Listener.Addr().String()) + } + + testCases := []struct { + name string + transport func() *http.Transport + }{ + {name: "DialTLSContext", transport: func() *http.Transport { + return &http.Transport{DialTLSContext: dialTLS} + }}, + {name: "DialTLS", transport: func() *http.Transport { + return &http.Transport{DialTLS: func(network, addr string) (net.Conn, error) { //nolint:staticcheck // the deprecated field is still honored + return dialTLS(context.Background(), network, addr) + }} + }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Positive control: the custom dialer reaches the server + resp, err := (&http.Client{Transport: tc.transport()}).Get(server.URL) + require.NoError(t, err) + resp.Body.Close() + + requests = 0 + _, err = (&http.Client{Transport: RestrictTransport(tc.transport())}).Get(server.URL) + require.ErrorIs(t, err, ErrBlockedTarget) + assert.Zero(t, requests, "a blocked destination must not be reached") + }) + } +} + +func TestRestrictTransport(t *testing.T) { + // httptest listens on the loopback interface, so it stands in for any + // destination a restricted transport must refuse to reach. + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Positive control: the destination is reachable without the guard. + resp, err := http.Get(server.URL) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 1, requests) + + testCases := []struct { + name string + transport func() *http.Transport + }{ + {name: "nil transport", transport: func() *http.Transport { return RestrictTransport(nil) }}, + {name: "transport with a proxy and its own dialer", transport: func() *http.Transport { + return RestrictTransport(&http.Transport{ + Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "127.0.0.1:1"}), + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + // Ignores the address it is given, which must not let the + // restricted transport reach a blocked destination. + return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String()) + }, + }) + }}, + {name: "shared transport", transport: Transport}, + {name: "client transport", transport: func() *http.Transport { + return NewHTTPClient(0).Transport.(*http.Transport) + }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + requests = 0 + transport := tc.transport() + + // A proxy would be the only address the transport connects to, + // leaving the destination unchecked. + assert.Nil(t, transport.Proxy) + + _, err := (&http.Client{Transport: transport}).Get(server.URL) + require.ErrorIs(t, err, ErrBlockedTarget) + assert.Zero(t, requests, "a blocked destination must not be reached") + }) + } +} + +// 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(publicIP)}}, 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") +} diff --git a/pkg/policies/engine/engine.go b/pkg/policies/engine/engine.go index 3de5ef02d..8579f1613 100644 --- a/pkg/policies/engine/engine.go +++ b/pkg/policies/engine/engine.go @@ -46,6 +46,10 @@ type CommonEngineOptions struct { // as optional. ProjectName string ProjectVersionName string + // PublicTargetsOnly refuses rego http.send requests to destinations that + // are not publicly routable, even when their host is allowed. See + // WithPublicTargetsOnly. + PublicTargetsOnly bool } // Option is a unified functional option for configuring policy engines @@ -132,6 +136,19 @@ func WithProjectContext(name, version string) Option { } } +// WithPublicTargetsOnly makes the rego engine refuse http.send requests to +// destinations that are not publicly routable, such as loopback, private and +// link-local addresses, and ignore any proxy configured in the environment. +// The check applies to the address a host name resolves to and to every +// redirect hop. It is off by default, since policies evaluated in a user's own +// environment may legitimately reach internal hosts. The WASM engine does not +// support it yet. +func WithPublicTargetsOnly() Option { + return func(opts *Options) { + opts.PublicTargetsOnly = true + } +} + // ApplyOptions applies options and returns the configured Options // This automatically appends BaseAllowedHostnames to any user-provided hostnames func ApplyOptions(opts ...Option) *Options { diff --git a/pkg/policies/engine/rego/httpsend.go b/pkg/policies/engine/rego/httpsend.go index 3e4086e79..eb22f8bbc 100644 --- a/pkg/policies/engine/rego/httpsend.go +++ b/pkg/policies/engine/rego/httpsend.go @@ -18,9 +18,11 @@ package rego import ( "context" "fmt" + "net/http" "net/url" "slices" + "github.com/chainloop-dev/chainloop/pkg/netguard" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/topdown" ) @@ -55,6 +57,19 @@ func isPermissiveMode(ctx context.Context) bool { return permissive } +type publicTargetsOnlyCtxKey struct{} + +// withPublicTargetsOnly marks the evaluation context so that http.send only +// connects to publicly routable destinations. +func withPublicTargetsOnly(ctx context.Context) context.Context { + return context.WithValue(ctx, publicTargetsOnlyCtxKey{}, true) +} + +func isPublicTargetsOnly(ctx context.Context) bool { + publicOnly, _ := ctx.Value(publicTargetsOnlyCtxKey{}).(bool) + return publicOnly +} + // OPA resolves built-ins from a global registry, so http.send can only be // restricted by replacing its implementation there. func init() { @@ -65,10 +80,23 @@ func init() { return err } } + if isPublicTargetsOnly(bctx.Context) { + bctx.RoundTripper = publicTargetsOnlyRoundTripper + } return httpSend(bctx, operands, iter) }) } +// publicTargetsOnlyRoundTripper restricts the transport http.send builds for a +// request to publicly routable destinations. OPA passes a nil transport when +// the request needs no custom one, which then shares one pooled transport. +func publicTargetsOnlyRoundTripper(t *http.Transport) http.RoundTripper { + if t == nil { + return netguard.Transport() + } + return netguard.RestrictTransport(t) +} + func validateHTTPSendRequest(operands []*ast.Term) error { req, ok := operands[0].Value.(ast.Object) if !ok { diff --git a/pkg/policies/engine/rego/httpsend_test.go b/pkg/policies/engine/rego/httpsend_test.go index ba4b46699..172a24efd 100644 --- a/pkg/policies/engine/rego/httpsend_test.go +++ b/pkg/policies/engine/rego/httpsend_test.go @@ -18,8 +18,12 @@ package rego import ( "context" "fmt" + "net/http" + "net/http/httptest" + "net/url" "testing" + "github.com/chainloop-dev/chainloop/pkg/netguard" "github.com/chainloop-dev/chainloop/pkg/policies/engine" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -155,3 +159,67 @@ violations contains resp.error.message if { assert.Contains(t, result.Violations[0].Violation, "/nonexistent/ca.pem") assert.NotContains(t, result.Violations[0].Violation, "is not allowed") } + +func TestRego_HTTPSendPublicTargetsOnly(t *testing.T) { + // httptest listens on the loopback interface, so it stands in for any + // destination that is not publicly routable. + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + policy := &engine.Policy{ + Name: "http-send", + Source: fmt.Appendf(nil, httpSendPolicyTemplate, fmt.Sprintf(`{"method": "GET", "url": %q}`, server.URL)), + } + + testCases := []struct { + name string + mode EnvironmentMode + publicOnly bool + }{ + {name: "restrictive mode reaches a private target by default", mode: EnvironmentModeRestrictive}, + {name: "restrictive mode blocks a private target", mode: EnvironmentModeRestrictive, publicOnly: true}, + {name: "permissive mode reaches a private target by default", mode: EnvironmentModePermissive}, + {name: "permissive mode blocks a private target", mode: EnvironmentModePermissive, publicOnly: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := []engine.Option{ + engine.WithOperatingMode(int32(tc.mode)), + // OPA matches the allowed host without its port + engine.WithAllowedHostnames(serverURL.Hostname()), + } + if tc.publicOnly { + opts = append(opts, engine.WithPublicTargetsOnly()) + } + r := NewEngine(opts...) + + requests = 0 + _, verifyErr := r.Verify(context.TODO(), policy, []byte(`{}`), nil) + _, matchErr := r.MatchesParameters(context.TODO(), policy, nil, nil) + + if !tc.publicOnly { + require.NoError(t, verifyErr) + require.NoError(t, matchErr) + assert.Equal(t, 2, requests) + return + } + + assert.Zero(t, requests, "a private target must not be reached") + // Only strict evaluation surfaces http.send errors + if tc.mode == EnvironmentModeRestrictive { + for _, err := range []error{verifyErr, matchErr} { + require.Error(t, err) + assert.Contains(t, err.Error(), netguard.ErrBlockedTarget.Error()) + } + } + }) + } +} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index 03a700ced..f0352bc5c 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -454,10 +454,14 @@ func (r *Engine) MatchesEvaluation(ctx context.Context, policy *engine.Policy, v } // evalContext marks the context of permissive engines, which lifts the -// http.send request option restrictions. +// http.send request option restrictions, and of engines limited to public +// network destinations. func (r *Engine) evalContext(ctx context.Context) context.Context { if r.operatingMode == EnvironmentModePermissive { - return withPermissiveMode(ctx) + ctx = withPermissiveMode(ctx) + } + if r.CommonEngineOptions != nil && r.PublicTargetsOnly { + ctx = withPublicTargetsOnly(ctx) } return ctx } diff --git a/pkg/policies/group_loader.go b/pkg/policies/group_loader.go index d7fdc8b28..9b9b1806d 100644 --- a/pkg/policies/group_loader.go +++ b/pkg/policies/group_loader.go @@ -18,7 +18,6 @@ package policies import ( "context" "fmt" - "io" "net/http" "net/url" "os" @@ -67,7 +66,10 @@ func (l *FileGroupLoader) Load(_ context.Context, attachment *v1.PolicyGroupAtta } // HTTPSGroupLoader loader loads policies from HTTP or HTTPS references -type HTTPSGroupLoader struct{} +type HTTPSGroupLoader struct { + // Client sends the request. Defaults to http.DefaultClient. + Client *http.Client +} func (l *HTTPSGroupLoader) Load(_ context.Context, attachment *v1.PolicyGroupAttachment) (*v1.PolicyGroup, *PolicyDescriptor, error) { ref, wantDigest := ExtractDigest(attachment.GetRef()) @@ -77,15 +79,9 @@ func (l *HTTPSGroupLoader) Load(_ context.Context, attachment *v1.PolicyGroupAtt return nil, nil, fmt.Errorf("invalid policy reference %q: %w", ref, err) } - // #nosec G107 - resp, err := http.Get(ref) - if err != nil { - return nil, nil, fmt.Errorf("requesting remote policy: %w", err) - } - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) + raw, err := fetchRemote(l.Client, ref) if err != nil { - return nil, nil, fmt.Errorf("reading remote policy: %w", err) + return nil, nil, err } var group v1.PolicyGroup diff --git a/pkg/policies/loader.go b/pkg/policies/loader.go index 3a5ee1132..1e7e104d4 100644 --- a/pkg/policies/loader.go +++ b/pkg/policies/loader.go @@ -18,6 +18,7 @@ package policies import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -127,7 +128,10 @@ func (l *FileLoader) Load(_ context.Context, attachment *v1.PolicyAttachment) (* } // HTTPSLoader loader loads policies from HTTP or HTTPS references -type HTTPSLoader struct{} +type HTTPSLoader struct { + // Client sends the request. Defaults to http.DefaultClient. + Client *http.Client +} func (l *HTTPSLoader) Load(_ context.Context, attachment *v1.PolicyAttachment) (*v1.Policy, *PolicyDescriptor, error) { ref, wantDigest := ExtractDigest(attachment.GetRef()) @@ -137,15 +141,9 @@ func (l *HTTPSLoader) Load(_ context.Context, attachment *v1.PolicyAttachment) ( return nil, nil, fmt.Errorf("invalid policy reference %q: %w", ref, err) } - // #nosec G107 - resp, err := http.Get(ref) - if err != nil { - return nil, nil, fmt.Errorf("requesting remote policy: %w", err) - } - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) + raw, err := fetchRemote(l.Client, ref) if err != nil { - return nil, nil, fmt.Errorf("reading remote policy: %w", err) + return nil, nil, err } var policy v1.Policy @@ -157,6 +155,38 @@ func (l *HTTPSLoader) Load(_ context.Context, attachment *v1.PolicyAttachment) ( return &policy, d, nil } +// fetchRemote reads the body of a GET request to ref, sent with client, or +// http.DefaultClient when it is nil. Responses other than 2xx are refused. +// Errors leave out ref, which may carry credentials in its query string. +func fetchRemote(client *http.Client, ref string) ([]byte, error) { + if client == nil { + client = http.DefaultClient + } + + // #nosec G107 + resp, err := client.Get(ref) + if err != nil { + // url.Error prefixes the full URL to the underlying error + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return nil, fmt.Errorf("requesting remote policy: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("requesting remote policy: server returned HTTP %d", resp.StatusCode) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading remote policy: %w", err) + } + + return raw, nil +} + // ChainloopLoader loads policies referenced with chainloop://provider/name URLs type ChainloopLoader struct { Client pb.AttestationServiceClient diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index eac8839aa..f4aff7795 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -22,6 +22,7 @@ import ( "fmt" "io/fs" "maps" + "net/http" "net/url" "path/filepath" "regexp" @@ -34,6 +35,7 @@ import ( "buf.build/go/protovalidate" v13 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/pkg/cache" + "github.com/chainloop-dev/chainloop/pkg/netguard" "github.com/chainloop-dev/chainloop/pkg/templates" intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" @@ -109,6 +111,10 @@ type PolicyVerifier struct { projectVersionName string runtimeInputs *RuntimeInputs executionTimeout time.Duration + publicTargetsOnly bool + // httpClient fetches remote policy specs, groups and scripts. Nil means + // http.DefaultClient. + httpClient *http.Client } var _ Verifier = (*PolicyVerifier)(nil) @@ -127,6 +133,7 @@ type PolicyVerifierOptions struct { ProjectVersionName string RuntimeInputs *RuntimeInputs ExecutionTimeout time.Duration + PublicTargetsOnly bool } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -222,6 +229,18 @@ func WithExecutionTimeout(timeout time.Duration) PolicyVerifierOption { } } +// WithPublicTargetsOnly refuses network requests to destinations that are not +// publicly routable, such as loopback, private and link-local addresses. It +// covers the HTTP requests rego policies make while being evaluated, and the +// fetches of policy specs, groups and scripts referenced by http(s) URLs. +// It is off by default, since policies evaluated in a user's own environment +// may legitimately reach internal hosts. +func WithPublicTargetsOnly() PolicyVerifierOption { + return func(o *PolicyVerifierOptions) { + o.PublicTargetsOnly = true + } +} + const defaultPolicyCacheTTL = 5 * time.Minute func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClient, logger *zerolog.Logger, opts ...PolicyVerifierOption) *PolicyVerifier { @@ -248,6 +267,11 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien options.GroupCache, _ = cache.New[*groupWithReference](cache.WithTTL(defaultPolicyCacheTTL)) } + var httpClient *http.Client + if options.PublicTargetsOnly { + httpClient = netguard.NewHTTPClient(remoteLoaderFetchTimeout) + } + return &PolicyVerifier{ policies: policies, client: client, @@ -265,6 +289,8 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien projectVersionName: options.ProjectVersionName, runtimeInputs: options.RuntimeInputs, executionTimeout: executionTimeout, + publicTargetsOnly: options.PublicTargetsOnly, + httpClient: httpClient, } } @@ -398,7 +424,7 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme } // load the policy scripts (rego) - scripts, err := LoadPolicyScriptsFromSpec(policy, opts.kind, policyLocation(attachment.GetRef())) + scripts, err := loadPolicyScriptsFromSpec(policy, opts.kind, policyLocation(attachment.GetRef()), pv.httpClient) if err != nil { return nil, NewPolicyError(err) } @@ -682,6 +708,10 @@ func (pv *PolicyVerifier) executeScript(ctx context.Context, script *engine.Poli opts = append(opts, engine.WithProjectContext(pv.projectName, pv.projectVersionName)) } + if pv.publicTargetsOnly { + opts = append(opts, engine.WithPublicTargetsOnly()) + } + // Bound each policy evaluation regardless of the engine that runs it opts = append(opts, engine.WithExecutionTimeout(pv.executionTimeout)) @@ -781,7 +811,7 @@ func (pv *PolicyVerifier) getLoader(attachment *v1.PolicyAttachment) (Loader, er case fileScheme: loader = new(FileLoader) case httpsScheme, httpScheme: - loader = new(HTTPSLoader) + loader = &HTTPSLoader{Client: pv.httpClient} default: return nil, fmt.Errorf("policy scheme not supported: %s", scheme) } @@ -1042,10 +1072,16 @@ func getPolicyTypes(p *v1.Policy) []v1.CraftingSchema_Material_MaterialType { // * the policy kind is unspecified, meaning that it was forced by name selector // * the policy kind is specified, and it's equal to the material type func LoadPolicyScriptsFromSpec(policy *v1.Policy, kind v1.CraftingSchema_Material_MaterialType, basePath string) ([]*engine.Policy, error) { + return loadPolicyScriptsFromSpec(policy, kind, basePath, nil) +} + +// loadPolicyScriptsFromSpec is LoadPolicyScriptsFromSpec fetching remote scripts +// with client, or with http.DefaultClient when it is nil. +func loadPolicyScriptsFromSpec(policy *v1.Policy, kind v1.CraftingSchema_Material_MaterialType, basePath string, client *http.Client) ([]*engine.Policy, error) { scripts := make([]*engine.Policy, 0) if policy.GetSpec().GetSource() != nil { - script, err := loadLegacyPolicyScript(policy.GetSpec(), basePath) + script, err := loadLegacyPolicyScript(policy.GetSpec(), basePath, client) if err != nil { return nil, fmt.Errorf("failed to load policy script: %w", err) } @@ -1064,7 +1100,7 @@ func LoadPolicyScriptsFromSpec(policy *v1.Policy, kind v1.CraftingSchema_Materia specs := policy.GetSpec().GetPolicies() for _, spec := range specs { if spec.GetKind() == v1.CraftingSchema_Material_MATERIAL_TYPE_UNSPECIFIED || spec.GetKind() == kind { - script, err := loadPolicyScript(spec, basePath) + script, err := loadPolicyScript(spec, basePath, client) if err != nil { return nil, fmt.Errorf("failed to load policy script: %w", err) } @@ -1221,14 +1257,20 @@ func ensureHTTPSURL(u *url.URL) error { return nil } -// loadScriptContent loads a policy script from a reference declared in the policy spec located at basePath -func loadScriptContent(ref, basePath string) ([]byte, error) { +// loadScriptContent loads a policy script from a reference declared in the policy spec located at basePath. +// Remote scripts are fetched with client, or http.DefaultClient when it is nil. +func loadScriptContent(ref, basePath string, client *http.Client) ([]byte, error) { scriptPath, err := resolveScriptRef(ref, basePath) if err != nil { return nil, fmt.Errorf("resolving policy reference: %w", err) } - content, err := blob.LoadFileOrURL(scriptPath) + var content []byte + if scheme, _ := RefParts(scriptPath); scheme == httpsScheme { + content, err = fetchRemote(client, scriptPath) + } else { + content, err = blob.LoadFileOrURL(scriptPath) + } if err != nil { return nil, fmt.Errorf("loading policy content: %w", err) } @@ -1237,26 +1279,26 @@ func loadScriptContent(ref, basePath string) ([]byte, error) { return decodeIfBase64Wasm(content), nil } -func loadPolicyScript(spec *v1.PolicySpecV2, basePath string) ([]byte, error) { +func loadPolicyScript(spec *v1.PolicySpecV2, basePath string, client *http.Client) ([]byte, error) { switch source := spec.GetSource().(type) { case *v1.PolicySpecV2_Embedded: return decodeIfBase64Wasm([]byte(source.Embedded)), nil case *v1.PolicySpecV2_Ref: - return loadScriptContent(source.Ref, basePath) + return loadScriptContent(source.Ref, basePath, client) case *v1.PolicySpecV2_Path: // Deprecated: kept for backward compatibility - return loadScriptContent(source.Path, basePath) + return loadScriptContent(source.Path, basePath, client) default: return nil, fmt.Errorf("policy spec is empty") } } -func loadLegacyPolicyScript(spec *v1.PolicySpec, basePath string) ([]byte, error) { +func loadLegacyPolicyScript(spec *v1.PolicySpec, basePath string, client *http.Client) ([]byte, error) { switch source := spec.GetSource().(type) { case *v1.PolicySpec_Embedded: return decodeIfBase64Wasm([]byte(source.Embedded)), nil case *v1.PolicySpec_Path: - return loadScriptContent(source.Path, basePath) + return loadScriptContent(source.Path, basePath, client) default: return nil, fmt.Errorf("policy spec is empty") } diff --git a/pkg/policies/policy_groups.go b/pkg/policies/policy_groups.go index 8e74984b9..4520e388f 100644 --- a/pkg/policies/policy_groups.go +++ b/pkg/policies/policy_groups.go @@ -19,12 +19,14 @@ import ( "context" "errors" "fmt" + "net/http" "slices" v13 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/cache" + "github.com/chainloop-dev/chainloop/pkg/netguard" "github.com/chainloop-dev/chainloop/pkg/templates" intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" @@ -60,6 +62,7 @@ func (pgv *PolicyGroupVerifier) VerifyMaterial(ctx context.Context, material *ap Client: pgv.client, Logger: pgv.logger, GroupCache: pgv.groupCache, + HTTPClient: pgv.httpClient, }) if err != nil { return nil, NewPolicyError(err) @@ -150,8 +153,14 @@ func (pgv *PolicyGroupVerifier) VerifyStatement(ctx context.Context, statement * Client: pgv.client, Logger: pgv.logger, GroupCache: pgv.groupCache, + HTTPClient: pgv.httpClient, }) if err != nil { + // A group refused by the network guard is not a schema issue, and + // skipping it would let the evaluation pass without the group + if errors.Is(err, netguard.ErrBlockedTarget) { + return nil, NewPolicyError(err) + } // Temporarily skip if policy groups still use old schema // TODO: remove this check in next release pgv.logger.Warn().Msgf("policy group '%s' skipped since it's not found or it might use an old schema version", groupAtt.GetRef()) @@ -238,6 +247,9 @@ type LoadPolicyGroupOptions struct { Client v13.AttestationServiceClient Logger *zerolog.Logger GroupCache cache.Cache[*groupWithReference] + // HTTPClient fetches groups referenced by http(s) URLs. Defaults to + // http.DefaultClient. + HTTPClient *http.Client } // LoadPolicyGroup loads a group (unmarshalls it) from a group attachment @@ -281,7 +293,7 @@ func getGroupLoader(attachment *v1.PolicyGroupAttachment, opts *LoadPolicyGroupO case fileScheme: loader = new(FileGroupLoader) case httpsScheme, httpScheme: - loader = new(HTTPSGroupLoader) + loader = &HTTPSGroupLoader{Client: opts.HTTPClient} default: return nil, fmt.Errorf("policy scheme not supported: %q", scheme) } diff --git a/pkg/policies/public_targets_test.go b/pkg/policies/public_targets_test.go new file mode 100644 index 000000000..3b8b826b8 --- /dev/null +++ b/pkg/policies/public_targets_test.go @@ -0,0 +1,266 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policies + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sync/atomic" + + v12 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/netguard" +) + +// countingServer serves the given files and counts the requests that reach it. +// httptest listens on the loopback interface, so it stands in for any +// destination that is not publicly routable. +func (s *testSuite) countingServer(newServer func(http.Handler) *httptest.Server, files map[string]string) (*httptest.Server, *atomic.Int32) { + var requests atomic.Int32 + server := newServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + content, ok := files[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(content)) + })) + s.T().Cleanup(server.Close) + + return server, &requests +} + +func (s *testSuite) readTestdata(path string) string { + content, err := os.ReadFile(path) + s.Require().NoError(err) + return string(content) +} + +func (s *testSuite) TestPublicTargetsOnlyPolicyHTTPRequests() { + server, requests := s.countingServer(httptest.NewServer, nil) + serverURL, err := url.Parse(server.URL) + s.Require().NoError(err) + + policy := &v12.Policy{ + ApiVersion: "chainloop.dev/v1", + Kind: "Policy", + Metadata: &v12.Metadata{Name: "http-send"}, + Spec: &v12.PolicySpec{Policies: []*v12.PolicySpecV2{{ + Kind: v12.CraftingSchema_Material_ATTESTATION, + Source: &v12.PolicySpecV2_Embedded{Embedded: fmt.Sprintf(`package main + +import rego.v1 + +result := {"skipped": false, "violations": violations} + +violations contains "request sent" if { + http.send({"method": "GET", "url": %q}) +} +`, server.URL)}, + }}}, + } + policies := &v12.Policies{ + Attestation: []*v12.PolicyAttachment{{Policy: &v12.PolicyAttachment_Embedded{Embedded: policy}}}, + } + + cases := []struct { + name string + publicOnly bool + }{ + {name: "http.send reaches a private target by default"}, + {name: "http.send to a private target is blocked", publicOnly: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + // OPA matches the allowed host without its port + opts := []PolicyVerifierOption{WithAllowedHostnames(serverURL.Hostname())} + if tc.publicOnly { + opts = append(opts, WithPublicTargetsOnly()) + } + requests.Store(0) + + verifier := NewPolicyVerifier(policies, nil, &s.logger, opts...) + _, err := verifier.VerifyStatement(context.TODO(), loadStatement("testdata/statement.json", &s.Suite)) + + if tc.publicOnly { + s.ErrorContains(err, netguard.ErrBlockedTarget.Error()) + s.Zero(requests.Load(), "a private target must not be reached") + return + } + + s.NoError(err) + s.NotZero(requests.Load()) + }) + } +} + +func (s *testSuite) TestPublicTargetsOnlyRemoteLoaders() { + server, requests := s.countingServer(httptest.NewServer, map[string]string{ + "/policy.yaml": s.readTestdata("testdata/workflow_embedded.yaml"), + "/group.yaml": s.readTestdata("testdata/policy_group.yaml"), + }) + + cases := []struct { + name string + publicOnly bool + }{ + {name: "loaders reach a private target by default"}, + {name: "loading from a private target is blocked", publicOnly: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + var opts []PolicyVerifierOption + if tc.publicOnly { + opts = append(opts, WithPublicTargetsOnly()) + } + pgv := NewPolicyGroupVerifier(nil, nil, nil, &s.logger, opts...) + + loaders := map[string]func() error{ + "policy spec": func() error { + _, _, err := pgv.loadPolicySpec(context.TODO(), &v12.PolicyAttachment{ + Policy: &v12.PolicyAttachment_Ref{Ref: server.URL + "/policy.yaml"}, + }) + return err + }, + "policy group": func() error { + _, _, err := LoadPolicyGroup(context.TODO(), &v12.PolicyGroupAttachment{Ref: server.URL + "/group.yaml"}, + &LoadPolicyGroupOptions{Logger: &s.logger, HTTPClient: pgv.httpClient}) + return err + }, + } + + for name, load := range loaders { + requests.Store(0) + err := load() + + if tc.publicOnly { + s.ErrorIs(err, netguard.ErrBlockedTarget, name) + s.Zero(requests.Load(), "%s: a private target must not be reached", name) + continue + } + + s.NoError(err, name) + s.Equal(int32(1), requests.Load(), name) + } + }) + } +} + +// A group that cannot be fetched because its destination is blocked must fail +// the evaluation rather than be skipped, so the option fails closed. +func (s *testSuite) TestPublicTargetsOnlyGroupStatementFailsClosed() { + server, requests := s.countingServer(httptest.NewServer, map[string]string{ + "/group.yaml": s.readTestdata("testdata/policy_group.yaml"), + }) + groups := []*v12.PolicyGroupAttachment{{Ref: server.URL + "/group.yaml"}} + + cases := []struct { + name string + publicOnly bool + }{ + {name: "group statement evaluation reaches a private target by default"}, + {name: "group statement evaluation fails on a blocked target", publicOnly: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + var opts []PolicyVerifierOption + if tc.publicOnly { + opts = append(opts, WithPublicTargetsOnly()) + } + requests.Store(0) + + pgv := NewPolicyGroupVerifier(groups, nil, nil, &s.logger, opts...) + _, err := pgv.VerifyStatement(context.TODO(), loadStatement("testdata/statement.json", &s.Suite)) + + if tc.publicOnly { + s.ErrorIs(err, netguard.ErrBlockedTarget) + s.Zero(requests.Load(), "a private target must not be reached") + return + } + + s.NoError(err) + s.Equal(int32(1), requests.Load()) + }) + } +} + +// Remote references can carry credentials in their query string, such as +// signed URLs, so fetch errors must not echo them. +func (s *testSuite) TestFetchRemoteErrorsOmitURL() { + const secret = "s3cr3t-token" + server, _ := s.countingServer(httptest.NewServer, nil) + ref := server.URL + "/missing.yaml?token=" + secret + + cases := []struct { + name string + client *http.Client + }{ + {name: "non-2xx response"}, + {name: "blocked destination", client: netguard.NewHTTPClient(0)}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + _, err := fetchRemote(tc.client, ref) + s.Require().Error(err) + s.NotContains(err.Error(), secret) + }) + } +} + +func (s *testSuite) TestPublicTargetsOnlyScriptFetch() { + server, requests := s.countingServer(httptest.NewTLSServer, map[string]string{ + "/policy.rego": "package main", + }) + + cases := []struct { + name string + publicOnly bool + }{ + {name: "script fetch reaches a private target by default"}, + {name: "script fetch from a private target is blocked", publicOnly: true}, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + // The test server's own client trusts its certificate. The guarded + // client is taken from a verifier to cover how it is wired there. + client := server.Client() + if tc.publicOnly { + client = NewPolicyVerifier(nil, nil, &s.logger, WithPublicTargetsOnly()).httpClient + } + + requests.Store(0) + content, err := loadScriptContent(server.URL+"/policy.rego", "", client) + + if tc.publicOnly { + s.ErrorIs(err, netguard.ErrBlockedTarget) + s.Zero(requests.Load(), "a private target must not be reached") + return + } + + s.NoError(err) + s.Equal("package main", string(content)) + }) + } +}