CVE-2026-2352

Autoptimize <= 3.1.14 - Authenticated (Contributor+) Stored Cross-Site Scripting via 'ao_post_preload' Meta Value

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

Description

The Autoptimize plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the 'ao_post_preload' meta value in all versions up to, and including, 3.1.14. This is due to insufficient input sanitization in the `ao_metabox_save()` function and missing output escaping when the value is rendered into a `<link>` tag in `autoptimizeImages.php`. This makes it possible for authenticated attackers, with Contributor-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page, granted the "Image optimization" or "Lazy-load images" setting is enabled in the plugin configuration.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=3.1.14
PublishedMarch 20, 2026
Last updatedMarch 20, 2026
Affected pluginautoptimize

What Changed in the Fix

Changes introduced in v3.1.15

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Research Plan: Autoptimize <= 3.1.14 - Authenticated Stored XSS via 'ao_post_preload' ## 1. Vulnerability Summary The Autoptimize plugin is vulnerable to Stored Cross-Site Scripting (XSS) due to insufficient sanitization and escaping of the `ao_post_preload` meta value. This value is intended to …

Show full research plan

Research Plan: Autoptimize <= 3.1.14 - Authenticated Stored XSS via 'ao_post_preload'

1. Vulnerability Summary

The Autoptimize plugin is vulnerable to Stored Cross-Site Scripting (XSS) due to insufficient sanitization and escaping of the ao_post_preload meta value. This value is intended to store the URL of a Largest Contentful Paint (LCP) image to be preloaded. An authenticated attacker with at least Contributor-level access can inject a malicious script into this meta field. When the plugin renders the LCP preload link in the <head> of the affected page (via autoptimizeImages.php), the payload is executed in the context of the user's browser.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/post.php
  • Action: editpost (standard WordPress post update)
  • Vulnerable Parameter: ao_post_preload
  • Authentication Level: Contributor or higher (any role capable of editing posts/pages).
  • Preconditions:
    • The "Image optimization" or "Lazy-load images" setting must be enabled in the plugin configuration.
    • The ao_metabox_save() function must be triggered by saving a post or page.

3. Code Flow

  1. Input (Storage):
    • A user with edit_posts capability (Contributor+) edits a post/page.
    • The autoptimizeMetabox::ao_metabox_content() function renders a text input field named ao_post_preload in the Autoptimize meta box.
    • Upon saving the post, the save_post action triggers autoptimizeMetabox::ao_metabox_save().
    • ao_metabox_save() retrieves $_POST['ao_post_preload'] and saves it using update_post_meta() without adequate sanitization (e.g., it fails to use esc_url_raw or sanitize_text_field properly to prevent HTML injection).
  2. Output (Execution):
    • When a user views the published post/page, the plugin's frontend image optimization logic in classes/autoptimizeImages.php (hooked to wp_head or similar via run_on_frontend) is executed.
    • The code retrieves the ao_post_preload meta value.
    • The value is echoed directly into a <link rel="preload" as="image" href="..."> tag without using esc_url() or esc_attr().
    • The injected script executes.

4. Nonce Acquisition Strategy

The ao_metabox_save() function validates a nonce named ao_metabox_nonce with the action string ao_metabox. This nonce is automatically included in the post editor screen for any post type supported by Autoptimize.

  1. Log in as a Contributor.
  2. Navigate to the "Add New Post" page: /wp-admin/post-new.php.
  3. Use browser_eval to extract the nonce from the hidden input field:
    document.querySelector('input[name="ao_metabox_nonce"]').value
    

5. Exploitation Strategy

  1. Setup Configuration: Ensure "Image optimization" is active (requires Admin).
  2. Preparation: Log in as a Contributor and create a draft post to obtain a post_ID and the ao_metabox_nonce.
  3. Injection: Perform a POST request to /wp-admin/post.php to update the post with the XSS payload in ao_post_preload.
  4. Verification: Navigate to the frontend URL of the post and verify the payload renders in the <head>.

Payload

To break out of the href attribute of the <link> tag:
"><script>alert(document.domain)</script>

HTTP Request (Injection)

  • Method: POST
  • URL: {{BASE_URL}}/wp-admin/post.php
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body Parameters:
    • action: editpost
    • post_ID: {{POST_ID}}
    • ao_metabox_nonce: {{NONCE}}
    • ao_post_preload: "><script>alert(document.domain)</script>
    • post_title: XSS Test
    • post_type: post (or page)

6. Test Data Setup

  1. Plugin Activation: Ensure Autoptimize is installed and active.
  2. Plugin Config: Enable image optimization via WP-CLI:
    wp option patch insert autoptimize_imgopt_settings autoptimize_imgopt_checkbox_field_1 1
    
  3. User Creation: Create a user with the contributor role.
  4. Target Content: The contributor creates a post. The researcher will need the ID of this post.

