feat(legacy): trust the Windows certificate store - #142
Conversation
The embedded PHP has to be given a CA file, because its openssl extension cannot read the Windows certificate store, and curl built against Schannel then verifies against that file alone. So the CLI trusted only the certificates shipped with it, and failed wherever an organization installs its own root certificate, which is what issue #110 reported. The Go part of the CLI already trusted the store, so the two parts disagreed about the same connection. curl in the embedded PHP is now built against OpenSSL, which reads a CA file and the store together and does not limit the size of the file, so the bundle written to the cache directory holds the shipped certificates and the store's trusted roots. Pointing openssl.cafile at it covers the whole PHP layer, not just curl: Composer\CaBundle checks that setting, so Guzzle and the stream context follow the same file. The certificate store test changes with the behavior, as its comment said it would. Written by Claude Code.
This build has curl built against OpenSSL, which the CA bundle now depends on. It also brings security fixes since 8.4.20, including CVE-2026-12184, a segfault in file_get_contents with an https URL and a proxy set, which is a combination the CLI uses, and CVE-2026-14355 in the openssl extension. Written by Claude Code.
|
📋 PR Summary This incremental change makes the Windows CA bundle logic degrade gracefully instead of aborting the CLI. Changes
|
| func (m *phpManagerPerOS) writeCAFile() error { | ||
| bundle, err := caBundle() | ||
| if err != nil { | ||
| return err |
There was a problem hiding this comment.
🟡 Warning — A store-read failure now breaks every legacy command instead of degrading to shipped certs.
writeCAFile returns any caBundle() error, and copy() is run inside the init errgroup in legacy.go (g.Go(newPHPManager(cacheDir).copy)), so a failure makes g.Wait() fail and runInitOnce abort, which fails Exec for every legacy command. Before this change copy() wrote the embedded caCert unconditionally and always succeeded. Now if CertOpenSystemStore("ROOT") or CertEnumCertificatesInStore fails in a locked-down or unusual Windows environment, the CLI can no longer run any legacy command at all — a strictly worse outcome than the shipped-only bundle, which would have kept normal (non-inspected) TLS working. Falling back to the shipped bundle on a store-read error preserves the prior behavior.
| } | ||
|
|
||
| var out bytes.Buffer | ||
| err = eachStoreCert("ROOT", func(der []byte) error { |
There was a problem hiding this comment.
🔵 Info — Widens PHP trust to user-installed roots and unconstrained certs in the ROOT store.
eachStoreCert("ROOT", ...) opens the logical ROOT store via CertOpenSystemStore(0, ...), which merges the local-machine and current-user root stores, and every certificate found is appended to cacert.pem with no CA:true/basic-constraints or key-usage filtering. A root a non-privileged user (or user-level malware) adds to their own HKCU ROOT store, or any end-entity certificate sitting in ROOT, therefore becomes a trusted TLS anchor for the embedded PHP. This mirrors what the Go layer and other programs do, so it is largely by design, but it does widen PHP's trust to user-controllable roots.
copy() runs in the errgroup which initializes the wrapper, so returning an error from reading the certificate store would stop every legacy command from running. That is worse than not seeing an organization's certificates: the shipped certificates keep everything else working, and are what the CLI trusted before it read the store. The reason is reported through the wrapper's debug output instead. Written by Claude Code.
|
Both review findings addressed. The store-read failure warning was right and is the more important of the two: On widening trust to user-installed roots: that is deliberate, and it is the point of the change. The ROOT store is what the operating system trusts, and it is what the Go part of this same CLI already trusted through the platform verifier — so before this change the two halves disagreed about the same connection. Filtering to Written by Claude Code. |
There was a problem hiding this comment.
📋 Upsun Dispatch Review: incremental · 5 files reviewed · no new issues · 1 still open
Outstanding from earlier reviews:
- #3679219852 — internal/legacy/ca_bundle_windows.go:56: Widens PHP trust to user-installed roots and unconstrained certs in the ROOT store.
Fixes #110. Stacked on #139, so review that first — the base will change to
mainonce it merges. Needs cli-php-builds#1, which built the PHP this depends on.The problem
The embedded PHP has to be given a CA file: its openssl extension cannot read the Windows certificate store, and the CLI uses openssl streams for some requests. curl built against Schannel then verifies against that file alone. So on Windows the CLI trusted only the certificates shipped with it, and a machine whose TLS traffic is inspected — where every other program trusts the organization's root certificate from the store — failed with
cURL error 60.The Go part of the CLI verifies through the Windows platform verifier, so it already trusted that certificate. The two parts of the CLI disagreed about the same connection.
The change
curl in the embedded PHP is now built against OpenSSL, which loads a CA file and the Windows stores together and does not limit the size of the file.
caBundle()reads the trusted roots from the store and appends the ones the shipped bundle does not already have, skipping expired certificates and any the Go parser cannot read. The wrapper writes that tocacert.pemand pointsopenssl.cafileat it, as before.That setting covers the whole PHP layer, not just curl:
Composer\CaBundlechecksopenssl.cafilewhen it resolves a bundle, so Guzzle'sverifyoption and the stream context inConfig::getStreamContextOptions()use the same file.This widens trust to what the machine already trusts, which is the point, and it is what every other program on it does, Go included. It is additive, so nothing previously trusted stops working.
Why not merge the whole store under Schannel
That was the first attempt, and the runner rejected it: Schannel refuses a CA file over 1 MiB (
MAX_CAFILE_SIZEin curl'sschannel_verify.c), and the store held 564 roots, giving a 758 KB bundle even after filtering — 76% of a hard limit, with a silent fallback to shipped-only when exceeded. Switching the backend removed the limit, and with it the filtering heuristics and the fallback.Cost
Reading the store happens in
copy(), before every legacy command, inside the init lock. Measured on the runner:About 19 ms, effectively all of it reading and parsing certificates — the file comparison and write are free. Not cached deliberately: a cache would mean the CLI keeps rejecting a certificate the machine already trusts until the cache expired, which is the bug being fixed, only with a delay. The allocation count says the number is the implementation's, not the store's, so making it leaner is the first move if it ever matters.
PHP 8.4.23
The second commit takes the build with OpenSSL-backed curl, which this depends on. It also brings security fixes since 8.4.20, two of which apply here:
CVE-2026-12184, a segfault infile_get_contentswith an https URL and a proxy set, a combination the CLI uses; andCVE-2026-14355in the openssl extension.Written by Claude Code.