Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/worker/init.php
+++ b/worker/init.php
@@ -3,7 +3,7 @@
Plugin Name: ManageWP - Worker
Plugin URI: https://managewp.com
Description: We help you efficiently manage all your WordPress websites. <strong>Updates, backups, 1-click login, migrations, security</strong> and more, on one dashboard. This service comes in two versions: standalone <a href="https://managewp.com">ManageWP</a> service that focuses on website management, and <a href="https://godaddy.com/pro">GoDaddy Pro</a> that includes additional tools for hosting, client management, lead generation, and more.
-Version: 4.9.31
+Version: 4.9.32
Author: GoDaddy
Author URI: https://godaddy.com
License: GPL2
@@ -575,8 +575,8 @@
// reason (eg. the site can't ping itself). Handle that case early.
register_activation_hook(__FILE__, 'mwp_activation_hook');
- $GLOBALS['MMB_WORKER_VERSION'] = '4.9.31';
- $GLOBALS['MMB_WORKER_REVISION'] = '2026-03-10 00:00:00';
+ $GLOBALS['MMB_WORKER_VERSION'] = '4.9.32';
+ $GLOBALS['MMB_WORKER_REVISION'] = '2026-03-18 00:00:00';
// Ensure PHP version compatibility.
if (version_compare(PHP_VERSION, '5.2', '<')) {
--- a/worker/src/MWP/Action/DownloadFile.php
+++ b/worker/src/MWP/Action/DownloadFile.php
@@ -16,8 +16,58 @@
{
$requestedFiles = $params['files'];
- if (count($params['files']) > 1 || is_dir($requestedFiles[0])) {
- $requestedFile = $this->archiveFiles($params['files']);
+ // Validate that every requested path sits within the WordPress installation
+ // root (ABSPATH). This prevents path traversal attacks where a crafted path
+ // like ../../etc/passwd could escape the intended directory boundary.
+ //
+ // We deliberately avoid realpath() for the boundary check because it follows
+ // symlinks, which would block legitimate sites that symlink directories outside
+ // ABSPATH (e.g. an uploads folder pointing to network storage). Instead we
+ // collapse . and .. via string operations only, preserving intentional symlinks.
+ // realpath() is still called afterwards, but only to verify the file exists —
+ // its resolved value is not used for the security comparison.
+ //
+ // DIRECTORY_SEPARATOR is appended to $allowedBase so that a sibling path like
+ // /var/www/html-other cannot pass a prefix check intended for /var/www/html.
+ $allowedBase = rtrim(ABSPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+
+ // Collect normalised paths so that all file operations below use the
+ // same values that were security-checked. Using the raw input after
+ // validation (validate-then-use-different-value) would be unsafe.
+ $normalisedFiles = array();
+ foreach ($requestedFiles as $filePath) {
+ // Make relative paths absolute so the boundary check works correctly.
+ if (!path_is_absolute($filePath)) {
+ $filePath = ABSPATH . $filePath;
+ }
+
+ // Collapse . and .. segments without following symlinks.
+ $parts = explode('/', str_replace('\', '/', $filePath));
+ $normalised = array();
+ foreach ($parts as $part) {
+ if ($part === '..') {
+ array_pop($normalised);
+ } elseif ($part !== '' && $part !== '.') {
+ $normalised[] = $part;
+ }
+ }
+ $normalisedPath = DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $normalised);
+
+ // Boundary check against the .. -clean path (symlinks left intact).
+ if (strpos($normalisedPath . DIRECTORY_SEPARATOR, $allowedBase) !== 0) {
+ return array('message' => self::DOWNLOAD_FAILED);
+ }
+
+ // Verify the file actually exists on disk.
+ if (realpath($filePath) === false) {
+ return array('message' => self::DOWNLOAD_FAILED);
+ }
+
+ $normalisedFiles[] = $normalisedPath;
+ }
+
+ if (count($normalisedFiles) > 1 || is_dir($normalisedFiles[0])) {
+ $requestedFile = $this->archiveFiles($requestedFiles);
} else {
$requestedFile = $requestedFiles[0];
}
--- a/worker/src/MWP/Action/IncrementalBackup/ChecksumTables.php
+++ b/worker/src/MWP/Action/IncrementalBackup/ChecksumTables.php
@@ -13,7 +13,15 @@
public function execute(array $params = array(), MWP_Worker_Request $request)
{
- $tables = array_map(array($this, 'escapeName'), $params['query']);
+ // escapeName() validates and escapes each table name. Filter out any
+ // names that fail validation (returns null) to avoid injecting nulls
+ // into the query.
+ $tables = array_filter(array_map(array($this, 'escapeName'), $params['query']));
+
+ if (empty($tables)) {
+ return $this->createResult(array('checksum' => array(), 'db' => $this->container->getWordPressContext()->getConstant('DB_NAME')));
+ }
+
$query = implode(',', $tables);
$wpdb = $this->container->getWordPressContext()->getDb();
@@ -29,6 +37,20 @@
public function escapeName($tableName)
{
- return "`{$tableName}`";
+ // Validate that the table name contains only characters that are valid
+ // in MySQL identifiers: letters, digits, underscores, and dollar signs.
+ // Dots are intentionally excluded: wrapping "db.table" in a single pair
+ // of backticks produces the literal identifier `db.table` rather than
+ // the qualified `db`.`table` that MySQL expects. Callers always supply
+ // unqualified table names so dot support is not needed.
+ if (!preg_match('/^[a-zA-Z0-9_$]+$/', $tableName)) {
+ return null;
+ }
+
+ // Double any backtick characters within the name as per the MySQL
+ // standard for escaping identifier delimiters. This is defence-in-depth:
+ // the regex above already rejects backticks, but explicit escaping
+ // ensures safety if the validation rule is ever relaxed.
+ return '`' . str_replace('`', '``', $tableName) . '`';
}
}
--- a/worker/src/MWP/EventListener/MasterRequest/AuthenticateServiceRequest.php
+++ b/worker/src/MWP/EventListener/MasterRequest/AuthenticateServiceRequest.php
@@ -46,6 +46,8 @@
}
$algorithm = $request->getSignatureAlgorithm();
+ // Sanitize algorithm to prevent XSS when displayed in debug output
+ $sanitizedAlgorithm = sanitize_text_field($algorithm);
if ($algorithm == 'SHA256') {
$serviceSignature = $request->getServiceSignatureV2();
@@ -56,9 +58,11 @@
}
$keyName = $request->getKeyName();
+ // Sanitize key name to prevent XSS when displayed in debug output
+ $sanitizedKeyName = sanitize_text_field($keyName);
if (empty($serviceSignature) || empty($keyName)) {
- $this->context->optionSet('mwp_last_communication_error', 'Unexpected: service signature or key name are empty. Key name: '.$keyName.', Signature: '.$serviceSignature.', Algorithm: '.($algorithm ? $algorithm : 'SHA1'));
+ $this->context->optionSet('mwp_last_communication_error', 'Unexpected: service signature or key name are empty. Key name: '.$sanitizedKeyName.', Signature: '.$serviceSignature.', Algorithm: '.($sanitizedAlgorithm ? $sanitizedAlgorithm : 'SHA1'));
return;
}
@@ -67,7 +71,7 @@
if (empty($publicKey)) {
// for now do not throw an exception, just do not authenticate the request
// later we should start throwing an exception here when this becomes the main communication method
- $this->context->optionSet('mwp_last_communication_error', 'Could not find the appropriate communication key. Searched for: '.$keyName);
+ $this->context->optionSet('mwp_last_communication_error', 'Could not find the appropriate communication key. Searched for: '.$sanitizedKeyName);
return;
}
@@ -75,7 +79,7 @@
$messageToCheck = '';
if (empty($communicationKey)) {
- $this->context->optionSet('mwp_last_communication_error', 'Unexpected: communication key is empty. Key name: '.$keyName);
+ $this->context->optionSet('mwp_last_communication_error', 'Unexpected: communication key is empty. Key name: '.$sanitizedKeyName);
return;
}
@@ -88,7 +92,7 @@
if (empty($messageToCheck)) {
// for now do not throw an exception, just do not authenticate the request
// later we should start throwing an exception here when this becomes the main communication method
- $this->context->optionSet('mwp_last_communication_error', 'Unexpected: message to check is empty. Host: '.$request->server['HTTP_HOST']);
+ $this->context->optionSet('mwp_last_communication_error', 'Unexpected: message to check is empty. Host: '.sanitize_text_field($request->server['HTTP_HOST']));
return;
}
@@ -101,7 +105,7 @@
if (!$verify) {
// for now do not throw an exception, just do not authenticate the request
// later we should start throwing an exception here when this becomes the main communication method
- $this->context->optionSet('mwp_last_communication_error', 'Message signature invalid. Tried to verify: '.$messageToCheck.', Signature: '.base64_encode($serviceSignature));
+ $this->context->optionSet('mwp_last_communication_error', 'Message signature invalid. Tried to verify: '.base64_encode($messageToCheck).', Signature: '.base64_encode($serviceSignature));
return;
}
--- a/worker/src/MWP/EventListener/PublicRequest/AddConnectionKeyInfo.php
+++ b/worker/src/MWP/EventListener/PublicRequest/AddConnectionKeyInfo.php
@@ -344,7 +344,7 @@
$time = time();
foreach ($communicationKeys as $siteId => $communicationKey) { ?>
<tr>
- <td><?php echo $siteId !== 'any' ? $siteId : '*'; ?></td>
+ <td><?php echo esc_html($siteId !== 'any' ? $siteId : '*'); ?></td>
<td><?php
if ($communicationKey['added'] != null) {
/** @handled function */
@@ -368,7 +368,7 @@
} ?>
</td>
<td style="text-align: right">
- <a href="<?php echo $this->context->wpNonceUrl($this->context->getAdminUrl('plugins.php?worker_connections=1&action=mwp_deactivate_key&connection_id='.$siteId), 'mwp_deactivation_key', 'mwp_nonce'); ?>">
+ <a href="<?php echo esc_url($this->context->wpNonceUrl($this->context->getAdminUrl('plugins.php?worker_connections=1&action=mwp_deactivate_key&connection_id='.urlencode($siteId)), 'mwp_deactivation_key', 'mwp_nonce')); ?>">
<?php
/** @handled function */
echo esc_html__('Disconnect', 'worker'); ?>
@@ -410,21 +410,28 @@
if ($refreshedKeys['success'] === true) {
echo 'Keys successfully refreshed!';
} else {
- echo 'Keys were not successfully refreshed. Error: '.$refreshedKeys['message'];
+ // Escape the error message before output to prevent XSS.
+ // $refreshedKeys['message'] is built from raw OS/network data (e.g. PHP's
+ // $errstr from stream_socket_client, DNS resolution strings, or server
+ // response content) inside mwp_get_public_keys_from_live_fallback().
+ // None of that input is sanitized at the source, so it must be escaped
+ // here before being rendered into the admin page HTML.
+ echo 'Keys were not successfully refreshed. Error: '.esc_html($refreshedKeys['message']);
} ?>
</p>
<p>
- <?php echo 'Last communication error: '.$this->context->optionGet('mwp_last_communication_error', '') ?>
+ <?php echo 'Last communication error: '.esc_html($this->context->optionGet('mwp_last_communication_error', '')) ?>
</p>
<p><?php
/** @handled function */
echo esc_html__('Currently loaded keys:', 'worker'); ?>
</p>
<pre><?php
+ $publicKeys = $this->context->optionGet('mwp_public_keys', null);
if (version_compare(PHP_VERSION, '5.4', '>=') && defined('JSON_PRETTY_PRINT')) {
- echo trim(json_encode($this->context->optionGet('mwp_public_keys', null), JSON_PRETTY_PRINT));
+ echo esc_html(trim(json_encode($publicKeys, JSON_PRETTY_PRINT)));
} else {
- echo trim(json_encode($this->context->optionGet('mwp_public_keys', null)));
+ echo esc_html(trim(json_encode($publicKeys)));
}
?></pre>
<?php
--- a/worker/src/MWP/EventListener/PublicRequest/BrandContactSupport.php
+++ b/worker/src/MWP/EventListener/PublicRequest/BrandContactSupport.php
@@ -154,7 +154,21 @@
<div id="mwp_support_dialog" style="display: none;">
<?php if (!empty($contactText)): ?>
<div>
- <p><?php echo $contactText ?></p>
+ <p><?php
+ // $contactText is brand-provided and may contain links and basic
+ // formatting. We use wp_kses() rather than esc_html() so that
+ // legitimate markup (anchor tags, bold/italic, line breaks) is
+ // preserved while any executable or dangerous elements are stripped.
+ echo wp_kses($contactText, array(
+ 'a' => array('href' => array(), 'target' => array(), 'rel' => array()),
+ 'b' => array(),
+ 'strong' => array(),
+ 'em' => array(),
+ 'i' => array(),
+ 'br' => array(),
+ 'span' => array('class' => array()),
+ ));
+ ?></p>
</div>
<?php endif ?>
<?php if ($contactType == MWP_Worker_Brand::CONTACT_TYPE_TEXT_PLUS_FORM): ?>