Skip to content

v2.5.4

Choose a tag to compare

@github-actions github-actions released this 27 Jul 19:18
· 122 commits to main since this release
Immutable release. Only release title and notes can be modified.
c957fe5

Added

  • cms.updates.composer_binary config key (#232) — priority-1 override for the self-updater's composer binary path, populated by default from env('COMPOSER_BINARY'). Setting COMPOSER_BINARY=/opt/homebrew/bin/composer in a Laravel .env file now works from HTTP-request context, matching the advice the composerBinaryNotFound error message gives.
  • CMS_PHP_BINARY env var + CLI PHP interpreter resolution for composer (follow-up to #225) — a new resolvePhpBinary() walks CLI-shaped candidates (Herd, Homebrew, system) and rejects anything whose basename smells like -fpm/-cgi. CMS_PHP_BINARY wins when set; CLI SAPI short-circuits to PHP_BINARY. The resolved CLI PHP is used to invoke composer during both install and the rollback pre-check.

Fixed

  • Updater metadata GETs blocked by userland HTTP listeners (#231) — the feed check, single-release lookup, SHA-256 sidecar fetch, and custom JSON endpoint all went through Laravel's Http facade, which dispatches RequestSending/ResponseReceived around every request. Herd Pro's HttpClientWatcher, Telescope, Debugbar, and custom monitoring listeners could block or corrupt the request lifecycle — when Herd's dump-server socket was wedged, the check died after max_execution_time instead of the ~200ms round-trip. A new MetadataClient static helper uses a raw GuzzleHttp\Client and bypasses Laravel's HTTP factory event dispatch entirely; retry semantics (5xx retry, config-driven attempt count) are preserved for the custom JSON endpoint. The download path still uses Http::sink() (already shielded by #224).
  • COMPOSER_BINARY in .env didn't take effect under PHP-FPM (#232) — ApplicationUpdateManager::envComposerBinary() read via getenv('COMPOSER_BINARY'), but Laravel 11+'s default dotenv adapter populates env()/$_ENV without calling putenv(), so getenv() returned false from HTTP-request context and the priority-1 override was silently skipped. envComposerBinary() now reads cms.updates.composer_binary (populated from env('COMPOSER_BINARY') in the shipped config) first, then falls back to getenv() for hosts that export COMPOSER_BINARY at the OS level. The composerBinaryNotFound error message now names both the .env/config path and the OS-level path so operators aren't sent down a broken workaround.
  • Composer discovery failures were undiagnosable (#233) — discoverComposerBinary() now collects is_file()/is_executable() per candidate path and emits a structured Log::warning on failure so operators can distinguish a wrong-path failure from a PHP-FPM sandboxed-stat failure (macOS Herd Pro sandboxes /opt/homebrew/*; chrooted FPM pools and restrictive open_basedir behave the same way). The warning includes php_sapi and a hint pointing at the fix. UpdateException::composerBinaryNotFound() accepts either the legacy flat string list or the new per-candidate diagnostic shape and renders the stat outcome for each path in the exception message, so operators don't have to check the log. Legacy callers still work; a mixed-shape input degrades to path-only rendering instead of raising a TypeError.
  • PHP-FPM interpreter used to invoke composer (follow-up to #225) — under PHP-FPM, PHP_BINARY points at the FPM daemon binary, which prints usage and exits 64 when handed the composer PHAR. That produced the misleading "Composer install failed. Output: ." symptom on Herd hosts — composer was discovered fine, but the interpreter used to invoke it was the wrong SAPI. Rollback then wrongly reported "Composer binary could not be located" because verifyComposerBinaryAvailable() reused the same broken interpreter. buildComposerCommand() and the rollback pre-check now use the resolved CLI PHP; a new composerVerificationFailed exception surfaces the resolved binary, PHP interpreter, exit code, and captured output when --version fails, so future misdiagnoses don't repeat.
  • Silent extraction failures left partial installs on disk (#236) — inside ApplicationUpdateManager::extractUpdate()'s per-entry loop, a failed fopen('wb') or fread() on the target file previously did continue/break without logging or throwing. Extraction of that entry was silently abandoned, nothing propagated to performUpdate()'s catch block, and handleUpdateFailure()/rollback never triggered — leaving a partial install on disk that failed to boot on the next request with no obvious cause. Failures are now logged with the entry + errno and thrown via a new UpdateException::extractionEntryFailed() factory so performUpdate()'s catch block rolls back to the pre-update snapshot.

Security

  • Fail closed when update source omits SHA-256 checksum (#235) — ApplicationUpdateManager::maybeVerifyChecksum() previously logged a warning and returned silently when the update source did not advertise a SHA-256 hash, letting the updater download and execute arbitrary remote code without integrity verification. That fail-open behavior also weakened the reachability story for #234. maybeVerifyChecksum() now throws UpdateException::checksumRequired() when no checksum is advertised. An explicit opt-in — cms.updates.allow_unverified_updates (default false, env CMS_UPDATES_ALLOW_UNVERIFIED) — is available for hosts on trusted networks or air-gapped mirrors that intentionally accept the risk; those hosts still get the original warning log.
  • Zip-slip in extractUpdate() (#234) — ApplicationUpdateManager::extractUpdate() built the write target by concatenating base_path() with an unvalidated entry name from the ZIP, then streamed via fopen()/fwrite(). Because this bypassed ZipArchive::extractTo(), PHP's own traversal mitigations did not apply, so a crafted archive entry like release-root/../../../etc/cron.d/x would write outside the install root once the common prefix was stripped. Entries whose normalized path starts with / or contains .. segments are now rejected before the target directory is created, and realpath() verifies the resolved parent still sits under the extraction root before opening the write stream. Both failures are logged and the entry is skipped.