VYPR
Medium severity4.9NVD Advisory· Published Mar 20, 2026· Updated Apr 29, 2026

CVE-2026-32828

CVE-2026-32828

Description

Kargo manages and automates the promotion of software artifacts. In versions 1.4.0 through 1.6.3, 1.7.0-rc.1 through 1.7.8, 1.8.0-rc.1 through 1.8.11, and 1.9.0-rc.1 through 1.9.4, the http and http-download promotion steps allow Server-Side Request Forgery (SSRF) against link-local addresses, most critically the cloud instance metadata endpoint (169.254.169.254), enabling exfiltration of sensitive data such as IAM credentials. These steps provide full control over request headers and methods, rendering cloud provider header-based SSRF mitigations ineffective. An authenticated attacker with permissions to create/update Stages or craft Promotion resources can exploit this by submitting a malicious Promotion manifest, with response data retrievable via Promotion status fields, Git repositories, or a second http step. This issue has been fixed in versions 1.6.4, 1.7.9, 1.8.12 and 1.9.5.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/akuity/kargoGo
>= 1.4.0, < 1.6.41.6.4
github.com/akuity/kargoGo
>= 1.7.0-rc.1, < 1.7.91.7.9
github.com/akuity/kargoGo
>= 1.8.0-rc.1, < 1.8.121.8.12
github.com/akuity/kargoGo
>= 1.9.0-rc.1, < 1.9.51.9.5

Affected products

1
  • cpe:2.3:a:akuity:kargo:*:*:*:*:*:kubernetes:*:*
    Range: >=1.4.0,<1.6.4

Patches

1
fd25620c2473

Merge commit from fork

