Darwin: Git LFS Improperly Adds Certificates That Cause SSL Validation To Fail

by ADMIN 79 views

Describe the Bug

In our organization (example.org), we use the Jamf endpoint management system, which adds a self-signed SSL certificate with the CN:

CN=example.org JSS Built-in Certificate Authority

This causes SSL validation to fail when accessing Git LFS on example.org:

Post "https://example.org/group/project/info/lfs/locks/verify": x509: certificate signed by unknown authority

This issue arises due to the following reasons:

  1. When accessing example.org endpoints, Git LFS checks if there are any custom certificates to trust for that host.
  2. It calls appendRootCAsForHostFromKeychain with the hostname example.org.
  3. This function executes /usr/bin/security find-certificate -a -p -c example.org <keychain>, which performs a substring match on the Common Name.
  4. If the System keychain contains a certificate with CN example.org JSS Built-in Certificate Authority, this gets returned by the fuzzy match.
  5. Git LFS then adds this CA certificate to the trust store for connections to example.org.
  6. When connecting to the real example.org, Git LFS is now trying to validate the official TLS certificate against this self-signed JAMF CA certificate, which will always fail.

To Reproduce

To reproduce this behavior, follow these steps:

  1. Install a self-signed cert that has a matching Git LFS server hostname in the CN.
  2. Attempt to pull or push LFS files.

System Environment

macOS

Output of git lfs env

git-lfs/3.6.1 (GitHub; darwin arm64; go 1.23.4)
git version 2.49.0

Endpoint=https://github.com/git-lfs/git-lfs.git/info/lfs (auth=none)
LocalWorkingDir=/Users/stanhu/github/git-lfs
LocalGitDir=/Users/stanhu/github/git-lfs/.git
LocalGitStorageDir=/Users/stanhu/github/git-lfs/.git
LocalMediaDir=/Users/stanhu/github/git-lfs/.git/lfs/objects
LocalReferenceDirs=
TempDir=/Users/stanhu/github/git-lfs/.git/lfs/tmp
ConcurrentTransfers=8
TusTransfers=false
BasicTransfersOnly=false
SkipDownloadErrors=false
FetchRecentAlways=false
FetchRecentRefsDays=7
FetchRecentCommitsDays=0
FetchRecentRefsIncludeRemotes=true
PruneOffsetDays=3
PruneVerifyRemoteAlways=false
PruneVerifyUnreachableAlways=false
PruneRemoteName=origin
LfsStorageDir=/Users/stanhu/github/git-lfs/.git/lfs
AccessDownload=none
AccessUpload=none
DownloadTransfers=basic,lfs-standalone-file,ssh
UploadTransfers=basic,lfs-standalone-file,ssh
GIT_APPEND_BUILD_OPTIONS=LIBPCREDIR=/opt/homebrew/opt/pcre2
GIT_EXEC_PATH=/opt/homebrew/opt/git/libexec/git-core
git config filter.lfs.process = "git-lfs filter-process"
git config filter.lfs.smudge = "git-lfs smudge -- %f"
git config.lfs.clean = "git-lfs clean -- %f"

Additional Context

It is not possible to make security find-certificate return a strict match, but we can make the Git LFS code perform filtering. The following code snippet demonstrates how to achieve this:

diff --git a/lfshttp/certs_darwin.go b/lfshttp/certs_darwin.go
index 232e9871..547ff4f1 100644
--- a/lfshttp/certs_darwin.go
+++ b/lfshttp/certs_darwin.go
@@ -1,7 +1,9 @@
 package lfshttp
 
 import (
+	"bytes"
 	"crypto/x509"
+	"encoding/pem"
 	"regexp"
 	"strings"
 
@@ -57,8 +59,8 @@ func appendRootCAsForHostFromPlatform(pool *x509.CertPool, host string) *x509.Ce
 	return pool
 }
 