7. Expected Results

  • The update_post_meta call succeeds, storing the raw payload in the database.
  • When viewing the post frontend, the source code should contain:
    <link rel="preload" as="image" href=""><script>alert(document.domain)</script>">
  • The browser executes the alert dialog.

8. Verification Steps

  1. Check Database: Verify the payload is stored correctly in the meta table:
    wp post meta get {{POST_ID}} ao_post_preload
    
  2. Check Frontend Response: Use http_request to fetch the post page and search for the <link rel="preload" tag to confirm the unescaped payload is present.

9. Alternative Approaches

If breaking out of the tag fails due to unanticipated server-side filters, try attribute-based injection if the tag uses single quotes:

  • Payload: x' onload='alert(1) (Note: onload is less reliable on <link>, tag breakout is preferred).
  • If ao_metabox_save is not accessible to Contributors directly, verify if they can still trigger it via the Gutenberg editor's meta-saving mechanism which often bridges to save_post.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Autoptimize plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the 'ao_post_preload' meta value due to insufficient input sanitization in the storage logic and missing output escaping when rendering link tags. This allows authenticated attackers with Contributor-level permissions or higher to inject arbitrary scripts that execute in the context of any user visiting the affected post, provided image optimization or lazy-loading is enabled.

Vulnerable Code

// classes/autoptimizeMetabox.php:275
if ( in_array( $opti_type, apply_filters( 'autoptimize_filter_meta_valid_optims', array( 'ao_post_optimize', 'ao_post_js_optimize', 'ao_post_css_optimize', 'ao_post_ccss', 'ao_post_lazyload', 'ao_post_preload' ) ) ) ) {
    if ( in_array( $opti_type, apply_filters( 'autoptimize_filter_meta_optim_nonbool', array( 'ao_post_preload' ) ) ) ) {
        if ( isset( $_POST[ $opti_type ] ) ) {
            $ao_meta_result[ $opti_type ] = $_POST[ $opti_type ];
        } else {
            $ao_meta_result[ $opti_type ] = false;
        }
    }
}

---

// classes/autoptimizeImages.php:804
if ( ! empty( $metabox_preloads ) && is_array( $metabox_preloads ) && empty( $to_preload ) && false !== apply_filters( 'autoptimize_filter_imgopt_dopreloads', true ) ) {
    // the preload was not in an img tag, so adding a non-responsive preload instead.
    foreach ( $metabox_preloads as $img_preload ) {
        $to_preload .= '<link rel="preload" href="' . $img_preload . '" as="image">';
    }
}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.14/autoptimize.php /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.15/autoptimize.php
--- /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.14/autoptimize.php	2025-11-23 14:59:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.15/autoptimize.php	2026-03-14 12:44:16.000000000 +0000
@@ -3,7 +3,7 @@
  * Plugin Name: Autoptimize
  * Plugin URI: https://autoptimize.com/pro/
  * Description: Makes your site faster by optimizing CSS, JS, Images, Google fonts and more.
- * Version: 3.1.14
+ * Version: 3.1.15
  * Author: Frank Goossens (futtta)
  * Author URI: https://autoptimize.com/pro/
  * Text Domain: autoptimize
@@ -21,7 +21,7 @@
     exit;
 }
 
-define( 'AUTOPTIMIZE_PLUGIN_VERSION', '3.1.14' );
+define( 'AUTOPTIMIZE_PLUGIN_VERSION', '3.1.15' );
 
 // plugin_dir_path() returns the trailing slash!
 define( 'AUTOPTIMIZE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.14/classes/autoptimizeExtra.php /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.15/classes/autoptimizeExtra.php
--- /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.14/classes/autoptimizeExtra.php	2025-11-23 14:59:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.15/classes/autoptimizeExtra.php	2026-03-14 12:44:16.000000000 +0000
@@ -467,7 +467,7 @@
                 $preload_as = 'other';
             }
 
-            $preload_output .= '<link rel="preload" href="' . $preload . '" as="preload_as"' . $mime_type . $crossorigin . '>';
+            $preload_output .= '<link rel="preload" fetchpriority="high" href="' . $preload . '" as="' . $preload_as . '"' . $mime_type . $crossorigin . '>';
         }
         $preload_output = apply_filters( 'autoptimize_filter_extra_preload_output', $preload_output );
 