https://github.com/akuity/kargoKent RancourtMar 2, 2026via ghsa
4 files changed · +178 2
  • pkg/net/safe_dialer.go+82 0 added
    @@ -0,0 +1,82 @@
    +package net
    +
    +import (
    +	"context"
    +	"fmt"
    +	"net"
    +	"net/http"
    +	"time"
    +)
    +
    +var (
    +	linkLocalV4 = net.IPNet{
    +		IP:   net.IP{169, 254, 0, 0},
    +		Mask: net.CIDRMask(16, 32),
    +	}
    +	linkLocalV6 = net.IPNet{
    +		IP:   net.ParseIP("fe80::"),
    +		Mask: net.CIDRMask(10, 128),
    +	}
    +)
    +
    +// isLinkLocal returns true if the given IP is in the IPv4 link-local range
    +// (169.254.0.0/16) or the IPv6 link-local range (fe80::/10).
    +func isLinkLocal(ip net.IP) bool {
    +	return linkLocalV4.Contains(ip) || linkLocalV6.Contains(ip)
    +}
    +
    +// SafeDialContext returns a DialContext function that blocks connections to
    +// link-local IP addresses (169.254.0.0/16 and fe80::/10). This prevents SSRF
    +// attacks targeting cloud instance metadata endpoints (e.g. 169.254.169.254).
    +//
    +// The returned function resolves the hostname before connecting and rejects the
    +// connection if all resolved addresses are link-local.
    +func SafeDialContext(dialer *net.Dialer) func(
    +	ctx context.Context,
    +	network string,
    +	addr string,
    +) (net.Conn, error) {
    +	return func(ctx context.Context, network, addr string) (net.Conn, error) {
    +		host, port, err := net.SplitHostPort(addr)
    +		if err != nil {
    +			return nil, fmt.Errorf("failed to parse address %q: %w", addr, err)
    +		}
    +
    +		// Resolve the hostname to IP addresses.
    +		ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
    +		if err != nil {
    +			return nil, fmt.Errorf("failed to resolve host %q: %w", host, err)
    +		}
    +
    +		// Filter out link-local addresses.
    +		var safe []net.IPAddr
    +		for _, ip := range ips {
    +			if !isLinkLocal(ip.IP) {
    +				safe = append(safe, ip)
    +			}
    +		}
    +
    +		if len(safe) == 0 {
    +			return nil, fmt.Errorf(
    +				"connections to link-local addresses are not permitted "+
    +					"(host %q resolved to link-local IPs only)",
    +				host,
    +			)
    +		}
    +
    +		// Dial using the first safe address.
    +		safeAddr := net.JoinHostPort(safe[0].IP.String(), port)
    +		return dialer.DialContext(ctx, network, safeAddr)
    +	}
    +}
    +
    +// SafeTransport wraps the given transport's DialContext to block connections to
    +// link-local IP addresses.
    +func SafeTransport(t *http.Transport) *http.Transport {
    +	dialer := &net.Dialer{
    +		Timeout:   30 * time.Second,
    +		KeepAlive: 30 * time.Second,
    +	}
    +	t.DialContext = SafeDialContext(dialer)
    +	return t
    +}
    
  • pkg/net/safe_dialer_test.go+88 0 added
    @@ -0,0 +1,88 @@
    +package net
    +
    +import (
    +	"net"
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +)
    +
    +func Test_isLinkLocal(t *testing.T) {
    +	tests := []struct {
    +		name     string
    +		ip       string
    +		expected bool
    +	}{
    +		{
    +			name:     "IPv4 link-local lower bound",
    +			ip:       "169.254.0.0",
    +			expected: true,
    +		},
    +		{
    +			name:     "IPv4 link-local metadata endpoint",
    +			ip:       "169.254.169.254",
    +			expected: true,
    +		},
    +		{
    +			name:     "IPv4 link-local upper bound",
    +			ip:       "169.254.255.255",
    +			expected: true,
    +		},
    +		{
    +			name:     "IPv4 just below link-local range",
    +			ip:       "169.253.255.255",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv4 just above link-local range",
    +			ip:       "169.255.0.0",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv4 private 10.x",
    +			ip:       "10.0.0.1",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv4 public",
    +			ip:       "8.8.8.8",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv4 loopback",
    +			ip:       "127.0.0.1",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv6 link-local",
    +			ip:       "fe80::1",
    +			expected: true,
    +		},
    +		{
    +			name:     "IPv6 link-local upper bound",
    +			ip:       "febf::ffff",
    +			expected: true,
    +		},
    +		{
    +			name:     "IPv6 just outside link-local",
    +			ip:       "fec0::1",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv6 loopback",
    +			ip:       "::1",
    +			expected: false,
    +		},
    +		{
    +			name:     "IPv6 public",
    +			ip:       "2001:db8::1",
    +			expected: false,
    +		},
    +	}
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			ip := net.ParseIP(tt.ip)
    +			assert.Equal(t, tt.expected, isLinkLocal(ip))
    +		})
    +	}
    +}
    
  • pkg/promotion/runner/builtin/http_downloader.go+4 1 modified
    @@ -18,6 +18,7 @@ import (
     	kargoapi "github.com/akuity/kargo/api/v1alpha1"
     	"github.com/akuity/kargo/pkg/io/fs"
     	"github.com/akuity/kargo/pkg/logging"
    +	kargonet "github.com/akuity/kargo/pkg/net"
     	"github.com/akuity/kargo/pkg/promotion"
     	"github.com/akuity/kargo/pkg/x/promotion/runner/builtin"
     )
    @@ -156,6 +157,8 @@ func (d *httpDownloader) performHTTPRequest(cfg builtin.HTTPDownloadConfig) (*ht
     		return nil, fmt.Errorf("error creating HTTP client: %w", err)
     	}
     
    +	// #nosec G704 -- The client is using a custom dialer that mitigates the worst
    +	// practical risks of SSRF by refusing to dial link-local addresses.
     	resp, err := client.Do(req)
     	if err != nil {
     		return nil, fmt.Errorf("error sending HTTP request: %w", err)
    @@ -190,7 +193,7 @@ func (d *httpDownloader) buildRequest(cfg builtin.HTTPDownloadConfig) (*http.Req
     
     // buildHTTPClient creates an HTTP client with the specified configuration.
     func (d *httpDownloader) buildHTTPClient(cfg builtin.HTTPDownloadConfig) (*http.Client, error) {
    -	httpTransport := cleanhttp.DefaultTransport()
    +	httpTransport := kargonet.SafeTransport(cleanhttp.DefaultTransport())
     	if cfg.InsecureSkipTLSVerify {
     		httpTransport.TLSClientConfig = &tls.Config{
     			InsecureSkipVerify: true, // nolint: gosec
    
  • pkg/promotion/runner/builtin/http_requester.go+4 1 modified
    @@ -19,6 +19,7 @@ import (
     	kargoapi "github.com/akuity/kargo/api/v1alpha1"
     	"github.com/akuity/kargo/pkg/io"
     	"github.com/akuity/kargo/pkg/logging"
    +	kargonet "github.com/akuity/kargo/pkg/net"
     	"github.com/akuity/kargo/pkg/promotion"
     	"github.com/akuity/kargo/pkg/x/promotion/runner/builtin"
     )
    @@ -95,6 +96,8 @@ func (h *httpRequester) run(
     		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
     			&promotion.TerminalError{Err: fmt.Errorf("error creating HTTP client: %w", err)}
     	}
    +	// #nosec G704 -- The client is using a custom dialer that mitigates the worst
    +	// practical risks of SSRF by refusing to dial link-local addresses.
     	resp, err := client.Do(req)
     	if err != nil {
     		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
    @@ -247,7 +250,7 @@ func (h *httpRequester) buildRequest(cfg builtin.HTTPConfig) (*http.Request, err
     }
     
     func (h *httpRequester) getClient(cfg builtin.HTTPConfig) (*http.Client, error) {
    -	httpTransport := cleanhttp.DefaultTransport()
    +	httpTransport := kargonet.SafeTransport(cleanhttp.DefaultTransport())
     	if cfg.InsecureSkipTLSVerify {
     		httpTransport.TLSClientConfig = &tls.Config{
     			InsecureSkipVerify: true, // nolint: gosec
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.