diff --git a/CLAUDE.md b/CLAUDE.md index 3bf49f41..42a90772 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: - All platforms use static binaries built with [static-php-cli](https://github.com/crazywhalecc/static-php-cli) - Supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 - Extensions included: curl, filter, openssl, pcntl (Unix), phar, posix (Unix), zlib -- On Windows, curl is built with Schannel, which verifies certificates with Windows (its certificate store, distrust list and revocation). No CA file is set, as Schannel would use it instead of the store, so the legacy CLI must make HTTPS requests with curl (Guzzle), not PHP streams (OpenSSL), and uses `CURLSSLOPT_REVOKE_BEST_EFFORT`, as roots installed for TLS inspection often have no revocation information +- On Windows, curl is built with Schannel, which verifies certificates with Windows (its certificate store, distrust list and revocation). No CA file is set, as Schannel would use it instead of the store, unless `SSL_CERT_FILE` is set (the wrapper sets `openssl.cafile` to it, and the legacy CLI also sets `CURLOPT_PROXY_CAINFO`, as PHP only passes it to curl for servers: this matches Go). So the legacy CLI must make HTTPS requests with curl (Guzzle), not PHP streams (OpenSSL), and uses `CURLSSLOPT_REVOKE_BEST_EFFORT`, as roots installed for TLS inspection often have no revocation information **Downloading PHP Binaries**: ```bash diff --git a/internal/legacy/cert_store_windows_test.go b/internal/legacy/cert_store_windows_test.go index cb001e5f..8294e59d 100644 --- a/internal/legacy/cert_store_windows_test.go +++ b/internal/legacy/cert_store_windows_test.go @@ -46,6 +46,13 @@ curl_setopt($ch, CURLOPT_SSL_OPTIONS, (int) getenv('TEST_SSL_OPTIONS')); if (getenv('TEST_CAINFO') !== false) { curl_setopt($ch, CURLOPT_CAINFO, getenv('TEST_CAINFO')); } +if (getenv('TEST_PROXY') !== false) { + curl_setopt($ch, CURLOPT_PROXY, getenv('TEST_PROXY')); + curl_setopt($ch, CURLOPT_PROXY_SSL_OPTIONS, (int) getenv('TEST_SSL_OPTIONS')); + if (getenv('TEST_PROXY_CAINFO_FROM_INI') !== false) { + curl_setopt($ch, CURLOPT_PROXY_CAINFO, ini_get('openssl.cafile')); + } +} $body = curl_exec($ch); echo curl_errno($ch) === 0 ? 'OK:' . $body : 'ERR:' . curl_errno($ch) . ':' . curl_error($ch);` @@ -105,7 +112,7 @@ func TestWindowsCertStoreTrust(t *testing.T) { want: curlSSLConnectError, }, { - // So the CLI must not set openssl.cafile, which PHP passes to curl. + // So the CLI only sets openssl.cafile, which PHP passes to curl, when SSL_CERT_FILE is set. name: "a CA file replaces the store", sslOptions: curlSSLOptRevokeBestEffort, caFile: otherCAFile, diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index 0fb89c55..48153480 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -168,12 +168,16 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { // makeCmd makes a legacy CLI command with the given context and arguments. func (c *CLIWrapper) makeCmd(ctx context.Context, args []string, cacheDir string) *exec.Cmd { phpMgr := newPHPManager(cacheDir) + settings := phpMgr.settings() // "-n" tells PHP to ignore any php.ini and scan directory on the host. // The embedded PHP binary is statically built with all extensions it // needs compiled in, and cannot load shared extensions referenced by a // system php.ini (e.g. inside Lando containers). - var cmdArgs = make([]string, 0, len(args)+2) + var cmdArgs = make([]string, 0, len(args)+2+len(settings)*2) cmdArgs = append(cmdArgs, "-n") + for _, s := range settings { + cmdArgs = append(cmdArgs, "-d", s) + } cmdArgs = append(cmdArgs, c.pharPath(cacheDir)) cmdArgs = append(cmdArgs, args...) return exec.CommandContext(ctx, phpMgr.binPath(), cmdArgs...) //nolint:gosec diff --git a/internal/legacy/php_manager.go b/internal/legacy/php_manager.go index a234f1f7..5a4f5ef2 100644 --- a/internal/legacy/php_manager.go +++ b/internal/legacy/php_manager.go @@ -6,6 +6,9 @@ type phpManager interface { // binPath returns the path to the temporary PHP binary. binPath() string + + // settings returns PHP INI entries (key=value format). + settings() []string } type phpManagerPerOS struct { diff --git a/internal/legacy/php_manager_unix.go b/internal/legacy/php_manager_unix.go index a75043f1..48c3a06b 100644 --- a/internal/legacy/php_manager_unix.go +++ b/internal/legacy/php_manager_unix.go @@ -15,3 +15,8 @@ func (m *phpManagerPerOS) copy() error { func (m *phpManagerPerOS) binPath() string { return filepath.Join(m.cacheDir, "php") } + +// settings returns no settings: the legacy CLI reads SSL_CERT_FILE itself, through Composer\CaBundle. +func (m *phpManagerPerOS) settings() []string { + return nil +} diff --git a/internal/legacy/php_manager_windows.go b/internal/legacy/php_manager_windows.go index 14834637..4859101c 100644 --- a/internal/legacy/php_manager_windows.go +++ b/internal/legacy/php_manager_windows.go @@ -2,6 +2,7 @@ package legacy import ( _ "embed" + "os" "path/filepath" "github.com/upsun/cli/internal/file" @@ -12,7 +13,7 @@ var phpCLI []byte // copy writes the PHP binary. Its curl is built with Schannel, which verifies // certificates with Windows (its certificate store, and its distrust and -// revocation lists) unless given a CA file, so none is written. +// revocation lists) unless given a CA file (see settings). func (m *phpManagerPerOS) copy() error { return file.WriteIfNeeded(m.binPath(), phpCLI, 0o755) } @@ -20,3 +21,15 @@ func (m *phpManagerPerOS) copy() error { func (m *phpManagerPerOS) binPath() string { return filepath.Join(m.cacheDir, "php.exe") } + +// settings passes SSL_CERT_FILE to PHP as openssl.cafile, which PHP gives to +// curl. Schannel then verifies certificates against that file instead of the +// Windows certificate store, as Go does for the rest of the CLI. +// +// PHP reads the path from the environment itself, so it needs no escaping. +func (m *phpManagerPerOS) settings() []string { + if os.Getenv("SSL_CERT_FILE") != "" { + return []string{`openssl.cafile="${SSL_CERT_FILE}"`} + } + return nil +} diff --git a/internal/legacy/php_manager_windows_test.go b/internal/legacy/php_manager_windows_test.go new file mode 100644 index 00000000..334cdb4a --- /dev/null +++ b/internal/legacy/php_manager_windows_test.go @@ -0,0 +1,153 @@ +package legacy + +import ( + "crypto/tls" + "encoding/pem" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWindowsSSLCertFileSetting checks that PHP reads SSL_CERT_FILE back unchanged, whatever characters the path has. +func TestWindowsSSLCertFileSetting(t *testing.T) { + manager := newPHPManager(t.TempDir()) + require.NoError(t, manager.copy()) + + paths := []string{ + `C:\Users\runneradmin\AppData\Local\cacert.pem`, + `C:\Users\RUNNER~1\AppData\Local\cacert.pem`, + `\\server\share\cacert.pem`, + `certs\ca.pem`, + `C:\certs\${NOT_A_VAR}\$HOME;"x"\ca.pem`, + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + t.Setenv("SSL_CERT_FILE", path) + args := []string{"-n"} + for _, s := range manager.settings() { + args = append(args, "-d", s) + } + args = append(args, "-r", `echo ini_get("openssl.cafile");`) + output, err := exec.Command(manager.binPath(), args...).CombinedOutput() //nolint:gosec + require.NoError(t, err, "PHP exited with an error: %s", output) + assert.Equal(t, path, string(output)) + }) + } +} + +// TestWindowsSSLCertFile checks that curl in PHP verifies against SSL_CERT_FILE, when it is set, instead of the +// Windows certificate store. +func TestWindowsSSLCertFile(t *testing.T) { + ca := generateTestCA(t) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("hello")) + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{ca.serverCert}} //nolint:gosec // test server; Go picks the minimum version. + server.StartTLS() + defer server.Close() + + // An HTTPS proxy, with a certificate from the same CA. + proxy := httptest.NewUnstartedServer(http.HandlerFunc(serveConnect)) + proxy.TLS = &tls.Config{Certificates: []tls.Certificate{ca.serverCert}} //nolint:gosec // test server; Go picks the minimum version. + proxy.StartTLS() + defer proxy.Close() + + cacheDir := t.TempDir() + manager := newPHPManager(cacheDir) + require.NoError(t, manager.copy()) + + writeCAFile := func(name string, certDER []byte) string { + path := filepath.Join(cacheDir, name) + require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0o600)) + return path + } + serverCAFile := writeCAFile("server.pem", ca.certDER) + otherCAFile := writeCAFile("other.pem", generateTestCA(t).certDER) + + cases := []struct { + name string + certFile string + env []string + want string + }{ + {"a file with the server's root", serverCAFile, nil, "OK:hello"}, + {"a file without the server's root", otherCAFile, nil, curlPeerFailedVerification}, + {"a missing file", filepath.Join(cacheDir, "missing.pem"), nil, "ERR:77:"}, // CURLE_SSL_CACERT_BADFILE + { + // So the legacy CLI sets CURLOPT_PROXY_CAINFO from openssl.cafile. + name: "an HTTPS proxy is not verified with the file by default", + certFile: serverCAFile, + env: []string{"TEST_PROXY=" + proxy.URL}, + want: curlPeerFailedVerification, + }, + { + name: "an HTTPS proxy verified with the file", + certFile: serverCAFile, + env: []string{"TEST_PROXY=" + proxy.URL, "TEST_PROXY_CAINFO_FROM_INI=1"}, + want: "OK:hello", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("SSL_CERT_FILE", c.certFile) + + // Request the server as the legacy CLI does: with the wrapper's settings and best-effort revocation checks. + args := []string{"-n"} + for _, s := range manager.settings() { + args = append(args, "-d", s) + } + args = append(args, "-r", phpRequestScript) + cmd := exec.Command(manager.binPath(), args...) //nolint:gosec + cmd.Env = append(os.Environ(), "TEST_URL="+server.URL, "TEST_SSL_OPTIONS="+strconv.Itoa(curlSSLOptRevokeBestEffort)) + cmd.Env = append(cmd.Env, c.env...) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "PHP exited with an error: %s", output) + + assert.True(t, strings.HasPrefix(string(output), c.want), "expected %q to start with %q", output, c.want) + }) + } + + t.Run("no settings without SSL_CERT_FILE", func(t *testing.T) { + t.Setenv("SSL_CERT_FILE", "") + assert.Empty(t, manager.settings()) + }) +} + +// serveConnect is a minimal HTTPS proxy handler: it tunnels CONNECT requests. +func serveConnect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "CONNECT only", http.StatusMethodNotAllowed) + return + } + upstream, err := net.Dial("tcp", r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer upstream.Close() + conn, buf, err := http.NewResponseController(w).Hijack() + if err != nil { + return + } + defer conn.Close() + if _, err := conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { + return + } + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, buf) + close(done) + }() + _, _ = io.Copy(conn, upstream) + <-done +} diff --git a/legacy/src/Service/Config.php b/legacy/src/Service/Config.php index 0a6f80b4..49b6c59d 100644 --- a/legacy/src/Service/Config.php +++ b/legacy/src/Service/Config.php @@ -625,7 +625,7 @@ public function getNoProxy(): array /** * Returns Guzzle request options for HTTP requests: TLS verification, proxies and the user agent. * - * @return array{headers: array, proxy: array, verify: bool|string, curl?: array} + * @return array{headers: array, proxy: array, verify: bool|string, curl?: array} */ public function getGuzzleOptions(): array { @@ -637,7 +637,8 @@ public function getGuzzleOptions(): array $options['verify'] = false; } elseif (self::curlUsesSchannel()) { // Schannel (on Windows) verifies certificates with Windows: its certificate store, distrust list and - // revocation. Given a CA file it would use that instead of the store, so none is set. Revocation is + // revocation. Given a CA file it would use that instead of the store, so none is set here: the wrapper + // sets openssl.cafile, which PHP passes to curl, only when SSL_CERT_FILE is set. Revocation is // checked where possible, as roots installed to inspect TLS traffic often have no revocation information: // for the server, and for an HTTPS proxy. $options['verify'] = true; @@ -645,6 +646,11 @@ public function getGuzzleOptions(): array \CURLOPT_SSL_OPTIONS => \CURLSSLOPT_REVOKE_BEST_EFFORT, \CURLOPT_PROXY_SSL_OPTIONS => \CURLSSLOPT_REVOKE_BEST_EFFORT, ]; + // PHP passes openssl.cafile to curl for servers only, so an HTTPS proxy is verified with it too, as in Go. + $caFile = \ini_get('openssl.cafile'); + if (\is_string($caFile) && $caFile !== '') { + $options['curl'][\CURLOPT_PROXY_CAINFO] = $caFile; + } } else { $options['verify'] = $this->caBundlePath(); } diff --git a/legacy/tests/ConfigTest.php b/legacy/tests/ConfigTest.php index 90a185f9..65047211 100644 --- a/legacy/tests/ConfigTest.php +++ b/legacy/tests/ConfigTest.php @@ -159,10 +159,16 @@ public function testGetGuzzleOptions(): void if (Config::curlUsesSchannel()) { // Schannel verifies with Windows when it is not given a CA file. $this->assertTrue($options['verify']); - $this->assertEquals([ + $expected = [ CURLOPT_SSL_OPTIONS => CURLSSLOPT_REVOKE_BEST_EFFORT, CURLOPT_PROXY_SSL_OPTIONS => CURLSSLOPT_REVOKE_BEST_EFFORT, - ], $options['curl'] ?? null); + ]; + // The wrapper sets openssl.cafile from SSL_CERT_FILE, and the proxy is verified with it too. + $caFile = ini_get('openssl.cafile'); + if (is_string($caFile) && $caFile !== '') { + $expected[CURLOPT_PROXY_CAINFO] = $caFile; + } + $this->assertEquals($expected, $options['curl'] ?? null); } else { $this->assertIsString($options['verify']); $this->assertFileExists($options['verify']);