diff --git a/go.mod b/go.mod index f54f43542..1ab9f4a0c 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( require ( dario.cat/mergo v1.0.2 - github.com/argoproj/argo-cd/v3 v3.3.10 + github.com/argoproj/argo-cd/v3 v3.3.15 sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250308055145-5fe7bb3edc86 sigs.k8s.io/controller-tools v0.16.4 sigs.k8s.io/yaml v1.6.0 diff --git a/go.sum b/go.sum index 47c478a65..525cf4b7b 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/argoproj-labs/argocd-operator v0.17.0 h1:hsCjIT8F6bZhrzq3hHNjQxv7fesTh5TAWMgBsPjHFYQ= github.com/argoproj-labs/argocd-operator v0.17.0/go.mod h1:NQ382HBjCxWnDAvg/4nn2SXZ+RHPFhH96rglup3Wi6Y= -github.com/argoproj/argo-cd/v3 v3.3.10 h1:3QE36lhN/0ntjwl2TH13xL23w2J2AaXgC0Dxn0469h4= -github.com/argoproj/argo-cd/v3 v3.3.10/go.mod h1:LdPjAnIFbx0khaB6vLsOKrGoSWeJ8kzOt//jF1ltCPg= +github.com/argoproj/argo-cd/v3 v3.3.15 h1:I0OdhjsNnoG1qI5iatqtaTQUxWsSxPklo0z8yO2JRGA= +github.com/argoproj/argo-cd/v3 v3.3.15/go.mod h1:EY6M6lLPcGMwIKYlVyLdb+fSPlxo6dlGIJ7WsxaZxrg= github.com/argoproj/gitops-engine v0.7.1-0.20250908182407-97ad5b59a627 h1:yntvA+uaFz62HRfWGGwlvs4ErdxoLQjCpDXufdEt2FI= github.com/argoproj/gitops-engine v0.7.1-0.20250908182407-97ad5b59a627/go.mod h1:yJ3t/GRn9Gx2LEyMrh9X0roL7zzVlk3nvuJt6G1o6jI= github.com/argoproj/pkg v0.13.7-0.20250305113207-cbc37dc61de5 h1:YBoLSjpoaJXaXAldVvBRKJuOPvIXz9UOv6S96gMJM/Q= diff --git a/vendor/github.com/argoproj/argo-cd/v3/util/git/client.go b/vendor/github.com/argoproj/argo-cd/v3/util/git/client.go index b7284350d..28745a7f5 100644 --- a/vendor/github.com/argoproj/argo-cd/v3/util/git/client.go +++ b/vendor/github.com/argoproj/argo-cd/v3/util/git/client.go @@ -1192,13 +1192,7 @@ func (m *nativeGitClient) AddAndPushNote(sha string, namespace string, note stri log.Debugf("AddAndPushNote push failed (attempt %d): %v", attempt, err) // Check if this is a retryable error - errStr := err.Error() - isRetryable := strings.Contains(errStr, "fetch first") || // Remote updated after our fetch (concurrent push completed between our fetch and push) - strings.Contains(errStr, "reference already exists") || // Concurrent push is holding the lock (git server-side lock) - strings.Contains(errStr, "incorrect old value") || // Git detected our local ref is stale (concurrent update) - strings.Contains(errStr, "failed to update ref") // Generic ref update failure that may include transient issues - - if !isRetryable { + if !isRetryableNotePushError(err.Error()) { return struct{}{}, backoff.Permanent(fmt.Errorf("failed to push note: %w", err)) } @@ -1215,6 +1209,20 @@ func (m *nativeGitClient) AddAndPushNote(sha string, namespace string, note stri return nil } +// isRetryableNotePushError reports whether a failed git notes push is caused by a +// concurrent update to the same notes ref and is therefore safe to retry after +// re-fetching the remote ref. Multiple application controller shards can hydrate +// and push to the same refs/notes ref at once, and git reports the resulting +// collision through several different messages depending on the git version and +// server, including "cannot lock ref" when the server already holds the ref lock. +func isRetryableNotePushError(errStr string) bool { + return strings.Contains(errStr, "fetch first") || // Remote updated after our fetch (concurrent push completed between our fetch and push) + strings.Contains(errStr, "reference already exists") || // Concurrent push is holding the lock (git server-side lock) + strings.Contains(errStr, "incorrect old value") || // Git detected our local ref is stale (concurrent update) + strings.Contains(errStr, "failed to update ref") || // Generic ref update failure that may include transient issues + strings.Contains(errStr, "cannot lock ref") // Server could not lock the notes ref because a concurrent push from another shard holds it +} + // HasFileChanged returns the outout of git diff considering whether it is tracked or un-tracked func (m *nativeGitClient) HasFileChanged(filePath string) (bool, error) { // Step 1: Is it UNTRACKED? (file is new to git) diff --git a/vendor/github.com/argoproj/argo-cd/v3/util/git/creds.go b/vendor/github.com/argoproj/argo-cd/v3/util/git/creds.go index 733a9b1ed..ae297519c 100644 --- a/vendor/github.com/argoproj/argo-cd/v3/util/git/creds.go +++ b/vendor/github.com/argoproj/argo-cd/v3/util/git/creds.go @@ -13,6 +13,8 @@ import ( "net/http" "net/url" "os" + "regexp" + "slices" "strconv" "strings" "sync" @@ -50,6 +52,9 @@ var ( // installationIdCache caches installation IDs for organizations to avoid redundant API calls. githubInstallationIdCache *gocache.Cache githubInstallationIdCacheMutex sync.RWMutex // For bulk API call coordination + + // validProxyHostRegexp matches only safe hostname characters: alphanumeric, hyphen, dot, and brackets (for IPv6). + validProxyHostRegexp = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) ) const ( @@ -76,6 +81,57 @@ func init() { githubInstallationIdCache = gocache.New(60*time.Minute, 60*time.Minute) } +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 +} + type NoopCredsStore struct{} func (d NoopCredsStore) Add(_ string, _ string) string { @@ -225,7 +281,7 @@ func (creds HTTPSCreds) Environ() (io.Closer, []string, error) { defer keyFile.Close() // We should have both temp files by now - httpCloser = authFilePaths([]string{certFile.Name(), keyFile.Name()}) + httpCloser = []string{certFile.Name(), keyFile.Name()} _, err = certFile.WriteString(creds.clientCertData) if err != nil { @@ -283,6 +339,15 @@ type SSHCreds struct { } func NewSSHCreds(sshPrivateKey string, caPath string, insecureIgnoreHostKey bool, proxy string) SSHCreds { + if proxy != "" { + if err := validateProxyURL(proxy); err != nil { + log.WithFields(log.Fields{ + common.SecurityField: common.SecurityHigh, + common.SecurityCWEField: 78, + }).Warnf("NewSSHCreds: rejecting unsafe proxy URL: %v", err) + proxy = "" + } + } return SSHCreds{sshPrivateKey, caPath, insecureIgnoreHostKey, proxy} } @@ -353,17 +418,56 @@ func (c SSHCreds) Environ() (io.Closer, []string, error) { knownHostsFile := certutil.GetSSHKnownHostsDataPath() args = append(args, "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile="+knownHostsFile) } + // Handle SSH socks5 proxy settings - proxyEnv := []string{} + var proxyEnv []string + var proxyScriptPath string + if c.proxy != "" { + if err := validateProxyURL(c.proxy); err != nil { + sshCloser.Close() + return nil, nil, fmt.Errorf("unsafe proxy URL rejected: %w", err) + } + parsedProxyURL, err := url.Parse(c.proxy) if err != nil { sshCloser.Close() return nil, nil, fmt.Errorf("failed to set environment variables related to socks5 proxy, could not parse proxy URL '%s': %w", c.proxy, err) } - args = append(args, "-o", fmt.Sprintf("ProxyCommand='connect-proxy -S %s:%s -5 %%h %%p'", + + // Write a temporary wrapper script instead of embedding the hostname and port directly into the + // shell-interpreted GIT_SSH_COMMAND string. + proxyScriptContent := fmt.Sprintf( + "#!/bin/sh\nexec connect-proxy -5 -S %s:%s \"$@\"\n", parsedProxyURL.Hostname(), - parsedProxyURL.Port())) + parsedProxyURL.Port(), + ) + + proxyScript, err := os.CreateTemp(argoio.TempDir, "argocd-proxy-cmd-*") + if err != nil { + sshCloser.Close() + return nil, nil, fmt.Errorf("failed to create proxy command script: %w", err) + } + proxyScriptPath = proxyScript.Name() + + if _, err := proxyScript.WriteString(proxyScriptContent); err != nil { + _ = os.Remove(proxyScriptPath) + sshCloser.Close() + return nil, nil, fmt.Errorf("failed to write proxy command script: %w", err) + } + if err := proxyScript.Close(); err != nil { + _ = os.Remove(proxyScriptPath) + sshCloser.Close() + return nil, nil, fmt.Errorf("failed to close proxy command script: %w", err) + } + if err := os.Chmod(proxyScriptPath, 0o700); err != nil { + _ = os.Remove(proxyScriptPath) + sshCloser.Close() + return nil, nil, fmt.Errorf("failed to chmod proxy command script: %w", err) + } + + args = append(args, "-o", "ProxyCommand="+proxyScriptPath+" %h %p") + if parsedProxyURL.User != nil { proxyEnv = append(proxyEnv, "SOCKS5_USER="+parsedProxyURL.User.Username()) if socks5Passwd, isPasswdSet := parsedProxyURL.User.Password(); isPasswdSet { @@ -371,9 +475,28 @@ func (c SSHCreds) Environ() (io.Closer, []string, error) { } } } + env = append(env, []string{"GIT_SSH_COMMAND=" + strings.Join(args, " ")}...) env = append(env, proxyEnv...) - return sshCloser, env, nil + + // We need a combined closer that removes both the SSH private key tempfile + // and the proxy script tempfile (if one was created). + combinedCloser := utilio.NewCloser(func() error { + keyErr := sshCloser.Close() + var proxyErr error + if proxyScriptPath != "" { + proxyErr = os.Remove(proxyScriptPath) + if os.IsNotExist(proxyErr) { + proxyErr = nil + } + } + if keyErr != nil { + return keyErr + } + return proxyErr + }) + + return combinedCloser, env, nil } // GitHubAppCreds to authenticate as GitHub application @@ -391,7 +514,17 @@ type GitHubAppCreds struct { } // NewGitHubAppCreds provide github app credentials +// repoURL is required for automatic installation ID discovery when appInstallId is 0 func NewGitHubAppCreds(appID int64, appInstallId int64, privateKey string, baseURL string, clientCertData string, clientCertKey string, insecure bool, proxy string, noProxy string, store CredsStore) GenericHTTPSCreds { + if proxy != "" { + if err := ValidateHTTPProxyURL(proxy); err != nil { + log.WithFields(log.Fields{ + common.SecurityField: common.SecurityHigh, + common.SecurityCWEField: 78, + }).Warnf("NewGitHubAppCreds: rejecting unsafe proxy URL: %v", err) + proxy = "" + } + } return GitHubAppCreds{appID: appID, appInstallId: appInstallId, privateKey: privateKey, baseURL: baseURL, clientCertData: clientCertData, clientCertKey: clientCertKey, insecure: insecure, proxy: proxy, noProxy: noProxy, store: store} } @@ -434,7 +567,7 @@ func (g GitHubAppCreds) Environ() (io.Closer, []string, error) { defer keyFile.Close() // We should have both temp files by now - httpCloser = authFilePaths([]string{certFile.Name(), keyFile.Name()}) + httpCloser = []string{certFile.Name(), keyFile.Name()} _, err = certFile.WriteString(g.clientCertData) if err != nil { diff --git a/vendor/github.com/argoproj/argo-cd/v3/util/helm/cmd.go b/vendor/github.com/argoproj/argo-cd/v3/util/helm/cmd.go index 7a1e9f03c..fa9afbe8a 100644 --- a/vendor/github.com/argoproj/argo-cd/v3/util/helm/cmd.go +++ b/vendor/github.com/argoproj/argo-cd/v3/util/helm/cmd.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/url" "os" "os/exec" @@ -58,9 +59,14 @@ var redactor = func(text string) string { } func (c Cmd) run(ctx context.Context, args ...string) (string, string, error) { + return c.runWithStdin(ctx, nil, args...) +} + +func (c Cmd) runWithStdin(ctx context.Context, stdin io.Reader, args ...string) (string, string, error) { cmd := exec.CommandContext(ctx, "helm", args...) cmd.Dir = c.WorkDir cmd.Env = os.Environ() + cmd.Stdin = stdin if !c.IsLocal { cmd.Env = append(cmd.Env, fmt.Sprintf("XDG_CACHE_HOME=%s/cache", c.helmHome), @@ -96,7 +102,7 @@ func (c *Cmd) RegistryLogin(repo string, creds Creds) (string, error) { return "", fmt.Errorf("failed to get password for helm registry: %w", err) } if helmPassword != "" { - args = append(args, "--password", helmPassword) + args = append(args, "--password-stdin") } if creds.GetCAPath() != "" { @@ -124,7 +130,11 @@ func (c *Cmd) RegistryLogin(repo string, creds Creds) (string, error) { if creds.GetInsecureSkipVerify() { args = append(args, "--insecure") } - out, _, err := c.run(context.Background(), args...) + var stdin io.Reader + if helmPassword != "" { + stdin = strings.NewReader(helmPassword) + } + out, _, err := c.runWithStdin(context.Background(), stdin, args...) if err != nil { return "", fmt.Errorf("failed to login to registry: %w", err) } diff --git a/vendor/github.com/argoproj/argo-cd/v3/util/io/files/util.go b/vendor/github.com/argoproj/argo-cd/v3/util/io/files/util.go index 681e84bc5..e5ae0348e 100644 --- a/vendor/github.com/argoproj/argo-cd/v3/util/io/files/util.go +++ b/vendor/github.com/argoproj/argo-cd/v3/util/io/files/util.go @@ -92,3 +92,18 @@ func Inbound(candidate, baseDir string) bool { } return strings.HasPrefix(target, filepath.Clean(baseDir)+string(os.PathSeparator)) } + +// ResolveInbound resolves symlinks in candidate and reports whether the +// resolved path is inside baseDir, per Inbound's rules. err is non-nil only +// if the symlinks in candidate could not be resolved (e.g. candidate doesn't +// exist); ok is false if resolution succeeded but the resolved path is +// outside baseDir. Callers are responsible for producing their own error +// message when ok is false, so as not to leak absolute filesystem paths in +// messages that may reach an end user. +func ResolveInbound(candidate, baseDir string) (resolved string, ok bool, err error) { + resolved, err = filepath.EvalSymlinks(candidate) + if err != nil { + return "", false, err + } + return resolved, Inbound(resolved, baseDir), nil +} diff --git a/vendor/github.com/argoproj/argo-cd/v3/util/oci/client.go b/vendor/github.com/argoproj/argo-cd/v3/util/oci/client.go index 7dc65805f..b0f4c4b7e 100644 --- a/vendor/github.com/argoproj/argo-cd/v3/util/oci/client.go +++ b/vendor/github.com/argoproj/argo-cd/v3/util/oci/client.go @@ -44,6 +44,12 @@ var ( indexLock = sync.NewKeyLock() ) +// maxOCIManifestSize bounds how many bytes we read when decoding an OCI manifest +// returned by a registry. This matches oras-go's MaxMetadataBytes default (4 MiB) +// and protects the repo-server from unbounded memory consumption if a registry +// returns an excessively large manifest. +const maxOCIManifestSize = 4 * 1024 * 1024 // 4 MiB + const ( helmOCIConfigType = "application/vnd.cncf.helm.config.v1+json" helmOCILayerType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" @@ -696,7 +702,10 @@ func getOCIManifest(ctx context.Context, digest string, repo oras.ReadOnlyTarget defer rc.Close() manifest := imagev1.Manifest{} - decoder := json.NewDecoder(rc) + // Limit how much we read while decoding the manifest to protect against a + // malicious or misbehaving registry returning an arbitrarily large response, + // which would otherwise be buffered into memory and could exhaust the repo-server. + decoder := json.NewDecoder(io.LimitReader(rc, maxOCIManifestSize)) if err = decoder.Decode(&manifest); err != nil { return nil, fmt.Errorf("error decoding oci manifest for digest %s: %w", digest, err) } diff --git a/vendor/modules.txt b/vendor/modules.txt index f16a9b100..20fe39289 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -130,7 +130,7 @@ github.com/ProtonMail/go-crypto/openpgp/x448 ## explicit; go 1.24.6 github.com/argoproj-labs/argocd-operator/api/v1beta1 github.com/argoproj-labs/argocd-operator/common -# github.com/argoproj/argo-cd/v3 v3.3.10 +# github.com/argoproj/argo-cd/v3 v3.3.15 ## explicit; go 1.25.5 github.com/argoproj/argo-cd/v3/assets github.com/argoproj/argo-cd/v3/common