--- /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.14/classes/autoptimizeImages.php	2025-11-23 14:59:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.15/classes/autoptimizeImages.php	2026-03-14 12:44:16.000000000 +0000
@@ -801,7 +801,7 @@
         if ( ! empty( $metabox_preloads ) && is_array( $metabox_preloads ) && empty( $to_preload ) && false !== apply_filters( 'autoptimize_filter_imgopt_dopreloads', true ) ) {
             // the preload was not in an img tag, so adding a non-responsive preload instead.
             foreach ( $metabox_preloads as $img_preload ) {
-                $to_preload .= '<link rel="preload" href="' . $img_preload . '" as="image">';
+                $to_preload .= apply_filters( 'autoptimize_filter_imgopt_preload_tag_result', $this->kses_preload_link( '<link fetchpriority="high" rel="preload" href="' . $img_preload . '" as="image">' ) );
             }
         }
 
@@ -935,7 +935,7 @@
         if ( ! empty( $metabox_preloads ) && is_array( $metabox_preloads ) && empty( $to_preload ) && false !== apply_filters( 'autoptimize_filter_imgopt_dopreloads', true ) ) {
             // the preload was not in an img tag, so adding a non-responsive preload instead.
             foreach ( $metabox_preloads as $img_preload ) {
-                $to_preload .= '<link rel="preload" href="' . $img_preload . '" as="image">';
+                $to_preload .= apply_filters( 'autoptimize_filter_imgopt_preload_tag_result', $this->kses_preload_link( '<link fetchpriority="high" rel="preload" href="' . $img_preload . '" as="image">' ) );
             }
         }
 
@@ -1053,11 +1054,21 @@
 
         // rewrite img tag to link preload img.
         $_from = array( '<img ', ' src=', ' sizes=', ' srcset=' );
-        $_to   = array( '<link rel="preload" as="image" ', ' href=', ' imagesizes=', ' imagesrcset=' );
+        $_to   = array( '<link fetchpriority="high" rel="preload" as="image" ', ' href=', ' imagesizes=', ' imagesrcset=' );
         $tag   = str_replace( $_from, $_to, $tag );
 
-        // and using kses, remove all unneeded attributes
-        // keeping only those we *know* are OK and/ or needed
+        // sanitize output
+        $tag = $this->kses_preload_link( $tag );
+        
+        // and provide filter for late changes.
+        $tag = apply_filters( 'autoptimize_filter_imgopt_preload_tag_result', $tag );
+        
+        return $tag;
+    }
+
+    public static function kses_preload_link( $_preload ) {
+        // using kses, remove all unneeded attributes
+        // keeping only those we *know* are OK and/ or needed.
         $allowed_html = array( 
                 'link' => array(
                     'rel'           => true,
@@ -1067,11 +1078,12 @@
                     'imagesrcset'   => true,
                     'type'          => true,
                     'media'         => true,
+                    'fetchpriority' => true,
                 ),
             );
-        $tag = wp_kses( $tag, $allowed_html );
+        $_preload = wp_kses( $_preload, $allowed_html );
         
-        return $tag;
+        return $_preload;
     }
 
--- /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.14/classes/autoptimizeMetabox.php	2024-07-25 17:14:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/autoptimize/3.1.15/classes/autoptimizeMetabox.php	2026-03-14 12:44:16.000000000 +0000
@@ -273,7 +273,7 @@
         foreach ( apply_filters( 'autoptimize_filter_meta_valid_optims', array( 'ao_post_optimize', 'ao_post_js_optimize', 'ao_post_css_optimize', 'ao_post_ccss', 'ao_post_lazyload', 'ao_post_preload' ) ) as $opti_type ) {
             if ( in_array( $opti_type, apply_filters( 'autoptimize_filter_meta_optim_nonbool', array( 'ao_post_preload' ) ) ) ) {
                 if ( isset( $_POST[ $opti_type ] ) ) {
-                    $ao_meta_result[ $opti_type ] = $_POST[ $opti_type ];
+                    $ao_meta_result[ $opti_type ] = sanitize_text_field( $_POST[ $opti_type ] );
                 } else {
                     $ao_meta_result[ $opti_type ] = false;
                 }

Exploit Outline

The exploit is a Stored XSS attack requiring Contributor-level authentication. 1. **Authentication**: Log in to the WordPress dashboard as a user with at least 'Contributor' privileges. 2. **Nonce Acquisition**: Navigate to the post editor (e.g., `/wp-admin/post-new.php`) and retrieve the value of the `ao_metabox_nonce` hidden input field. 3. **Injection**: Send a POST request to `/wp-admin/post.php` with the action `editpost`. Include the parameter `ao_post_preload` containing an XSS payload designed to break out of an HTML attribute (e.g., `"><script>alert(document.domain)</script>`). 4. **Precondition**: Ensure either 'Image optimization' or 'Lazy-load images' is enabled in the Autoptimize plugin settings (usually requires Administrator access to configure initially). 5. **Trigger**: Navigate to the published post. The plugin will render the malicious payload within the `<head>` section inside a `<link rel="preload">` tag, executing the script.

Check if your site is affected.

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