CVE-2026-2721

MailArchiver <= 4.4.0 - Authenticated (Administrator+) Stored Cross-Site Scripting via Settings

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
4.8
CVSS Score
4.8
CVSS Score
medium
Severity
4.5.0
Patched in
1d
Time to patch

Description

The MailArchiver plugin for WordPress is vulnerable to Stored Cross-Site Scripting via admin settings in all versions up to, and including, 4.4.0 due to insufficient input sanitization and output escaping. This makes it possible for authenticated attackers, with administrator-level permissions and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page. This only affects multi-site installations and installations where unfiltered_html has been disabled.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
High
User Interaction
Required
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=4.4.0
PublishedMarch 6, 2026
Last updatedMarch 7, 2026
Affected pluginmailarchiver

What Changed in the Fix

Changes introduced in v4.5.0

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This plan outlines the research and exploitation process for **CVE-2026-2721**, a Stored Cross-Site Scripting (XSS) vulnerability in the MailArchiver plugin for WordPress. ### 1. Vulnerability Summary The MailArchiver plugin (versions <= 4.4.0) contains a Stored XSS vulnerability within its adminis…

Show full research plan

This plan outlines the research and exploitation process for CVE-2026-2721, a Stored Cross-Site Scripting (XSS) vulnerability in the MailArchiver plugin for WordPress.

1. Vulnerability Summary

The MailArchiver plugin (versions <= 4.4.0) contains a Stored XSS vulnerability within its administrative settings. The vulnerability arises because the plugin's internal form-rendering engine (found in the Mailarchiver\System\Form class) fails to escape existing option values when generating HTML input and textarea fields. Specifically, values retrieved from the database are concatenated directly into HTML attributes or tags without using functions like esc_attr() or esc_html().

While this typically requires Administrator privileges, it is considered a vulnerability because it allows XSS in environments where unfiltered_html is disabled (e.g., WordPress Multisite or hardened single-site installations), bypassing the intended security restrictions.

2. Attack Vector Analysis

  • Endpoint: wp-admin/admin.php?page=mailarchiver-settings (and potentially archiver configuration sub-pages).
  • Vulnerable Parameter: Likely the to field or any text-based setting field within an "Archiver" configuration.
  • Authentication Level: Administrator (specifically, a user with manage_options capability).
  • Preconditions: The site must be a Multisite installation or have define( 'DISALLOW_UNFILTERED_HTML', true ); in wp-config.php.

