Autoptimize <= 3.1.14 - Authenticated (Contributor+) Stored Cross-Site Scripting via 'ao_post_preload' Meta Value
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:NTechnical Details
<=3.1.14What Changed in the Fix
Changes introduced in v3.1.15
Source Code
WordPress.org SVN# 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
- Input (Storage):
- A user with
edit_postscapability (Contributor+) edits a post/page. - The
autoptimizeMetabox::ao_metabox_content()function renders a text input field namedao_post_preloadin the Autoptimize meta box. - Upon saving the post, the
save_postaction triggersautoptimizeMetabox::ao_metabox_save(). ao_metabox_save()retrieves$_POST['ao_post_preload']and saves it usingupdate_post_meta()without adequate sanitization (e.g., it fails to useesc_url_raworsanitize_text_fieldproperly to prevent HTML injection).
- A user with
- Output (Execution):
- When a user views the published post/page, the plugin's frontend image optimization logic in
classes/autoptimizeImages.php(hooked towp_heador similar viarun_on_frontend) is executed. - The code retrieves the
ao_post_preloadmeta value. - The value is echoed directly into a
<link rel="preload" as="image" href="...">tag without usingesc_url()oresc_attr(). - The injected script executes.
- When a user views the published post/page, the plugin's frontend image optimization logic in
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.
- Log in as a Contributor.
- Navigate to the "Add New Post" page:
/wp-admin/post-new.php. - Use
browser_evalto extract the nonce from the hidden input field:document.querySelector('input[name="ao_metabox_nonce"]').value
5. Exploitation Strategy
- Setup Configuration: Ensure "Image optimization" is active (requires Admin).
- Preparation: Log in as a Contributor and create a draft post to obtain a
post_IDand theao_metabox_nonce. - Injection: Perform a POST request to
/wp-admin/post.phpto update the post with the XSS payload inao_post_preload. - 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:editpostpost_ID:{{POST_ID}}ao_metabox_nonce:{{NONCE}}ao_post_preload:"><script>alert(document.domain)</script>post_title:XSS Testpost_type:post(orpage)
6. Test Data Setup
- Plugin Activation: Ensure Autoptimize is installed and active.
- Plugin Config: Enable image optimization via WP-CLI:
wp option patch insert autoptimize_imgopt_settings autoptimize_imgopt_checkbox_field_1 1 - User Creation: Create a user with the
contributorrole. - Target Content: The contributor creates a post. The researcher will need the ID of this post.
7. Expected Results
- The
update_post_metacall 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
alertdialog.
8. Verification Steps
- Check Database: Verify the payload is stored correctly in the meta table:
wp post meta get {{POST_ID}} ao_post_preload - Check Frontend Response: Use
http_requestto 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:onloadis less reliable on<link>, tag breakout is preferred). - If
ao_metabox_saveis not accessible to Contributors directly, verify if they can still trigger it via the Gutenberg editor's meta-saving mechanism which often bridges tosave_post.
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
@@ -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__ ) ); @@ -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 ); @@ -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; } @@ -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.