From 699aca6092e7c3edb559c525d1dd0c15a08ccdb6 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 21 Oct 2026 19:05:44 +0100 Subject: [PATCH 1/3] fix(legacy): use SSL_CERT_FILE on Windows, as Go does Since #259, the embedded PHP on Windows verifies certificates with Schannel against the Windows certificate store, and ignored SSL_CERT_FILE. Go (since 1.27) still uses it on Windows, so with it set, Go and PHP commands would trust different certificates, as before #141. The wrapper now passes SSL_CERT_FILE to PHP as openssl.cafile, which PHP gives to every curl handle as CURLOPT_CAINFO. Schannel then verifies against that file instead of the store, as v5.15.0 did (through Composer\CaBundle) and as Go does. Without SSL_CERT_FILE, nothing is passed and the store is used. An unusable file makes requests fail, as in Go. SSL_CERT_DIR is still not supported on Windows, as Schannel cannot use a directory. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- internal/legacy/cert_store_windows_test.go | 2 +- internal/legacy/legacy.go | 6 +- internal/legacy/php_manager.go | 3 + internal/legacy/php_manager_unix.go | 5 + internal/legacy/php_manager_windows.go | 25 ++++- internal/legacy/php_manager_windows_test.go | 102 ++++++++++++++++++++ legacy/src/Service/Config.php | 3 +- 8 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 internal/legacy/php_manager_windows_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 3bf49f415..0bd85e6ff 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 passes it as `openssl.cafile`, matching 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 cb001e5f7..a2d910d99 100644 --- a/internal/legacy/cert_store_windows_test.go +++ b/internal/legacy/cert_store_windows_test.go @@ -105,7 +105,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 0fb89c554..48153480a 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 a234f1f7e..5a4f5ef27 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 a75043f14..48c3a06b4 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 148346379..06b1aa54c 100644 --- a/internal/legacy/php_manager_windows.go +++ b/internal/legacy/php_manager_windows.go @@ -2,7 +2,9 @@ package legacy import ( _ "embed" + "os" "path/filepath" + "strings" "github.com/upsun/cli/internal/file" ) @@ -12,7 +14,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 +22,24 @@ 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. +func (m *phpManagerPerOS) settings() []string { + if f := os.Getenv("SSL_CERT_FILE"); f != "" { + return []string{iniSetting("openssl.cafile", f)} + } + return nil +} + +// iniSetting formats a PHP setting for the -d option. +// +// PHP reads ini values as expressions, in which characters such as "~" are +// operators, so a value has to be quoted. Windows short paths contain them, +// for example C:\Users\RUNNER~1\AppData. Inside quotes a backslash escapes the +// next character, so the backslashes have to be doubled. +func iniSetting(key, value string) string { + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) + return key + `="` + escaped + `"` +} diff --git a/internal/legacy/php_manager_windows_test.go b/internal/legacy/php_manager_windows_test.go new file mode 100644 index 000000000..507e45f9d --- /dev/null +++ b/internal/legacy/php_manager_windows_test.go @@ -0,0 +1,102 @@ +package legacy + +import ( + "crypto/tls" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWindowsIniSetting checks that PHP reads a path back unchanged. +// +// PHP parses ini values as expressions, so an unquoted path is truncated at a +// character such as "~", which Windows short paths contain, and inside quotes a +// backslash escapes the next character. +func TestWindowsIniSetting(t *testing.T) { + manager := newPHPManager(t.TempDir()) + require.NoError(t, manager.copy()) + + cases := []struct { + name string + path string + }{ + {"a long path", `C:\Users\runneradmin\AppData\Local\cacert.pem`}, + {"a short path", `C:\Users\RUNNER~1\AppData\Local\cacert.pem`}, + {"a network path", `\\server\share\cacert.pem`}, + {"a relative path", `certs\ca.pem`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + args := []string{"-n", "-d", iniSetting("openssl.cafile", c.path), "-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, c.path, strings.TrimSpace(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() + + 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 + want string + }{ + {"a file with the server's root", serverCAFile, "OK:hello"}, + {"a file without the server's root", otherCAFile, curlPeerFailedVerification}, + {"a missing file", filepath.Join(cacheDir, "missing.pem"), "ERR:77:"}, // CURLE_SSL_CACERT_BADFILE + } + 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)) + 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()) + }) +} diff --git a/legacy/src/Service/Config.php b/legacy/src/Service/Config.php index 0a6f80b49..2e70ef17c 100644 --- a/legacy/src/Service/Config.php +++ b/legacy/src/Service/Config.php @@ -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; From 5b06fbc122b42caf7db375c6ef81e9574895f175 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 21 Oct 2026 19:08:40 +0100 Subject: [PATCH 2/3] fix(legacy): let PHP read SSL_CERT_FILE from the environment The wrapper now sets openssl.cafile to "${SSL_CERT_FILE}", which PHP expands itself, instead of quoting and escaping the path. A path with a literal "${...}" was otherwise expanded by PHP's ini parser. The ini escaping helper is removed, and the test checks paths with "~", backslashes, "$" and quotes. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- internal/legacy/php_manager_windows.go | 18 +++-------- internal/legacy/php_manager_windows_test.go | 35 ++++++++++----------- 3 files changed, 22 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0bd85e6ff..42ec7af11 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, unless `SSL_CERT_FILE` is set (the wrapper passes it as `openssl.cafile`, matching 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 +- 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, matching 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/php_manager_windows.go b/internal/legacy/php_manager_windows.go index 06b1aa54c..4859101c9 100644 --- a/internal/legacy/php_manager_windows.go +++ b/internal/legacy/php_manager_windows.go @@ -4,7 +4,6 @@ import ( _ "embed" "os" "path/filepath" - "strings" "github.com/upsun/cli/internal/file" ) @@ -26,20 +25,11 @@ func (m *phpManagerPerOS) binPath() string { // 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 f := os.Getenv("SSL_CERT_FILE"); f != "" { - return []string{iniSetting("openssl.cafile", f)} + if os.Getenv("SSL_CERT_FILE") != "" { + return []string{`openssl.cafile="${SSL_CERT_FILE}"`} } return nil } - -// iniSetting formats a PHP setting for the -d option. -// -// PHP reads ini values as expressions, in which characters such as "~" are -// operators, so a value has to be quoted. Windows short paths contain them, -// for example C:\Users\RUNNER~1\AppData. Inside quotes a backslash escapes the -// next character, so the backslashes have to be doubled. -func iniSetting(key, value string) string { - escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) - return key + `="` + escaped + `"` -} diff --git a/internal/legacy/php_manager_windows_test.go b/internal/legacy/php_manager_windows_test.go index 507e45f9d..474a0490f 100644 --- a/internal/legacy/php_manager_windows_test.go +++ b/internal/legacy/php_manager_windows_test.go @@ -16,30 +16,29 @@ import ( "github.com/stretchr/testify/require" ) -// TestWindowsIniSetting checks that PHP reads a path back unchanged. -// -// PHP parses ini values as expressions, so an unquoted path is truncated at a -// character such as "~", which Windows short paths contain, and inside quotes a -// backslash escapes the next character. -func TestWindowsIniSetting(t *testing.T) { +// 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()) - cases := []struct { - name string - path string - }{ - {"a long path", `C:\Users\runneradmin\AppData\Local\cacert.pem`}, - {"a short path", `C:\Users\RUNNER~1\AppData\Local\cacert.pem`}, - {"a network path", `\\server\share\cacert.pem`}, - {"a relative path", `certs\ca.pem`}, + 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 _, c := range cases { - t.Run(c.name, func(t *testing.T) { - args := []string{"-n", "-d", iniSetting("openssl.cafile", c.path), "-r", `echo ini_get("openssl.cafile");`} + 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, c.path, strings.TrimSpace(string(output))) + assert.Equal(t, path, string(output)) }) } } From d8f1ad29643862837fea9864df83af5c4acbed28 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 21 Oct 2026 19:53:09 +0100 Subject: [PATCH 3/3] fix(legacy): verify an HTTPS proxy with SSL_CERT_FILE on Windows too PHP passes openssl.cafile to curl as CURLOPT_CAINFO, which only covers servers: curl verifies an HTTPS proxy with CURLOPT_PROXY_CAINFO, without falling back to CAINFO. So with SSL_CERT_FILE set, Schannel still verified a proxy against the Windows store, while Go uses the file for both. With Schannel, getGuzzleOptions() now also sets CURLOPT_PROXY_CAINFO from openssl.cafile when it is set. The Windows test checks both cases through an HTTPS proxy. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- internal/legacy/cert_store_windows_test.go | 7 +++ internal/legacy/php_manager_windows_test.go | 58 +++++++++++++++++++-- legacy/src/Service/Config.php | 7 ++- legacy/tests/ConfigTest.php | 10 +++- 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42ec7af11..42a907721 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, unless `SSL_CERT_FILE` is set (the wrapper sets `openssl.cafile` to it, matching 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 +- 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 a2d910d99..8294e59d0 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);` diff --git a/internal/legacy/php_manager_windows_test.go b/internal/legacy/php_manager_windows_test.go index 474a0490f..334cdb4ac 100644 --- a/internal/legacy/php_manager_windows_test.go +++ b/internal/legacy/php_manager_windows_test.go @@ -3,6 +3,8 @@ package legacy import ( "crypto/tls" "encoding/pem" + "io" + "net" "net/http" "net/http/httptest" "os" @@ -54,6 +56,12 @@ func TestWindowsSSLCertFile(t *testing.T) { 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()) @@ -69,11 +77,25 @@ func TestWindowsSSLCertFile(t *testing.T) { cases := []struct { name string certFile string + env []string want string }{ - {"a file with the server's root", serverCAFile, "OK:hello"}, - {"a file without the server's root", otherCAFile, curlPeerFailedVerification}, - {"a missing file", filepath.Join(cacheDir, "missing.pem"), "ERR:77:"}, // CURLE_SSL_CACERT_BADFILE + {"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) { @@ -87,6 +109,7 @@ func TestWindowsSSLCertFile(t *testing.T) { 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) @@ -99,3 +122,32 @@ func TestWindowsSSLCertFile(t *testing.T) { 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 2e70ef17c..49b6c59d9 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 { @@ -646,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 90a185f9b..650472110 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']);