3. Code Flow

  1. Entry Point: The Administrator navigates to the settings page registered in Mailarchiver_Admin::init_perfopsone_admin_menus() with the slug mailarchiver-settings.
  2. Data Retrieval: The plugin retrieves stored settings using get_option().
  3. Vulnerable Sink (Output): The retrieved (and potentially malicious) string is passed to methods in includes/system/class-form.php.
    • Mailarchiver\System\Form::field_input_text($id, $value, ...):
      $html = '<input ... value="' . $value . '"' . $width . '/>'; // $value is not escaped
      
    • Mailarchiver\System\Form::field_input_textarea($id, $value, ...):
      $html = '<textarea ...>' . $value . '</textarea>'; // $value is not escaped
      
  4. Execution: When the settings page is rendered, the browser interprets the unescaped payload (e.g., "><script>alert(1)</script>) as executable code.

4. Nonce Acquisition Strategy

The settings page is part of the WordPress admin dashboard and uses standard WordPress nonces for protection against CSRF. To exploit this as an Administrator:

  1. Navigate to the MailArchiver settings page: /wp-admin/admin.php?page=mailarchiver-settings.
  2. The plugin uses the PerfOpsOne framework for menus. The nonce for saving settings is likely provided in a hidden input field within the settings form.
  3. JS Strategy: Use browser_eval to extract the nonce:
    // Example for a standard WordPress settings nonce
    document.querySelector('input[name="_wpnonce"]')?.value;
    // Or check for localized data if the plugin uses AJAX for saving
    window.mailarchiver_admin?.nonce; 
    

5. Exploitation Strategy

The goal is to inject a payload into a setting field (specifically the "to" field mentioned in the changelog) and verify its execution.

Step-by-Step Plan:

  1. Authentication: Log in to the WordPress admin panel with Administrator credentials.
  2. Navigate to Target: Open /wp-admin/admin.php?page=mailarchiver-settings.
  3. Identify Field: Locating the "Archivers" section. According to the readme.txt and CHANGELOG.md, the vulnerability is in the "to" field of an archiver (likely an IMAP or Email archiver type).
  4. Inject Payload:
    • Field Name (Inferred): to or mailarchiver_archiver_to.
    • Payload: "><script>alert(document.domain)</script>
  5. Submit Form: Use http_request to send a POST request to wp-admin/admin.php?page=mailarchiver-settings (or the relevant admin-post.php handler if the plugin uses one).
    • Content-Type: application/x-www-form-urlencoded
    • Body: Include _wpnonce, action (if applicable), and the payload in the identified field.
  6. Verify Storage: Navigate back to the settings page.
  7. Trigger Execution: The alert should trigger upon page load.

6. Test Data Setup

  1. Ensure a user with administrator role exists.
  2. Crucial: Disable unfiltered_html for the test.
    • In wp-config.php: define( 'DISALLOW_UNFILTERED_HTML', true );.
  3. The MailArchiver plugin must be active.
  4. If the "to" field is only visible for specific archiver types (e.g., IMAP), create/activate an IMAP archiver first via wp-cli:
    wp m-archive archiver add --type=imap --name=TestArchiver
    

7. Expected Results

  • After submitting the form, the database will store the literal string "><script>alert(document.domain)</script> in the wp_options table (or archiver configuration meta).
  • Upon reloading the settings page, the HTML source will contain:
    <input ... value=""><script>alert(document.domain)</script>" />
  • The browser will execute the script and display an alert box.

8. Verification Steps

  1. Browser Verification: Use browser_navigate to the settings page and check for the presence of the alert or the injected script in the DOM.
  2. Database Verification: Use wp-cli to check the stored option:
    wp option get mailarchiver_settings --format=json
    # Or search specifically for the archiver configuration
    wp db query "SELECT option_value FROM wp_options WHERE option_name LIKE '%mailarchiver%';"
    

9. Alternative Approaches

If the "to" field is not immediately accessible on the main settings page:

  1. Check Archiver Types: Look for the "Pushover" or "Slack" archivers mentioned in the changelog, as they often have recipient/token fields.
  2. Check the "Viewer": The changelog also mentions SEC005 fixed XSS in the "to" field of the viewer. Navigate to wp-admin/admin.php?page=mailarchiver-viewer and check how the "To" address of an archived email is rendered. If a sent email with a malicious "To" name is archived, it might trigger XSS in the viewer.
    • Payload for Viewer: Send an email with To: "><script>alert(1)</script> <test@example.com>.
    • Requirement: The plugin must be configured to archive emails to the internal viewer.
Research Findings
Static analysis — not yet PoC-verified

Summary

The MailArchiver plugin for WordPress is vulnerable to Stored Cross-Site Scripting via its administrative settings in versions up to 4.4.0. This occurs because the plugin's internal form-rendering engine fails to escape existing option values when generating HTML for input, password, and textarea fields. Authenticated attackers with administrator-level permissions can inject arbitrary web scripts that execute whenever a user accesses the settings page, even in environments where unfiltered_html is disabled.

Vulnerable Code

// includes/system/class-form.php

public function field_input_text( $id, $value = '', $description = null, $full_width = true, $enabled = true ) {
    if ( $full_width ) {
        $width = ' style="width:100%;"';
    } else {
        $width = '';
    }
    $html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="text" id="' . $id . '" value="' . $value . '"' . $width . '/>';
    if ( isset( $description ) ) {
        $html .= '<p class="description">' . $description . '</p>';
    }
    return $html;
}

---

public function field_input_password( $id, $value = '', $description = null, $full_width = true, $enabled = true ) {
    if ( $full_width ) {
        $width = ' style="width:100%;"';
    } else {
        $width = '';
    }
    $html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="password" id="' . $id . '" value="' . $value . '"' . $width . '/>';
    if ( isset( $description ) ) {
        $html .= '<p class="description">' . $description . '</p>';
    }
    return $html;
}

---

public function field_input_textarea( $id, $value = '', $description = null, $columns = 80, $lines = 3, $enabled = true ) {
    $html = '<textarea' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" id="' . $id . '" cols="' . $columns . '" rows="' . $lines . '" class="regular-text code">' . $value . '</textarea>';
    if ( isset( $description ) ) {
        $html .= '<p class="description">' . $description . '</p>';
    }
    return $html;
}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.4.0/admin/class-mailarchiver-admin.php /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.5.0/admin/class-mailarchiver-admin.php
--- /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.4.0/admin/class-mailarchiver-admin.php	2023-07-12 10:14:12.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.5.0/admin/class-mailarchiver-admin.php	2026-02-19 12:56:56.000000000 +0000
@@ -71,6 +71,14 @@
 	protected $current_view = null;
 
 	/**
+	 * Password already set "flag".
+	 *
+	 * @since  4.5.0
+	 * @var    string    $password_set    The "flag".
+	 */
+	private $password_set = 'password already set';
+
+	/**
 	 * Initialize the class and set its properties.
 	 *
 	 * @since 1.0.0
@@ -575,8 +583,15 @@
 					$this->current_archiver['privacy']['pseudonymization']  = ( array_key_exists( 'mailarchiver_archiver_privacy_name', $_POST ) ? true : false );
 					$this->current_archiver['privacy']['mailanonymization'] = ( array_key_exists( 'mailarchiver_archiver_privacy_mail', $_POST ) ? true : false );
 					$this->current_archiver['security']['xss']              = ( array_key_exists( 'mailarchiver_archiver_security_xss', $_POST ) ? true : false );
-					$this->current_archiver['privacy']['encryption']        = ( array_key_exists( 'mailarchiver_archiver_privacy_encryption', $_POST ) ? Secret::set( filter_input( INPUT_POST, 'mailarchiver_archiver_privacy_encryption', FILTER_UNSAFE_RAW ) ) : '' );
-					$this->current_archiver['processors']                   = [];
+					if ( array_key_exists( 'mailarchiver_archiver_privacy_encryption', $_POST ) ) {
+						$key = filter_input( INPUT_POST, 'mailarchiver_archiver_privacy_encryption', FILTER_UNSAFE_RAW );
+						if ( $key !== $this->password_set) {
+							$this->current_archiver['privacy']['encryption'] = Secret::set( $key );
+						}
+					} else {
+						$this->current_archiver['privacy']['encryption'] = '';
+					}
+					$this->current_archiver['processors'] = [];
 					$proc = new ProcessorTypes();
 					foreach ( array_reverse( $proc->get_all() ) as $processor ) {
 						if ( array_key_exists( 'mailarchiver_archiver_details_' . strtolower( $processor['id'] ), $_POST ) ) {
@@ -1103,11 +1118,24 @@
 			]
 		);
 		register_setting( 'mailarchiver_archiver_privacy_section', 'mailarchiver_archiver_privacy_mail' );
-		if ( PwdProtect::is_available() ) {
-			$description  = esc_html__( 'Note: this is NOT a strong security feature; it\'s just a simple way to protect privacy in case of data leaks from external services. Think about it as a simple "password protection", with the password stored in plain text in your WordPress database.', 'mailarchiver' );
-			$description .= '<br/>' . esc_html__( 'Encryption used:', 'mailarchiver' ) . ' ' . PwdProtect::get_encryption_details();
+
+
+		if ( '' === $this->current_archiver['privacy']['encryption'] ) {
+			if ( PwdProtect::is_available() ) {
+				$description  = esc_html__( 'Note: this is NOT a strong security feature; it\'s just a simple way to protect privacy in case of data leaks from external services. Think about it as a simple "password protection", with the password stored in plain text in your WordPress database.', 'mailarchiver' );
+				$description .= '<br/>' . esc_html__( 'Encryption used:', 'mailarchiver' ) . ' ' . PwdProtect::get_encryption_details();
+			} else {
+				$description = esc_html__( 'Your server does not have OpenSSL installed. Mail body encryption is unavailable.', 'mailarchiver' );
+			}
+			$description = esc_html__( 'Key used to encrypt mail body: once set, you can\'t change it. Let blank to not encrypt it.', 'mailarchiver' ) . '<br/>' . $description;
+			enabled = PwdProtect::is_available();
+			$value = PwdProtect::is_available() ? Secret::get( $this->current_archiver['privacy']['encryption'] ) : '';
+			$readonly = false;
 		} else {
-			$description = esc_html__( 'Your server does not have OpenSSL installed. Mail body encryption is unavailable.', 'mailarchiver' );
+			$description = esc_html__( 'The key is already set. You can\'t change it.', 'mailarchiver' );
+			enabled = true;
+			$readonly = true;
+			$value = $this->password_set;
 		}
 		add_settings_field(
 			'mailarchiver_archiver_privacy_encryption',
@@ -1117,10 +1145,11 @@
 			'mailarchiver_archiver_privacy_section',
 			[
 				'id'          => 'mailarchiver_archiver_privacy_encryption',
-				'value'       => PwdProtect::is_available() ? Secret::get( $this->current_archiver['privacy']['encryption'] ) : '',
-				'description' => esc_html__( 'Key used to encrypt mail body. Let blank to not encrypt it.', 'mailarchiver' ) . '<br/>' . $description,
+				'value'       => $value,
+				'description' => $description,
 				'full_width'  => false,
-				'enabled'     => PwdProtect::is_available(),
+				'enabled'     => $enabled,
+				'readonly'    => $readonly,
 			]
 		);
 		register_setting( 'mailarchiver_archiver_privacy_section', 'mailarchiver_archiver_privacy_encryption' );
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.4.0/includes/system/class-form.php /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.5.0/includes/system/class-form.php
--- /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.4.0/includes/system/class-form.php	2021-09-29 17:42:44.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mailarchiver/4.5.0/includes/system/class-form.php	2026-02-19 12:56:56.000000000 +0000
@@ -113,17 +113,18 @@
 	 * @param   string  $value  The string to put in the text field.
 	 * @param   string  $description    Optional. A description to display.
 	 * @param   boolean $full_width     Optional. Is the control full width?
-	 * @param   boolean $enabled     Optional. Is the control enabled?
+	 * @param   boolean $enabled        Optional. Is the control enabled?
+	 * @param   boolean $readonly       Optional. Is the control readonly?
 	 * @return  string The HTML string ready to print.
 	 * @since   1.0.0
 	 */
-	public function field_input_password( $id, $value = '', $description = null, $full_width = true, $enabled = true ) {
+	public function field_input_password( $id, $value = '', $description = null, $full_width = true, $enabled = true, $readonly = false ) {
 		if ( $full_width ) {
 			$width = ' style="width:100%;"';
 		} else {
 			$width = '';
 		}
-		$html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="password" id="' . $id . '" value="' . $value . '"' . $width . '/>';
+		$html = '<input' . ( $enabled ? '' : ' disabled' ) . ' name="' . $id . '" type="password" id="' . $id . '" value="' . $value . '"' . $width . ' ' . ( $readonly ? 'readonly' : '' ) . '/>';
 		if ( isset( $description ) ) {
 			$html .= '<p class="description">' . $description . '</p>';
 		}
@@ -137,7 +138,7 @@
 	 * @since   1.0.0
 	 */
 	public function echo_field_input_password( $args ) {
-		echo $this->field_input_password( $args['id'], $args['value'], $args['description'], $args['full_width'], $args['enabled'] );
+		echo $this->field_input_password( $args['id'], $args['value'], $args['description'], $args['full_width'], $args['enabled'], $args['readonly'] ?? false );
 	}

Exploit Outline

To exploit this vulnerability, an attacker with Administrator-level access must navigate to the plugin's settings page (wp-admin/admin.php?page=mailarchiver-settings). The attacker identifies a vulnerable configuration field, such as the 'To' field in an archiver configuration or the 'encryption key' in the privacy section. By injecting a payload like "><script>alert(document.domain)</script> and saving the settings, the malicious code is stored in the database. Because the Form class fails to escape values when rendering HTML fields, the script will execute automatically in the context of any user (typically other administrators) who visits the settings page.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.