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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/akuity/kargoGo | >= 1.4.0, < 1.6.4 | 1.6.4 |
github.com/akuity/kargoGo | >= 1.7.0-rc.1, < 1.7.9 | 1.7.9 |
github.com/akuity/kargoGo | >= 1.8.0-rc.1, < 1.8.12 | 1.8.12 |
github.com/akuity/kargoGo | >= 1.9.0-rc.1, < 1.9.5 | 1.9.5 |
Affected products
1Patches
14 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- github.com/akuity/kargo/commit/fd25620c2473ed19bec4be4d0f181287ef0f0391nvdPatchWEB
- github.com/advisories/GHSA-j94x-8wcp-x7hmghsaADVISORY
- github.com/akuity/kargo/security/advisories/GHSA-j94x-8wcp-x7hmnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32828ghsaADVISORY
News mentions
0No linked articles in our index yet.