← Back
Editing: WPInstallStorage.php
<?php /** ********************************************* * LiteSpeed Web Server Cache Manager * * @author Michael Alegre * @copyright 2018-2026 LiteSpeed Technologies, Inc. * ******************************************* */ namespace Lsc\Wp; use Lsc\Wp\Context\Context; use Lsc\Wp\Panel\ControlPanel; use Lsc\Wp\ThirdParty\Polyfill\Utf8; /** * map to data file */ class WPInstallStorage { /** * @var string */ const CMD_ADD_CUST_WPINSTALLS = 'addCustWPInstalls'; /** * @since 1.13.3 * @var string */ const CMD_ADD_NEW_WPINSTALL = 'addNewWPInstall'; /** * @deprecated 1.13.3 Use CMD_DISCOVER_NEW2 instead. * @var string */ const CMD_DISCOVER_NEW = 'discoverNew'; /** * @since 1.14 * @var string */ const CMD_DISCOVER_NEW_AND_ENABLE = 'discoverNewAndEnable'; /** * @since 1.13.3 * @var string */ const CMD_DISCOVER_NEW2 = 'discoverNew2'; /** * @var string */ const CMD_FLAG = 'flag'; /** * @var string */ const CMD_MASS_FLAG = 'mass_flag'; /** * @var string */ const CMD_MASS_UNFLAG = 'mass_unflag'; /** * @deprecated 1.13.3 Use CMD_SCAN2 instead for now. * @var string */ const CMD_SCAN = 'scan'; /** * @since 1.13.3 * @var string */ const CMD_SCAN2 = 'scan2'; /** * @var string */ const CMD_UNFLAG = 'unflag'; /** * @var string */ const DATA_VERSION = '1.5'; /** * @var int */ const ERR_NOT_EXIST = 1; /** * @var int */ const ERR_CORRUPTED = 2; /** * @var int */ const ERR_VERSION_HIGH = 3; /** * @var int */ const ERR_VERSION_LOW = 4; /** * @var string */ protected $dataFile; /** * @var string */ protected $customDataFile; /** * @var null|WPInstall[] Key is the path to a WordPress installation. */ protected $wpInstalls = null; /** * @var null|WPInstall[] Key is the path to a WordPress installation. */ protected $custWpInstalls = null; /** * @var int */ protected $error; /** * @var WPInstall[] */ protected $workingQueue = []; /** * * @param string $dataFile * @param string $custDataFile * * @throws LSCMException Thrown indirectly by $this->init() call. */ public function __construct( $dataFile, $custDataFile = '' ) { $this->dataFile = $dataFile; $this->customDataFile = $custDataFile; $this->error = $this->init(); } /** * * @return int * * @throws LSCMException Thrown indirectly by Logger::debug() call. */ protected function init() { $dataExists = false; try { if ( file_exists($this->dataFile) ) { $dataExists = true; $this->wpInstalls = $this->getDataFileData($this->dataFile); } if ( $this->customDataFile != '' && file_exists($this->customDataFile) ) { $dataExists = true; $this->custWpInstalls = $this->getDataFileData($this->customDataFile); } } catch ( LSCMException $e ) { Logger::debug($e->getMessage()); return $e->getCode(); } if ( !$dataExists ) { return self::ERR_NOT_EXIST; } return 0; } /** * * @since 1.15 * * @param string $dataFile * * @return false|string */ protected static function getDataFileContents( $dataFile ) { return file_get_contents($dataFile); } /** * * @param string $dataFile * * @return WPInstall[] * * @throws LSCMException Thrown when data file is corrupt. * @throws LSCMException Thrown when there is a data file version issue. * @throws LSCMException Thrown indirectly by $this->verifyDataFileVer() * call. */ protected function getDataFileData( $dataFile ) { $content = static::getDataFileContents($dataFile); if ( ($data = json_decode($content, true)) === null ) { /** * Data file may be in the legacy serialized format (pre-v1.15). * * I-7: every cPanel-supported runtime is PHP 7+, so the previous * PHP_VERSION < 7.0 LSCMException branch was dead code. Calling * unserialize() with `'allowed_classes' => false` blocks object * instantiation, which is the only gadget path of concern here. */ $data = unserialize($content, ['allowed_classes' => false]); } if ( $data === false || !is_array($data) || !isset($data['__VER__']) ) { throw new LSCMException( "$dataFile - Data is corrupt.", self::ERR_CORRUPTED ); } if ( ($err = $this->verifyDataFileVer($dataFile, $data['__VER__'])) ) { throw new LSCMException( "$dataFile - Data file version issue.", $err ); } unset($data['__VER__']); $wpInstalls = []; foreach ( $data as $utf8Path => $idata ) { $path = Utf8::decode($utf8Path); $i = new WPInstall($path); $siteUrl = isset($idata[WPInstall::FLD_SITEURL]) ? $idata[WPInstall::FLD_SITEURL] : null; $idata[WPInstall::FLD_SITEURL] = ($siteUrl !== null) ? urldecode($siteUrl) : null; $serverName = isset($idata[WPInstall::FLD_SERVERNAME]) ? $idata[WPInstall::FLD_SERVERNAME] : null; $idata[WPInstall::FLD_SERVERNAME] = ($serverName !== null) ? urldecode($serverName) : null; $i->initData($idata); $wpInstalls[$path] = $i; } return $wpInstalls; } /** * * @return int */ public function getError() { return $this->error; } /** * * @param bool $nonFatalOnly * * @return int */ public function getCount( $nonFatalOnly = false ) { $count = 0; if ( $this->wpInstalls != null ) { if ( $nonFatalOnly ) { foreach ( $this->wpInstalls as $install ) { if ( !$install->hasFatalError() ) { $count++; } } } else { $count += count($this->wpInstalls); } } if ( $this->custWpInstalls != null ) { if ( $nonFatalOnly ) { foreach ( $this->custWpInstalls as $custInstall ) { if ( !$custInstall->hasFatalError() ) { $count++; } } } else { $count += count($this->custWpInstalls); } } return $count; } /** * * @return null|WPInstall[] * * @noinspection PhpUnused */ public function getWPInstalls() { return $this->wpInstalls; } /** * * @return null|WPInstall[] * * @noinspection PhpUnused */ public function getCustWPInstalls() { return $this->custWpInstalls; } /** * * @return null|WPInstall[] */ public function getAllWPInstalls() { if ( $this->wpInstalls != null ) { if ( $this->custWpInstalls != null ) { return array_merge($this->wpInstalls, $this->custWpInstalls); } else { return $this->wpInstalls; } } elseif ( $this->custWpInstalls != null ) { return $this->custWpInstalls; } else { return null; } } /** * Get all known WPInstall paths. * * @return string[] */ public function getPaths() { $paths = []; if ( $this->wpInstalls != null ) { $paths = array_keys($this->wpInstalls); } if ( $this->custWpInstalls != null ) { $paths = array_merge($paths, array_keys($this->custWpInstalls)); } return $paths; } /** * * @param string $path * * @return WPInstall|null */ public function getWPInstall( $path ) { /** * V9.5 — match the literal path against the canonical keyset first. * Stored keys are already canonical (WPInstall::init() canonicalises * and addWPInstall() keys by getPath()), so a caller passing a * canonical path (the flag-action case) resolves to its bound install * even if an intermediate component was swapped after the path was * enqueued. Resolving via realpath() first would follow such a swap to * a victim tree and miss the binding, yielding an unbound WPInstall * downstream whose null expectedOwnerUid would skip Layer 2 of * addUserFlagFile(). */ if ( isset($this->wpInstalls[$path]) ) { return $this->wpInstalls[$path]; } elseif ( isset($this->custWpInstalls[$path]) ) { return $this->custWpInstalls[$path]; } if ( ($realPath = realpath($path)) === false ) { $index = $path; } else { $index = $realPath; } if ( isset($this->wpInstalls[$index]) ) { return $this->wpInstalls[$index]; } elseif ( isset($this->custWpInstalls[$index]) ) { return $this->custWpInstalls[$index]; } return null; } /** * * @return WPInstall[] */ public function getWorkingQueue() { return $this->workingQueue; } /** * * @param WPInstall $wpInstall */ public function addWPInstall( WPInstall $wpInstall ) { $this->wpInstalls[$wpInstall->getPath()] = $wpInstall; } /** * * @throws LSCMException Thrown indirectly by $this->saveDataFile() call. * @throws LSCMException Thrown indirectly by $this->saveDataFile() call. */ public function syncToDisk() { $this->saveDataFile($this->dataFile, $this->wpInstalls); if ( $this->customDataFile != '' ) { $this->saveDataFile($this->customDataFile, $this->custWpInstalls); } } /** * * @param string $dataFile * @param WPInstall[]|null $wpInstalls * * @throws LSCMException Thrown indirectly by $this->log() call. */ protected function saveDataFile( $dataFile, $wpInstalls ) { $data = [ '__VER__' => self::DATA_VERSION ]; if ( !empty($wpInstalls) ) { foreach ( $wpInstalls as $path => $install ) { if ( !$install->shouldRemove() ) { $utf8Path = Utf8::encode($path); $data[$utf8Path] = $install->getData(); $siteUrl = &$data[$utf8Path][WPInstall::FLD_SITEURL]; if ( $siteUrl != null ) { $siteUrl = urlencode($siteUrl); } $serverName = &$data[$utf8Path][WPInstall::FLD_SERVERNAME]; if ( $serverName != null ) { $serverName = urlencode($serverName); } } } ksort($data); } file_put_contents($dataFile, json_encode($data), LOCK_EX); chmod($dataFile, 0600); $this->log("Data file saved $dataFile", Logger::L_DEBUG); } /** * * @param string $dataFile * @param string $dataFileVer * * @return int * * @throws LSCMException Thrown indirectly by Logger::info() call. * @throws LSCMException Thrown indirectly by $this->updateDataFile() call. */ protected function verifyDataFileVer( $dataFile, $dataFileVer ) { $res = Util::betterVersionCompare($dataFileVer, self::DATA_VERSION); if ( $res == 1 ) { Logger::info( 'Data file version is higher than expected and cannot be used.' ); return self::ERR_VERSION_HIGH; } if ( $res == -1 && !$this->updateDataFile($dataFile, $dataFileVer) ) { return self::ERR_VERSION_LOW; } return 0; } /** * * @param string $dataFile * @param string $dataFileVer * * @return bool * * @throws LSCMException Thrown indirectly by Logger::info() call. * @throws LSCMException Thrown indirectly by Util::createBackup() call. * @throws LSCMException Thrown indirectly by Logger::error() call. */ public static function updateDataFile( $dataFile, $dataFileVer ) { Logger::info( "$dataFile - Old data file version detected. Attempting to " . 'update...' ); /** * Currently no versions are upgradeable to 1.5 */ $updatableVersions = []; if ( !in_array($dataFileVer, $updatableVersions) || ! Util::createBackup($dataFile) ) { Logger::error( "$dataFile - Data file could not be updated to version " . self::DATA_VERSION ); return false; } /** * Upgrade funcs will be called here. */ return true; } /** * * @param string $action * * @return string[] * * @throws LSCMException Thrown when "get docroots" command fails. * @throws LSCMException Thrown when $action value is unsupported. */ protected function prepareActionItems( $action ) { switch ( $action ) { case self::CMD_SCAN: case self::CMD_SCAN2: case self::CMD_DISCOVER_NEW: case self::CMD_DISCOVER_NEW2: try { return ControlPanel::getClassInstance()->getDocRoots(); } catch ( LSCMException $e ) { throw new LSCMException( $e->getMessage() . " Could not prepare $action action items." ); } case UserCommand::CMD_MASS_ENABLE: case UserCommand::CMD_MASS_DISABLE: case UserCommand::CMD_MASS_UPGRADE: case UserCommand::CMD_MASS_DASH_NOTIFY: case UserCommand::CMD_MASS_DASH_DISABLE: case self::CMD_MASS_UNFLAG: return $this->getPaths(); default: throw new LSCMException('Missing parameter(s).'); } } /** * * @param string $action * @param string $path * @param string[] $extraArgs * * @throws LSCMException Thrown when an invalid LSCWP version is selected * in action UserCommand::CMD_MASS_UPGRADE. * @throws LSCMException Thrown when LSCWP version fails to download in * action UserCommand::CMD_MASS_UPGRADE. * @throws LSCMException Thrown when LSCWP source package is not available * in action UserCommand::CMD_MASS_UPGRADE. * @throws LSCMException Thrown indirectly by $wpInstall->hasValidPath() * call. * @throws LSCMException Thrown indirectly by $wpInstall->addUserFlagFile() * call. * @throws LSCMException Thrown indirectly by $wpInstall->hasValidPath() * call. * @throws LSCMException Thrown indirectly by $wpInstall->refreshStatus() * call. * @throws LSCMException Thrown indirectly by $wpInstall->addUserFlagFile() * call. * @throws LSCMException Thrown indirectly by PluginVersion::getInstance() * call. * @throws LSCMException Thrown indirectly by * PluginVersion::getInstance()->getAllowedVersions() call. * @throws LSCMException Thrown indirectly by UserCommand::issue() call. * @throws LSCMException Thrown indirectly by $this->syncToDisk() call. * @throws LSCMException Thrown indirectly by $this->syncToDisk() call. */ protected function doWPInstallAction( $action, $path, array $extraArgs ) { if ( ($wpInstall = $this->getWPInstall($path)) == null ) { /** * V9.5 — for flag actions the path always originates from the * existing keyset (getPaths() canonical keys, or the single-flag * pre-validation in doSingleAction()). A miss here means the path * drifted between the time the list was built and this lookup — * indicative of a cross-tenant TOCTOU intermediate-component swap. * Constructing an unbound WPInstall would carry null * expectedOwnerUid, silently disabling Layer 2 of * addUserFlagFile() and allowing the privileged write to proceed * against an unverified (possibly swapped) path. Fail closed * instead. * * V9.6 — extend the fail-closed guard to every action that can * reach a root-context addUserFlagFile(false) on the * dispatcher-resolved object: * - CMD_ENABLE/CMD_DISABLE/CMD_DASH_NOTIFY/CMD_DASH_DISABLE can * call addUserFlagFile(false) directly when refreshStatus() * still shows a fatal error. * - All actions fed through UserCommand::issue() can call * addUserFlagFile(false) in root context via its error handler. * These paths have the same unbound-object exposure as the explicit * flag actions (§13/§14 of the fix plan). The fallback bare * construct is kept for discovery/custom install actions whose * paths do not originate from the existing keyset. * * V9.7 — the V9.6 explicit OR-list was incomplete: CMD_STATUS * (and all other issue-able commands) share the same * addUserFlagFile(false) exposure via UserCommand::issue()'s error * handler, and CMD_STATUS is reachable via the single-action UI * path (refresh_status_single). Centralise: guard every command * accepted by UserCommand::isSupportedIssueCmd() plus the explicit * flag commands so any future addition to the issue() command set * is protected automatically. * * V9.8 — CMD_UNFLAG/CMD_MASS_UNFLAG added to the guard. These * are not in isSupportedIssueCmd() (unflag is handled inline below, * not via UserCommand::issue()), so they must be listed explicitly. * An unbound WPInstall for an unflag miss would carry null * expectedOwnerUid, silently disabling the Layer 2 owner check in * the hardened removeFlagFile(false) path added in V9.8. */ if ( $action === self::CMD_FLAG || $action === self::CMD_MASS_FLAG || $action === self::CMD_UNFLAG || $action === self::CMD_MASS_UNFLAG || UserCommand::isSupportedIssueCmd($action) ) { $this->log( "Skipping $action: install path $path is not a known " . 'install (possible cross-tenant TOCTOU — ' . 'intermediate component swapped after the action ' . 'list was built).', Logger::L_INFO ); return; } $wpInstall = new WPInstall($path); $this->addWPInstall($wpInstall); } switch ( $action ) { case self::CMD_FLAG: case self::CMD_MASS_FLAG: if ( !$wpInstall->hasValidPath() ) { return; } if ( $wpInstall->addUserFlagFile(false) ) { $wpInstall->setCmdStatusAndMsg( UserCommand::EXIT_SUCC, 'Flag file set' ); } else { $wpInstall->setCmdStatusAndMsg( UserCommand::EXIT_FAIL, 'Could not create flag file' ); } $this->workingQueue[$path] = $wpInstall; return; case self::CMD_UNFLAG: case self::CMD_MASS_UNFLAG: if ( !$wpInstall->hasValidPath() ) { return; } if ( $wpInstall->removeFlagFile(false) ) { $wpInstall->setCmdStatusAndMsg( UserCommand::EXIT_SUCC, 'Flag file unset' ); } else { $wpInstall->setCmdStatusAndMsg( UserCommand::EXIT_FAIL, 'Could not remove flag file' ); } $this->workingQueue[$path] = $wpInstall; return; case UserCommand::CMD_ENABLE: case UserCommand::CMD_DISABLE: case UserCommand::CMD_DASH_NOTIFY: case UserCommand::CMD_DASH_DISABLE: if ( $wpInstall->hasFatalError() ) { $wpInstall->refreshStatus(); if ( $wpInstall->hasFatalError() ) { $wpInstall->addUserFlagFile(false); $wpInstall->setCmdStatusAndMsg( UserCommand::EXIT_FAIL, 'Install skipped and flagged due to Error status.' ); $this->workingQueue[$path] = $wpInstall; return; } } break; case UserCommand::CMD_MASS_UPGRADE: $lscwpVer = $extraArgs[1]; $isAllowedVer = in_array( $lscwpVer, PluginVersion::getInstance()->getAllowedVersions() ); if ( !$isAllowedVer ) { throw new LSCMException( 'Selected LSCWP version (' . htmlspecialchars($lscwpVer) . ') is invalid.' ); } break; //no default } if ( UserCommand::issue($action, $wpInstall, $extraArgs) ) { if ( $action == UserCommand::CMD_MASS_UPGRADE && ($wpInstall->getCmdStatus() & UserCommand::EXIT_FAIL) && preg_match( '/Download failed. Not Found/', $wpInstall->getCmdMsg() ) ) { $this->syncToDisk(); throw new LSCMException( 'Could not download version ' . htmlspecialchars($extraArgs[1]) . '.' ); } if ( $action == UserCommand::CMD_MASS_ENABLE && ($wpInstall->getCmdStatus() & UserCommand::EXIT_FAIL) && preg_match( '/Source Package not available/', $wpInstall->getCmdMsg() ) ) { $this->syncToDisk(); throw new LSCMException($wpInstall->getCmdMsg()); } $this->workingQueue[$path] = $wpInstall; } } /** * * @param string $action * @param null|string[] $list * @param string[]|string[][] $extraArgs * * @return string[] * * @throws LSCMException Thrown indirectly by $this->prepareActionItems() * call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by Context::getActionTimeout() * call. * @throws LSCMException Thrown indirectly by $this->scan() call. * @throws LSCMException Thrown indirectly by $this->addNewWPInstall() * call. * @throws LSCMException Thrown indirectly by * $this->addCustomInstallations() call. * @throws LSCMException Thrown indirectly by * PluginVersion::getCurrentVersion() call. * @throws LSCMException Thrown indirectly by PluginVersion::getInstance() * call. * @throws LSCMException Thrown indirectly by * PluginVersion::getInstance()->setActiveVersion() call. * @throws LSCMException Thrown indirectly by $this->doWPInstallAction() * call. * @throws LSCMException Thrown indirectly by $this->syncToDisk() call. */ public function doAction( $action, $list, array $extraArgs = [] ) { if ( $list === null ) { $list = $this->prepareActionItems($action); } $count = count($list); $this->log("doAction $action for $count items", Logger::L_VERBOSE); $endTime = ($count > 1) ? Context::getActionTimeout() : 0; $finishedList = []; switch ( $action ) { case self::CMD_SCAN: case self::CMD_DISCOVER_NEW: foreach ( $list as $path ) { $this->scan($path, ($action == self::CMD_SCAN)); $finishedList[] = $path; if ( $endTime && time() >= $endTime ) { break; } } break; case self::CMD_ADD_NEW_WPINSTALL: foreach ( $list as $path) { $this->addNewWPInstall($path); $finishedList[] = $path; if ( $endTime && time() >= $endTime ) { break; } } break; case self::CMD_ADD_CUST_WPINSTALLS: $this->addCustomInstallations($extraArgs[0]); break; default: if ( $action == UserCommand::CMD_ENABLE || $action == UserCommand::CMD_MASS_ENABLE ) { /** * Ensure that the current version is locally downloaded. */ PluginVersion::getInstance() ->setActiveVersion(PluginVersion::getCurrentVersion()) ; } foreach ( $list as $path ) { $this->doWPInstallAction($action, $path, $extraArgs); $finishedList[] = $path; if ( $endTime && time() >= $endTime ) { break; } } } $this->syncToDisk(); if ( $action == self::CMD_SCAN || $action == self::CMD_SCAN2 ) { /** * Explicitly clear any data file errors after scanning in case of * multiple actions performed in the same process (cli). */ $this->error = 0; } return $finishedList; } /** * * @deprecated 1.13.3 Use $this->scan2() instead. * * @since 1.17.10 Added 'find -- ' end-of-options guard. * * @param string $docroot * @param bool $forceRefresh * * @return void * * @throws LSCMException Thrown indirectly by Context::getScanDepth() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by * $this->wpInstalls[$wp_path]->refreshStatus() call. */ protected function scan( $docroot, $forceRefresh = false ) { /** * V6 (CWE-59 SSRF/symlink traversal) — use 'find -P' (the default; NOT * '-L') so find does not descend into symlinked directories. As root, * scanning a tenant-owned docroot with '-L' let a planted symlink (e.g. * public_html/x -> /root) surface a wp-admin that resolves outside the * tenant tree, steering subsequent root operations off-tree. * The '--' prevents a docroot starting with '-' from being misread as a * find option (escapeshellarg protects the shell, not find's own parser). */ $directories = shell_exec( 'find -P -- ' . escapeshellarg($docroot) . ' -maxdepth ' . (int) Context::getScanDepth() . ' -name wp-admin -print' ); $hasMatches = false; if ( $directories ) { /** * Example: * /home/user/public_html/wordpress/wp-admin * /home/user/public_html/blog/wp-admin * /home/user/public_html/wp/wp-admin */ $hasMatches = preg_match_all( '|' . preg_quote($docroot, '|') . '(.*)(?=/wp-admin)|', $directories, $matches ); } if ( ! $hasMatches ) { /** * Nothing found. */ return; } $realDocroot = realpath($docroot); /** * V9/V9.2 (CWE-59/CWE-367 cross-tenant TOCTOU) — capture the * panel-assigned docroot owner once before the loop. The docroot is * invariant across iterations; reading it here avoids repeated lstat() * calls and makes the binding available to both new and existing * installs (V9.2 fix: previously only new installs were rebound, * leaving upgraded existing installs with null Layer 2 bindings). */ $rootStat = @lstat($realDocroot); /** @noinspection PhpUndefinedVariableInspection */ foreach ( $matches[1] as $path ) { $wp_path = realpath($docroot . $path); /** * V6 — defence-in-depth: drop any match that does not canonically * resolve to a location contained under the docroot, so a symlinked * leaf component cannot redirect a root operation off-tree. * * Note: this realpath() is a snapshot, not a lock. A path component * could be swapped for a symlink after this check. That residual * race is intentionally handled at the consumer, not here: * - per-install enable/disable/upgrade/status run privilege- * dropped as the install owner (UserCommand::runAsUser), where * following an owner-planted symlink crosses no trust boundary; * - the one root-context write into the untrusted tree, * WPInstall::addUserFlagFile(), drops to install-owner * credentials (V8) before the unlink and fopen so an * intermediate-directory swap redirecting the path off-tree * fails with EACCES (CWE-59/CWE-367 closed at the consumer). */ if ( $wp_path === false || $realDocroot === false || strpos($wp_path . '/', $realDocroot . '/') !== 0 ) { $this->log( "Scan match not contained under docroot $docroot. Skipping.", Logger::L_INFO ); continue; } $refresh = $forceRefresh; if ( !isset($this->wpInstalls[$wp_path]) ) { $this->wpInstalls[$wp_path] = new WPInstall($wp_path); $refresh = true; $this->log( "New installation found: $wp_path", Logger::L_INFO ); if ( $this->custWpInstalls != null && isset($this->custWpInstalls[$wp_path]) ) { unset($this->custWpInstalls[$wp_path]); $this->log( "Installation removed from custom data file: $wp_path", Logger::L_INFO ); } } else { $this->log( "Installation already found: $wp_path", Logger::L_DEBUG ); } /** * V9/V9.2 — bind expected owner unconditionally on every scan * match, not only newly discovered installs. Existing installs * (loaded from the data file) must be rebound on each scan so * that Layer 2 (expected-owner equality check in addUserFlagFile) * fires for the root-context flag path after an upgrade or after * the first scan that discovered the install. */ if ( $rootStat !== false ) { $this->wpInstalls[$wp_path]->setExpectedOwner( $rootStat['uid'], $rootStat['gid'] ); } if ( $refresh ) { $this->wpInstalls[$wp_path]->refreshStatus(); $this->workingQueue[$wp_path] = $this->wpInstalls[$wp_path]; } } } /** * * @since 1.13.3 * @since 1.15 Changed function visibility from 'public' to * 'public static'. * @since 1.17.10 Added 'find -- ' end-of-options guard. * * @param string $docroot * * @return string[] * * @throws LSCMException Thrown indirectly by Context::getScanDepth() call. */ public static function scan2( $docroot ) { /** * V6 (CWE-59 SSRF/symlink traversal) — use 'find -P' (the default; NOT * '-L') so find does not descend into symlinked directories. As root, * scanning a tenant-owned docroot with '-L' let a planted symlink (e.g. * public_html/x -> /root) surface a wp-admin that resolves outside the * tenant tree, steering subsequent root operations off-tree. * The '--' prevents a docroot starting with '-' from being misread as a * find option (escapeshellarg protects the shell, not find's own parser). */ $directories = shell_exec( 'find -P -- ' . escapeshellarg($docroot) . ' -maxdepth ' . (int) Context::getScanDepth() . ' -name wp-admin -print' ); $hasMatches = false; if ( $directories ) { /** * Example: * /home/user/public_html/wordpress/wp-admin * /home/user/public_html/blog/wp-admin * /home/user/public_html/wp/wp-admin */ $hasMatches = preg_match_all( '|' . preg_quote($docroot, '|') . '(.*)(?=/wp-admin)|', $directories, $matches ); } if ( ! $hasMatches ) { /** * Nothing found. */ return []; } $wpPaths = []; $realDocroot = realpath($docroot); /** @noinspection PhpUndefinedVariableInspection */ foreach ( $matches[1] as $path ) { $wpPath = realpath($docroot . $path); /** * V6 — defence-in-depth: only return matches that canonically * resolve to a location contained under the docroot, so a symlinked * leaf component cannot redirect a root operation off-tree. * * Note: this realpath() is a snapshot, not a lock. A path component * could be swapped for a symlink after this check. That residual * race is intentionally handled at the consumer, not here: * - per-install enable/disable/upgrade/status run privilege- * dropped as the install owner (UserCommand::runAsUser), where * following an owner-planted symlink crosses no trust boundary; * - the one root-context write into the untrusted tree, * WPInstall::addUserFlagFile(), drops to install-owner * credentials (V8) before the unlink and fopen so an * intermediate-directory swap redirecting the path off-tree * fails with EACCES (CWE-59/CWE-367 closed at the consumer). */ if ( $wpPath === false || $realDocroot === false || strpos($wpPath . '/', $realDocroot . '/') !== 0 ) { continue; } $wpPaths[] = $wpPath; } return $wpPaths; } /** * Add a new WPInstall object to WPInstallStorage's $wpInstalls[] given a * path to a WordPress installation and refresh its status. If a WPInstall * object already exists for the given path, refresh its status. * * @since 1.13.3 * * @param string $wpPath * * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by * $this->wpInstalls[$wpPath]->refreshStatus() call. */ protected function addNewWPInstall( $wpPath ) { /** * V9.4 (CWE-59/CWE-367 cross-tenant TOCTOU) — drift-rejection guard. * * scan2() returns canonical, docroot-contained paths (produced via * realpath() + containment check). If realpath() here no longer * resolves to the same value, an intermediate directory component was * swapped (symlinked) between scan2's containment validation and this * invocation — the TOCTOU window that spans the WHM UI's multi-request * handoff (scan request stores paths in $_SESSION; discovery request * re-resolves them here). * * Fail closed: skip the add entirely so the swapped-in (victim) path * is never registered, never bound an owner from, and never flagged. * This prevents a compromised realpath() result from poisoning the V9 * Layer 2 owner binding captured two lines below. * * A legitimate scan2 path is already canonical, so realpath() is * idempotent and the equality holds with no false positives. */ $realPath = realpath($wpPath); if ( $realPath === false || $realPath !== $wpPath ) { $this->log( "Skipping add: install path $wpPath no longer canonicalises to " . 'itself (possible cross-tenant TOCTOU — intermediate ' . 'component swapped between scan2 and addNewWPInstall).', Logger::L_INFO ); return; } if ( !isset($this->wpInstalls[$wpPath]) ) { $this->wpInstalls[$wpPath] = new WPInstall($wpPath); $this->log("New installation found: $wpPath", Logger::L_INFO); if ( $this->custWpInstalls != null && isset($this->custWpInstalls[$wpPath]) ) { unset($this->custWpInstalls[$wpPath]); $this->log( "Installation removed from custom data file: $wpPath", Logger::L_INFO ); } } else { $this->log( "Installation already found: $wpPath", Logger::L_DEBUG ); } /** * V9.3 (CWE-59/CWE-367 cross-tenant TOCTOU) — bind the install * directory's owner observed in root context at this scan-time * snapshot. scan2() already canonicalised $wpPath via realpath() and * validated containment under the panel-assigned docroot before * returning it. Persisting the lstat() uid/gid here lets the later * root-context flag write (addUserFlagFile(false), in a separate * process / request) compare against a known-good snapshot rather * than a same-instant read of the very directory it is about to * modify — closing the race that Layers 1/3 leave open. * * Applied unconditionally to both newly-discovered and already-known * installs so that pre-V9.3 records hydrated with a null binding are * rebound on the next scan, mirroring §9 (V9.2)'s treatment in * scan(). lstat() is used (not stat()) so a symlinked leaf reports * the link's own inode owner rather than its target's, preventing * a same-instant final-component symlink swap from binding a * cross-tenant uid. If lstat() fails (path vanished between scan2 * and here), binding is skipped silently; the later flag write will * itself refuse on its own lstat($this->path) failure. */ $installStat = @lstat($wpPath); if ( $installStat !== false ) { $this->wpInstalls[$wpPath]->setExpectedOwner( $installStat['uid'], $installStat['gid'] ); } $this->wpInstalls[$wpPath]->refreshStatus(); $this->workingQueue[$wpPath] = $this->wpInstalls[$wpPath]; } /** * * @param string[] $wpInstallsInfo * * @return void * * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by * $this->custWpInstalls[$wpPath]->refreshStatus() call. * @throws LSCMException Thrown indirectly by $this->log() call. * @throws LSCMException Thrown indirectly by $this->log() call. */ protected function addCustomInstallations( array $wpInstallsInfo ) { if ( $this->customDataFile == '' ) { $this->log( 'No custom data file set, could not add custom Installation.', Logger::L_INFO ); return; } if ( $this->custWpInstalls == null ) { $this->custWpInstalls = []; } for ( $i = 0; $i < count($wpInstallsInfo); $i++ ) { $info = preg_split('/\s+/', trim($wpInstallsInfo[$i])); $line = $i + 1; if ( count($info) != 4 ) { $this->log( 'Incorrect number of values for custom installation input ' . "string on line $line. Skipping.", Logger::L_INFO ); continue; } $wpPath = $info[0]; if ( !Util::isSafeAbsPath($wpPath) ) { $this->log( "Unsafe wpPath value on line $line. Skipping.", Logger::L_INFO ); continue; } if ( !file_exists("$wpPath/wp-admin") ) { $this->log( "No 'wp-admin' directory found for $wpPath on line " . "$line. Skipping.", Logger::L_INFO ); continue; } $docroot = $info[1]; if ( !Util::isSafeAbsPath($docroot) ) { $this->log( "Unsafe docroot value on line $line. Skipping.", Logger::L_INFO ); continue; } $realWpPath = realpath($wpPath); $realDocroot = realpath($docroot); if ( $realWpPath === false || $realDocroot === false || strpos($realWpPath . '/', $realDocroot . '/') !== 0 ) { $this->log( "wpPath $wpPath not contained under docroot $docroot on " . "line $line. Skipping.", Logger::L_INFO ); continue; } if ( !isset($this->wpInstalls[$wpPath]) ) { $this->custWpInstalls[$wpPath] = new WPInstall($wpPath); $this->custWpInstalls[$wpPath]->setDocRoot($docroot); $this->custWpInstalls[$wpPath]->setServerName($info[2]); $this->custWpInstalls[$wpPath]->setSiteUrlDirect($info[3]); $this->custWpInstalls[$wpPath]->refreshStatus(); $this->log( "New installation added to custom data file: $wpPath", Logger::L_INFO ); } else { $this->log( "Installation already found during scan: $wpPath. " . 'Skipping.', Logger::L_INFO ); } } } /** * Get all WPInstall command messages as a key=>value array. * * @return string[][] */ public function getAllCmdMsgs() { $succ = $fail = $err = []; foreach ( $this->workingQueue as $wpInstall ) { if ( ($msg = $wpInstall->getCmdMsg()) ) { $cmdStatus = $wpInstall->getCmdStatus(); switch( true ) { case $cmdStatus & UserCommand::EXIT_SUCC: $msgType = &$succ; break; case $cmdStatus & UserCommand::EXIT_FAIL: $msgType = &$fail; break; case $cmdStatus & UserCommand::EXIT_ERROR: $msgType = &$err; break; default: continue 2; } $msgType[] = "{$wpInstall->getPath()} - $msg"; } } return [ 'succ' => $succ, 'fail' => $fail, 'err' => $err ]; } /** * * @param string $msg * @param int $level * * @throws LSCMException Thrown indirectly by Logger::error() call. * @throws LSCMException Thrown indirectly by Logger::warn() call. * @throws LSCMException Thrown indirectly by Logger::notice() call. * @throws LSCMException Thrown indirectly by Logger::info() call. * @throws LSCMException Thrown indirectly by Logger::verbose() call. * @throws LSCMException Thrown indirectly by Logger::debug() call. */ protected function log( $msg, $level ) { $msg = "WPInstallStorage - $msg"; switch ( $level ) { case Logger::L_ERROR: Logger::error($msg); break; case Logger::L_WARN: Logger::warn($msg); break; case Logger::L_NOTICE: Logger::notice($msg); break; case Logger::L_INFO: Logger::info($msg); break; case Logger::L_VERBOSE: Logger::verbose($msg); break; case Logger::L_DEBUG: Logger::debug($msg); break; //no default } } }
Save File
Cancel