-func appendRootCAsFromKeychain(pool *x509.CertPool, name, keychain string) *x509.CertPool {
-	cmd, err := subprocess.ExecCommand("/usr/bin/security", "find-certificate", "-a", "-p", "-c", name, keychain)
+func appendRootCAsFromKeychain(pool *x509.CertPool, hostname, keychain string) *x509.CertPool {
+	cmd, err := subprocess.ExecCommand("/usr/bin/security", "find-certificate", "-a", "-p", "-c", hostname, keychain)
 	if err != nil {
 		tracerx.Printf("Error getting command to read keychain %q: %v", keychain, err)
 		return pool
@@ -68,5 +70,39 @@ func appendRootCAsFromKeychain(pool *x509.CertPool, name, keychain string) *x509
 		tracerx.Printf("Error reading keychain %q: %v", keychain, err)
 		return pool
 	}
-	return appendCertsFromPEMData(pool, data)
+
+	// Parse PEM data into individual certificates
+	pemBlocks := []byte(data)
+	var validCerts [][]byte
+
+	for len(pemBlocks) > 0 {
+		var block *pem.Block
+		block, pemBlocks = pem.Decode(pemBlocks)
+		if block == nil {
+			break
+		}
+		if block.Type != "CERTIFICATE" {
+			continue
+		}
+
+		cert, err := x509.ParseCertificate(block.Bytes)
+		if err != nil {
+			continue
+		}
+
+		// Only include certificates with an exact CN match
+		if strings.ToLower(cert.Subject.CommonName) == strings.ToLower(hostname) {
+			validCerts = append(validCerts, pem.EncodeToMemory(block))
+		} else {
+			tracerx.Printf("Skipping certificate with non-exact CN match: %s", cert.Subject.CommonName)
+		}
+	}
+
+	// Add only the filtered certificates
+	if len(validCerts) > 0 {
+		allCerts := bytes.Join(validCerts, []byte{})
+		appendCertsFromPEMData(pool, allCerts)
+	}
+
+	return pool
 }

Q&A

Q: What is the issue with Git LFS on macOS?

A: The issue arises when Git LFS attempts to access a self-signed SSL certificate with a matching hostname in the Common Name (CN) field. This causes SSL validation to fail, resulting in errors when pulling or pushing LFS files.

Q: What is the root cause of this issue?

A: The root cause lies in the appendRootCAsForHostFromKeychain function, which performs a substring match on the Common Name field. This allows the self-signed JAMF CA certificate to be added to the trust store, causing SSL validation to fail.

Q: How can I reproduce this issue?

A: To reproduce this issue, follow these steps:

  1. Install a self-signed cert that has a matching Git LFS server hostname in the CN.
  2. Attempt to pull or push LFS files.

Q: What is the output of git lfs env?

A: The output of git lfs env will display the Git LFS environment variables, including the endpoint, local working directory, and local Git directory.

Q: How can I modify the Git LFS code to filter certificates?

A: You can modify the appendRootCAsFromKeychain function to filter certificates based on an exact CN match. The modified code will parse the PEM data into individual certificates and only include those with an exact CN match.

Q: What is the modified code for filtering certificates?

A: The modified code is as follows:

diff --git a/lfshttp/certs_darwin.go b/lfshttp/certs_darwin.go
index 232e9871..547ff4f1 100644
--- a/lfshttp/certs_darwin.go
+++ b/lfshttp/certs_darwin.go
@@ -1,7 +1,9 @@
 package lfshttp
 
 import (
+	"bytes"
 	"crypto/x509"
+	"encoding/pem"
 	"regexp"
 	"strings"
 
@@ -57,8 +59,8 @@ func appendRootCAsForHostFromPlatform(pool *x509.CertPool, host string) *x509.Ce
 	return pool
 }
 
-func appendRootCAsFromKeychain(pool *x509.CertPool, name, keychain string) *x509.CertPool {
-	cmd, err := subprocess.ExecCommand("/usr/bin/security", "find-certificate", "-a", "-p", "-c", name, keychain)
+func appendRootCAsFromKeychain(pool *x509.CertPool, hostname, keychain string) *x509.CertPool {
+	cmd, err := subprocess.ExecCommand("/usr/bin/security", "find-certificate", "-a", "-p", "-c", hostname, keychain)
 	if err != nil {
 		tracerx.Printf("Error getting command to read keychain %q: %v", keychain, err)
 		return pool
@@ -68,5 +70,39 @@ func appendRootCAsFromKeychain(pool *x509.CertPool, name, keychain string) *x509
 		tracerx.Printf("Error reading keychain %q: %v", keychain, err)
 	 pool
 	}
-	return appendCertsFromPEMData(pool, data)
+
+	// Parse PEM data into individual certificates
+	pemBlocks := []byte(data)
+	var validCerts [][]byte
+
+	for len(pemBlocks) > 0 {
+		var block *pem.Block
+		block, pemBlocks = pem.Decode(pemBlocks)
+		if block == nil {
+			break
+		}
+		if block.Type != "CERTIFICATE" {
+			continue
+		}
+
+		cert, err := x509.ParseCertificate(block.Bytes)
+		if err != nil {
+			continue
+		}
+
+		// Only include certificates with an exact CN match
+		if strings.ToLower(cert.Subject.CommonName) == strings.ToLower(hostname) {
+			validCerts = append(validCerts, pem.EncodeToMemory(block))
+		} else {
+			tracerx.Printf("Skipping certificate with non-exact CN match: %s", cert.Subject.CommonName)
+		}
+	}
+
+	// Add only the filtered certificates
+	if len(validCerts) > 0 {
+		allCerts := bytes.Join(validCerts, []byte{})
+		appendCertsFromPEMData(pool, allCerts)
+	}
+
+	return pool
 }

Q: How can I resolve this issue?

A: To resolve this issue, you can modify the Git LFS code to filter certificates based on an exact CN match, as shown in the modified code above. Alternatively, you can remove the self-signed JAMF CA certificate from the trust store.