From 6fea56a75164903c92c7e2df5a5c0e974ec78ed8 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 30 Jul 2026 01:08:00 +0100 Subject: [PATCH 1/2] fix: trust the certificates named by SSL_CERT_FILE on Windows and macOS Go reads that variable only on Unix; elsewhere it verifies through the operating system. The legacy CLI reads it on every platform, through Composer\CaBundle, so the two parts of the CLI disagreed about which certificates to trust, and a command implemented in Go failed where the same command implemented in PHP worked. An unusable file is reported as a warning rather than being fatal, and the system certificates are used, as before. Written by Claude Code. --- cmd/platform/main.go | 7 ++++ internal/certs/certs.go | 62 +++++++++++++++++++++++++++++++ internal/certs/certs_test.go | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 internal/certs/certs.go create mode 100644 internal/certs/certs_test.go diff --git a/cmd/platform/main.go b/cmd/platform/main.go index eb7e37be5..d2976b2cb 100644 --- a/cmd/platform/main.go +++ b/cmd/platform/main.go @@ -10,6 +10,7 @@ import ( "github.com/symfony-cli/terminal" "github.com/upsun/cli/commands" + "github.com/upsun/cli/internal/certs" "github.com/upsun/cli/internal/config" ) @@ -26,6 +27,12 @@ func main() { os.Exit(1) } + // Trust the same certificates as the legacy CLI. This is not fatal: the + // system certificates are used instead, as they were before. + if err := certs.UseEnvCertFile(); err != nil { + fmt.Fprintln(os.Stderr, "Warning: "+err.Error()) + } + // When Cobra starts, load Viper config from the environment. cobra.OnInitialize(func() { viper.SetEnvPrefix(strings.TrimSuffix(cnf.Application.EnvPrefix, "_")) diff --git a/internal/certs/certs.go b/internal/certs/certs.go new file mode 100644 index 000000000..78c816a97 --- /dev/null +++ b/internal/certs/certs.go @@ -0,0 +1,62 @@ +// Package certs lets the Go part of the CLI trust the certificates named by +// SSL_CERT_FILE, which the legacy PHP part already trusts on every platform. +package certs + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + "runtime" +) + +// EnvVar names a file holding the certificates to trust. +const EnvVar = "SSL_CERT_FILE" + +// UseEnvCertFile makes the default HTTP transport verify against the +// certificates named by SSL_CERT_FILE. +// +// Go reads that variable itself on Unix, but not on Windows or macOS, where it +// verifies through the operating system instead. The legacy CLI reads it on +// every platform, through Composer\CaBundle, so without this the two parts of +// the CLI disagree about which certificates to trust. +// +// The file replaces the system certificates rather than adding to them, which +// is what Go does on Unix and what the legacy CLI does everywhere. +func UseEnvCertFile() error { + if goReadsEnvCertFile() { + return nil + } + transport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return fmt.Errorf("the default HTTP transport cannot be configured") + } + return useCertFile(transport, os.Getenv(EnvVar)) +} + +// goReadsEnvCertFile reports whether Go's own verification reads the variable. +func goReadsEnvCertFile() bool { + return runtime.GOOS != "windows" && runtime.GOOS != "darwin" +} + +// useCertFile points a transport at a certificate file. An empty path is +// ignored, leaving the system certificates in use. +func useCertFile(transport *http.Transport, path string) error { + if path == "" { + return nil + } + pemCerts, err := os.ReadFile(path) //nolint:gosec // the user names the file. + if err != nil { + return fmt.Errorf("could not read %s: %w", EnvVar, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemCerts) { + return fmt.Errorf("no certificates found in %s: %s", EnvVar, path) + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + transport.TLSClientConfig.RootCAs = pool + return nil +} diff --git a/internal/certs/certs_test.go b/internal/certs/certs_test.go new file mode 100644 index 000000000..aea50a9e1 --- /dev/null +++ b/internal/certs/certs_test.go @@ -0,0 +1,71 @@ +package certs + +import ( + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestUseCertFile checks that a server signed by a certificate in the file is +// trusted, and that one signed by an unrelated certificate is not. +func TestUseCertFile(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("hello")) + })) + defer server.Close() + + certFile := filepath.Join(t.TempDir(), "cert.pem") + require.NoError(t, os.WriteFile(certFile, serverCertPEM(t, server), 0o600)) + + cases := []struct { + name string + path string + wantTrust bool + }{ + {"no file, so the system certificates are used", "", false}, + {"a file holding the server's certificate", certFile, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + transport := &http.Transport{} + require.NoError(t, useCertFile(transport, c.path)) + + resp, err := (&http.Client{Transport: transport}).Get(server.URL) + if !c.wantTrust { + require.Error(t, err, "expected the certificate not to be trusted") + return + } + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Setting the roots is what makes the file replace the system + // certificates rather than add to them. + assert.NotNil(t, transport.TLSClientConfig.RootCAs) + }) + } +} + +func TestUseCertFileErrors(t *testing.T) { + empty := filepath.Join(t.TempDir(), "empty.pem") + require.NoError(t, os.WriteFile(empty, []byte("not a certificate"), 0o600)) + + assert.ErrorContains(t, useCertFile(&http.Transport{}, filepath.Join(t.TempDir(), "missing.pem")), + "could not read SSL_CERT_FILE") + assert.ErrorContains(t, useCertFile(&http.Transport{}, empty), + "no certificates found in SSL_CERT_FILE") +} + +// serverCertPEM returns the certificate a test server signs with. +func serverCertPEM(t *testing.T, server *httptest.Server) []byte { + t.Helper() + + require.NotNil(t, server.Certificate()) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) +} From 44e7b0bb324b8fe3306b9e6656b3241c5f96859a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 30 Jul 2026 02:27:28 +0100 Subject: [PATCH 2/2] docs: note that the TLS minimum matches Go's own default Written by Claude Code. --- internal/certs/certs.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/certs/certs.go b/internal/certs/certs.go index 78c816a97..3630f82ed 100644 --- a/internal/certs/certs.go +++ b/internal/certs/certs.go @@ -55,6 +55,8 @@ func useCertFile(transport *http.Transport, path string) error { return fmt.Errorf("no certificates found in %s: %s", EnvVar, path) } if transport.TLSClientConfig == nil { + // TLS 1.2 is the minimum Go uses for a client anyway, so this sets no + // policy of its own. transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} } transport.TLSClientConfig.RootCAs = pool