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..3630f82ed --- /dev/null +++ b/internal/certs/certs.go @@ -0,0 +1,64 @@ +// 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 { + // 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 + 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}) +}