Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/platform/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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, "_"))
Expand Down
64 changes: 64 additions & 0 deletions internal/certs/certs.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions internal/certs/certs_test.go
Original file line number Diff line number Diff line change
@@ -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})
}