VYPR
High severity7.2NVD Advisory· Published May 28, 2026

CVE-2026-7634

CVE-2026-7634

Description

The SlimStat Analytics plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the 'User-Agent' header in all versions up to, and including, 5.4.11 due to insufficient input sanitization and output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page. The show_complete_user_agent_tooltip setting must be explicitly enabled by an administrator (disabled by default) for the stored payload to be rendered and executed.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Stored XSS in SlimStat Analytics WordPress plugin via User-Agent header, requires admin to enable a specific tooltip setting.

Vulnerability

The SlimStat Analytics plugin for WordPress versions up to and including 5.4.11 is vulnerable to Stored Cross-Site Scripting (XSS) via the User-Agent header. The vulnerability exists because the plugin logs the User-Agent value and, when the show_complete_user_agent_tooltip setting is enabled (disabled by default), it renders the value without proper sanitization and output escaping [1][4].

Exploitation

An unauthenticated attacker can send a crafted HTTP request containing malicious JavaScript in the User-Agent header. The payload is stored in the plugin's database and later executed when an administrator or authorized user views the reports page with the show_complete_user_agent_tooltip setting enabled [1]. No other authentication or user interaction is required beyond the victim accessing the affected page.

Impact

Successful exploitation allows the attacker to execute arbitrary web scripts in the context of the victim's browser. This can lead to session hijacking, credential theft, defacement, or other malicious actions, depending on the privileges of the victim [1]. The attack is executed from the WordPress admin area, potentially compromising the entire site.

Mitigation

As of the publication date, no patched version has been released. The vulnerability remains unpatched. The only mitigation is to ensure the show_complete_user_agent_tooltip setting remains disabled, which is the default configuration [1]. Administrators should not enable this setting until a fix is applied.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
4247af0e2d33

Merge pull request #297 from wp-slimstat/development

https://github.com/wp-slimstat/wp-slimstatParhum KhoshbakhtMay 13, 2026via nvd-ref
19 files changed · +1080 53
  • admin/view/wp-slimstat-reports.php+3 1 modified
    @@ -2096,7 +2096,9 @@ public static function get_resource_title($_resource = '')
         public static function inline_help($_text = '', $_echo = true)
         {
             if (is_admin() && !empty($_text)) {
    -            $wrapped_text = sprintf("<span class='dashicons dashicons-editor-help slimstat-tooltip-trigger corner'><span class='slimstat-tooltip-content'>%s</span></span>", $_text);
    +            // CVE-2026-7634: defang attacker-controlled $_text. wp_kses_post preserves
    +            // the formatting tags existing tooltips rely on.
    +            $wrapped_text = sprintf("<span class='dashicons dashicons-editor-help slimstat-tooltip-trigger corner'><span class='slimstat-tooltip-content'>%s</span></span>", wp_kses_post($_text));
             } else {
                 $wrapped_text = '';
             }
    
  • CHANGELOG.md+18 0 modified
    @@ -1,3 +1,21 @@
    += 5.4.12 - 2026-05-13 =
    +
    +**Security**
    +
    +- Authenticated SQL injection in the chart AJAX endpoint (`wp_ajax_slimstat_fetch_chart_data`) is now blocked. `chart_data.where` is validated against the registered report definitions before being inlined into the SQL query — only WHERE clauses declared by trusted reports (and by Pro addons that register reports via the `slimstat_reports_info` filter) are accepted. Reported via Patchstack (CVSS 8.5, High). The `data1` / `data2` allowlist introduced in PR #232 already covered the aggregate-expression vector; this release closes the parallel `where` vector.
    +- Patched unauthenticated stored XSS via the `User-Agent` header ([CVE-2026-7634](https://nvd.nist.gov/vuln/detail/CVE-2026-7634), CVSS 7.2). `Storage::updateRow()` now mirrors `insertRow()`'s `sanitize_text_field()`/`sanitize_url()` loop so a redirect (`Processor::updateContentType`) or AJAX follow-up (`Ajax::process` navigation/outbound/event branches) can no longer overwrite the inserted row with raw HTML. The User-Agent header is also sanitized on capture in `Browscap::_get_user_agent()`, and `wp_slimstat_reports::inline_help()` defangs unsafe HTML via `wp_kses_post()` before rendering — defense in depth across capture, storage, and output. Reported by Supakiad S. (m3ez) — E-CQURITY (Thailand) via Wordfence. Required `show_complete_user_agent_tooltip` to be enabled (off by default) for the stored payload to render in the admin Browsers report.
    +
    +**Bot detection hardening**
    +
    +- Chrome-based mobile Googlebot and Bingbot are now correctly blocked when Browscap classifies them as mobile devices (#14843, [#291](https://github.com/wp-slimstat/wp-slimstat/issues/291)). The bot-detection safety net previously only re-checked desktop-classified UAs (`browser_type === 0`); it now re-checks every non-crawler type (desktop, mobile, touch) so Android/Chrome-suffixed crawler UAs no longer slip through.
    +- Google-InspectionTool mobile UA is now detected as a crawler on both client and server tracking modes.
    +- `BOT_GENERIC_REGEX` extended with 15 new vendor-specific tokens to catch bare-name bots that expose neither a URL nor a conventional `bot`/`crawl`/`spider` keyword: Mediapartners-Google, Google-InspectionTool, Google-Site-Verification, Google Favicon, GoogleOther, GoogleAgent-Mariner (new March 2026), Google-Safety, DuplexWeb-Google, BingPreview, YandexDirect, YandexFavicons, WhatsApp preview, SkypeUriPreview, anthropic-ai, cohere-ai.
    +
    +**Tests**
    +
    +- 40-cell local real-production matrix (10 UAs × 2 tracking modes × 2 branches) verified every bot blocked on the fix branch with zero regressions on real browsers.
    +- Unit tests converted to static `@dataProvider` methods for PHPUnit 10/11 compatibility.
    +
     = 5.4.11 - 2026-04-17 =
     
     **Report fixes**
    
  • composer.json+3 1 modified
    @@ -76,6 +76,7 @@
         "test:browscap-filesystem": "php tests/browscap-wp-filesystem-test.php",
         "test:dnt-gdpr": "php tests/dnt-gdpr-independence-test.php",
         "test:browscap-bot-safety": "php tests/browscap-bot-safety-net-test.php",
    +    "test:storage-update-sanitize": "php tests/storage-update-sanitization-test.php",
         "test:unit": "phpunit --configuration phpunit.xml.dist",
         "test:all": [
           "@test:unit",
    @@ -98,7 +99,8 @@
           "@test:browscap-isolation",
           "@test:browscap-filesystem",
           "@test:dnt-gdpr",
    -      "@test:browscap-bot-safety"
    +      "@test:browscap-bot-safety",
    +      "@test:storage-update-sanitize"
         ]
       }
     }
    
  • languages/wp-slimstat.pot+12 7 modified
    @@ -2,14 +2,14 @@
     # This file is distributed under the GPL-2.0+.
     msgid ""
     msgstr ""
    -"Project-Id-Version: SlimStat Analytics 5.4.11\n"
    +"Project-Id-Version: SlimStat Analytics 5.4.12\n"
     "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-slimstat\n"
     "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
     "Language-Team: LANGUAGE <LL@li.org>\n"
     "MIME-Version: 1.0\n"
     "Content-Type: text/plain; charset=UTF-8\n"
     "Content-Transfer-Encoding: 8bit\n"
    -"POT-Creation-Date: 2026-04-17T16:32:09+00:00\n"
    +"POT-Creation-Date: 2026-05-11T10:08:26+00:00\n"
     "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
     "X-Generator: WP-CLI 2.12.0\n"
     "X-Domain: wp-slimstat\n"
    @@ -5393,17 +5393,22 @@ msgstr ""
     msgid "Year ago"
     msgstr ""
     
    -#: src/Modules/Chart.php:478
    -#: src/Modules/Chart.php:495
    +#: src/Modules/Chart.php:307
    +#: src/Modules/Chart.php:312
    +msgid "Invalid chart filter expression."
    +msgstr ""
    +
    +#: src/Modules/Chart.php:500
    +#: src/Modules/Chart.php:517
     msgid "Invalid SQL function in chart data expression"
     msgstr ""
     
    -#: src/Modules/Chart.php:482
    -#: src/Modules/Chart.php:499
    +#: src/Modules/Chart.php:504
    +#: src/Modules/Chart.php:521
     msgid "Invalid column name in chart data expression"
     msgstr ""
     
    -#: src/Modules/Chart.php:507
    +#: src/Modules/Chart.php:529
     msgid "Invalid SQL expression in chart data. Only whitelisted aggregate functions on valid columns are allowed."
     msgstr ""
     
    
  • readme.txt+8 1 modified
    @@ -5,7 +5,7 @@ Text Domain: wp-slimstat
     Requires at least: 5.6
     Requires PHP: 7.4
     Tested up to: 6.9.4
    -Stable tag: 5.4.11
    +Stable tag: 5.4.12
     License: GPL-2.0+
     License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    @@ -75,6 +75,13 @@ An extensive knowledge base is available on our [website](https://www.wp-slimsta
     9. **Settings** - Plenty of options to customize the plugin's behavior
     
     == Changelog ==
    += 5.4.12 - 2026-05-13 =
    +* Security: Authenticated SQL injection in the chart AJAX endpoint (slimstat_fetch_chart_data) is now blocked. The `chart_data.where` parameter is validated against the trusted report registry before reaching the query layer. Reported via Patchstack (CVSS 8.5, High).
    +* Security: Patch unauthenticated stored XSS via the User-Agent header (CVE-2026-7634). Storage::updateRow() now mirrors insertRow()'s sanitization, the User-Agent is sanitized at capture in Browscap, and admin tooltips are escaped via wp_kses_post(). Reported by Supakiad S. (m3ez) — E-CQURITY (Thailand) via Wordfence.
    +* Fix: Chrome-based mobile Googlebot and Bingbot now correctly blocked when Browscap classifies them as mobile devices (#14843)
    +* Fix: Google-InspectionTool mobile is now detected as a crawler
    +* Improvement: Bot detection regex extended with 15 new vendor tokens — Mediapartners-Google, Google-InspectionTool, Google-Site-Verification, Google Favicon, GoogleOther, GoogleAgent-Mariner, Google-Safety, DuplexWeb-Google, BingPreview, YandexDirect, YandexFavicons, WhatsApp preview, SkypeUriPreview, anthropic-ai, cohere-ai
    +
     = 5.4.11 - 2026-04-17 =
     * Fix: Access Log pagination no longer drops the user's selected custom date range
     * Fix: Auto Refresh setting in Settings → Reports is now honored
    
  • src/Dependencies/Symfony/Component/Console/composer.json+1 1 modified
    @@ -29,7 +29,7 @@
             "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
             "symfony/dependency-injection": "^4.4|^5.0|^6.0",
             "symfony/lock": "^4.4|^5.0|^6.0",
    -        "symfony/process": "^4.4|^5.0|^6.0",
    +        "symfony/process": "^5.4.51|^6.0",
             "symfony/var-dumper": "^4.4|^5.0|^6.0",
             "psr/log": "^1|^2"
         },
    
  • src/Modules/Chart.php+83 3 modified
    @@ -290,10 +290,32 @@ private function sqlFor(string $gran, array $args, array $prevArgs): array
             // Build WHERE clause from active filters (excluding time filters)
             $filterWhere = $this->buildFilterWhere();
     
    -        // Add chart-specific WHERE clause if provided
    +        // Add chart-specific WHERE clause if provided.
    +        // SECURITY: $args['chart_data']['where'] arrives via $_POST in the AJAX
    +        // path (ajaxFetchChartData) and is later inlined into raw SQL through
    +        // Query::whereRaw() with no parameter binding. To prevent SQL injection
    +        // (Patchstack disclosure, CVSS 8.5), require the supplied clause to
    +        // match — after whitespace normalization — one of the WHERE strings
    +        // declared by a report registered in wp_slimstat_reports::$reports.
             if (!empty($args['chart_data']['where'])) {
    -            $chartWhere = $args['chart_data']['where'];
    -            $filterWhere = !empty($filterWhere) ? $filterWhere . ' AND ' . $chartWhere : $chartWhere;
    +            // Reject non-string input before normalization. Chart.php does not
    +            // declare(strict_types=1), so casting an array (E_WARNING) or an
    +            // object without __toString (fatal Error → 500) would otherwise
    +            // produce noisy logs or crash the AJAX handler instead of the
    +            // generic security rejection below.
    +            if (!is_string($args['chart_data']['where'])) {
    +                throw new \Exception(__('Invalid chart filter expression.', 'wp-slimstat'));
    +            }
    +            $normalized = self::normalizeSqlWhitespace($args['chart_data']['where']);
    +            $allowed    = self::getAllowedWhereClauses();
    +            if (!isset($allowed[$normalized])) {
    +                throw new \Exception(__('Invalid chart filter expression.', 'wp-slimstat'));
    +            }
    +            $canonical   = $allowed[$normalized]; // splice trusted text, never the user-derived $normalized
    +            // Wrap: allowlisted clauses may contain a top-level OR that would
    +            // otherwise rebind and drop the preceding AND filters.
    +            $wrapped     = '(' . $canonical . ')';
    +            $filterWhere = !empty($filterWhere) ? $filterWhere . ' AND ' . $wrapped : $wrapped;
             }
     
             // Use UNIX_TIMESTAMP difference for broad MySQL 5.0.x compatibility.
    @@ -507,6 +529,64 @@ private function validateSqlExpression(string $expression): string
             throw new \Exception(__('Invalid SQL expression in chart data. Only whitelisted aggregate functions on valid columns are allowed.', 'wp-slimstat'));
         }
     
    +    /**
    +     * Allowlist of legitimate chart `where` clauses harvested from every report
    +     * registered in wp_slimstat_reports::$reports (including those added by
    +     * third-party Pro addons via the `slimstat_reports_info` filter).
    +     *
    +     * Rebuilt per request because dynamic clauses (home_url(), date_i18n(...))
    +     * are evaluated at init() time.
    +     *
    +     * @return array<string,string> normalized-clause => canonical clause text
    +     */
    +    private static function getAllowedWhereClauses(): array
    +    {
    +        static $cache = null;
    +        if (null !== $cache) {
    +            return $cache;
    +        }
    +
    +        if (!class_exists('\wp_slimstat_reports')) {
    +            $reportsFile = SLIMSTAT_DIR . '/admin/view/wp-slimstat-reports.php';
    +            if (file_exists($reportsFile)) {
    +                include_once $reportsFile;
    +            }
    +        }
    +        if (!class_exists('\wp_slimstat_reports')) {
    +            // Don't cache the failure — let a later call retry once the file
    +            // has had a chance to load (e.g. via a downstream filter).
    +            return [];
    +        }
    +
    +        \wp_slimstat_reports::init();
    +
    +        $cache = [];
    +        foreach ((array) \wp_slimstat_reports::$reports as $report) {
    +            $where = $report['callback_args']['chart_data']['where'] ?? null;
    +            // Skip non-string values defensively — a third-party report could
    +            // register an array/object/null; normalizeSqlWhitespace is typed
    +            // for string and Chart.php does not declare(strict_types=1).
    +            if (!is_string($where) || '' === $where) {
    +                continue;
    +            }
    +            $normalized = self::normalizeSqlWhitespace($where);
    +            if ('' !== $normalized) {
    +                $cache[$normalized] = $where;
    +            }
    +        }
    +
    +        return $cache;
    +    }
    +
    +    /**
    +     * Both sides of the `where` allowlist comparison must run through the
    +     * same whitespace normalization for the equality check to be sound.
    +     */
    +    private static function normalizeSqlWhitespace(string $sql): string
    +    {
    +        return trim(preg_replace('/\s+/', ' ', $sql));
    +    }
    +
         private function processResults(array $rows, array $totals, array $params, int $start, int $end, int $prevStart, int $prevEnd): array
         {
             // Normalize totals to array of stdClass for backward compatibility
    
  • src/Services/Browscap.php+16 10 modified
    @@ -71,10 +71,10 @@ public static function get_browser($_user_agent = '')
                 $browser['browser_version'] = $browser_version['browser_version'];
             }
     
    -        // Safety net: detect bots by UA keywords when Browscap did not flag as crawler.
    -        // Catches Chrome-based crawlers (Googlebot, Bingbot) that Browscap may
    -        // identify as regular browsers without setting crawler=true. See #291.
    -        if (0 === (int) $browser['browser_type']) {
    +        // Safety net: re-check any non-crawler type (desktop/mobile/touch) against
    +        // BOT_GENERIC_REGEX. Browscap misclassifies Chrome-based Googlebot mobile
    +        // UAs as type=2 because Android/Mobile signals match before the bot suffix.
    +        if (1 !== (int) $browser['browser_type']) {
                 $browser = self::apply_bot_safety_net($browser);
             }
     
    @@ -266,17 +266,23 @@ public static function update_browscap_database($_force_download = false)
     
         protected static function _get_user_agent()
         {
    -
    -        $user_agent = (empty($_SERVER['HTTP_USER_AGENT']) ? '' : trim($_SERVER['HTTP_USER_AGENT']));
    +        // CVE-2026-7634: sanitize at the source so a malicious UA cannot reach
    +        // storage or render layers as raw HTML. Mirrors the pattern used in
    +        // Session.php and IPHashProvider.php. Bot/crawler regex matching downstream
    +        // (UADetector::BOT_GENERIC_REGEX, BrowscapPHP) operates on alphanumerics
    +        // and punctuation that sanitize_text_field preserves.
    +        $user_agent = empty($_SERVER['HTTP_USER_AGENT'])
    +            ? ''
    +            : trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])));
             $real_user_agent = '';
             if (!empty($_SERVER['HTTP_X_DEVICE_USER_AGENT'])) {
    -            $real_user_agent = trim($_SERVER['HTTP_X_DEVICE_USER_AGENT']);
    +            $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_DEVICE_USER_AGENT'])));
             } elseif (!empty($_SERVER['HTTP_X_ORIGINAL_USER_AGENT'])) {
    -            $real_user_agent = trim($_SERVER['HTTP_X_ORIGINAL_USER_AGENT']);
    +            $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_ORIGINAL_USER_AGENT'])));
             } elseif (!empty($_SERVER['HTTP_X_MOBILE_UA'])) {
    -            $real_user_agent = trim($_SERVER['HTTP_X_MOBILE_UA']);
    +            $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_MOBILE_UA'])));
             } elseif (!empty($_SERVER['HTTP_X_OPERAMINI_PHONE_UA'])) {
    -            $real_user_agent = trim($_SERVER['HTTP_X_OPERAMINI_PHONE_UA']);
    +            $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_OPERAMINI_PHONE_UA'])));
             }
     
             if ('' !== $real_user_agent && '0' !== $real_user_agent && (strlen($real_user_agent) >= 5 || ('' === $user_agent || '0' === $user_agent))) {
    
  • src/Tracker/Storage.php+23 0 modified
    @@ -31,15 +31,30 @@ public static function updateRow($data = [])
     		$id = abs(intval($data['id']));
     		unset($data['id']);
     
    +		// CVE-2026-7634: mirror insertRow()'s sanitization so an UPDATE cannot
    +		// overwrite the row with raw HTML. Run before array_filter so values that
    +		// sanitize to '' get dropped along with originals.
    +		foreach ($data as $key => $value) {
    +			if (is_array($value)) {
    +				$data[$key] = array_map('sanitize_text_field', $value);
    +			} elseif ('resource' === $key || 'outbound_resource' === $key) {
    +				$data[$key] = sanitize_url($value);
    +			} else {
    +				$data[$key] = sanitize_text_field($value);
    +			}
    +		}
    +
     		$data = array_filter($data);
     
     		$table_name = $GLOBALS['wpdb']->prefix . 'slim_stats';
     		$query = Query::update($table_name)->ignore()->where('id', '=', $id);
    +		$hasUpdates = false;
     
     		if (!empty($data['notes']) && is_array($data['notes'])) {
     			$notes_to_append = '[' . implode('][', $data['notes']) . ']';
     			$query->setRaw('notes', "CONCAT(IFNULL(notes, ''), %s)", [$notes_to_append]);
     			unset($data['notes']);
    +			$hasUpdates = true;
     		}
     
     		if (!empty($data['outbound_resource'])) {
    @@ -50,10 +65,18 @@ public static function updateRow($data = [])
     				[$url, $url, $url]
     			);
     			unset($data['outbound_resource']);
    +			$hasUpdates = true;
     		}
     
     		if ($data !== []) {
     			$query->set($data);
    +			$hasUpdates = true;
    +		}
    +
    +		// If sanitization stripped every field there is nothing to write — skip
    +		// the execute() to avoid emitting `UPDATE ... SET  WHERE id=X` (invalid SQL).
    +		if (!$hasUpdates) {
    +			return $id;
     		}
     
     		$query->execute();
    
  • src/Utils/UADetector.php+1 1 modified
    @@ -10,7 +10,7 @@ class UADetector
         //		2: mobile
     
         /** Generic bot detection regex — shared with Browscap::apply_bot_safety_net(). */
    -    public const BOT_GENERIC_REGEX = '#(robot|bot[\s\-_\/\)]|bot$|blog|checker|crawl|feed|fetcher|libwww|[^\.e]link\s?|parser|reader|spider|verifier|href|https?\://|.+(?:\@|\s?at\s?)[a-z0-9_\-]+(?:\.|\s?dot\s?)|www[0-9]?\.[a-z0-9_\-]+\..+|\/.+\.(s?html?|aspx?|php5?|cgi))#i';
    +    public const BOT_GENERIC_REGEX = '#(robot|bot[\s\-_\/\)]|bot$|blog|checker|crawl|feed|fetcher|libwww|[^\.e]link\s?|parser|reader|spider|verif(?:ier|ication|y)|href|https?\://|.+(?:\@|\s?at\s?)[a-z0-9_\-]+(?:\.|\s?dot\s?)|www[0-9]?\.[a-z0-9_\-]+\..+|\/.+\.(s?html?|aspx?|php5?|cgi)|mediapartners|inspectiontool|googleother|googleagent|google-safety|duplexweb|google\sfavicon|yandex(?:direct|favicons)|anthropic-ai|cohere-ai|bingpreview|whatsapp\/|skypeuripreview)#i';
     
         public static function get_browser($_user_agent = '')
         {
    
  • tests/browscap-bot-safety-net-test.php+44 3 modified
    @@ -75,11 +75,18 @@ function safety_assert(bool $condition, string $msg): void
     );
     
     // ═══════════════════════════════════════════════════════════════════════════
    -// TEST 6: Safety net must only run when browser_type is 0
    +// TEST 6: Safety net gate must run for any non-crawler type (#14843 v2).
    +// Previously only fired on type=0 — mobile bot UAs (type=2) slipped through.
    +// The gate must be `1 !== (int) $browser['browser_type']` so types 0, 2, 3
    +// all re-run the UA keyword check.
     // ═══════════════════════════════════════════════════════════════════════════
     safety_assert(
    -    (bool) preg_match('/0\s*===.*browser_type.*apply_bot_safety_net/s', $browscap_src),
    -    'TEST 6: Safety net call must be guarded by browser_type === 0 check'
    +    (bool) preg_match('/1\s*!==.*browser_type.*apply_bot_safety_net/s', $browscap_src),
    +    'TEST 6: Safety net call must be guarded by `1 !== (int) browser_type` (not `0 ===`)'
    +);
    +safety_assert(
    +    false === strpos($browscap_src, "0 === (int) \$browser['browser_type']"),
    +    'TEST 6b: Safety net must not use the old `0 === (int) browser_type` gate'
     );
     
     // ═══════════════════════════════════════════════════════════════════════════
    @@ -121,4 +128,38 @@ function safety_assert(bool $condition, string $msg): void
         'TEST 10: Ajax.php must check ignore_bots via Browscap::get_browser() for follow-up events'
     );
     
    +// ═══════════════════════════════════════════════════════════════════════════
    +// TEST 11: BOT_GENERIC_REGEX must include the 15 vendor-group keywords (#14843 v2)
    +// Closes gaps for: Mediapartners-Google, Google-InspectionTool, Google-Site-
    +// Verification, Google Favicon, GoogleOther, GoogleAgent-Mariner, Google-Safety,
    +// DuplexWeb-Google, BingPreview, YandexDirect/YandexFavicons, WhatsApp,
    +// SkypeUriPreview, anthropic-ai, cohere-ai.
    +//
    +// The assertion runs against only the BOT_GENERIC_REGEX constant value — not
    +// the whole UADetector.php source — to avoid false positives from tokens that
    +// might appear in unrelated comments or identifiers elsewhere in the file.
    +// ═══════════════════════════════════════════════════════════════════════════
    +safety_assert(
    +    (bool) preg_match(
    +        "/public\\s+const\\s+BOT_GENERIC_REGEX\\s*=\\s*'([^']+)'/",
    +        $uadetector_src,
    +        $regex_match
    +    ),
    +    'TEST 11: BOT_GENERIC_REGEX constant must be parseable from UADetector.php'
    +);
    +$bot_regex_value = $regex_match[1] ?? '';
    +
    +$required_regex_tokens = [
    +    'mediapartners', 'inspectiontool', 'googleother', 'googleagent',
    +    'google-safety', 'duplexweb', 'bingpreview', 'yandex',
    +    'direct|favicons', 'anthropic-ai', 'cohere-ai', 'skypeuripreview',
    +    'whatsapp', 'favicon', 'verif',
    +];
    +foreach ($required_regex_tokens as $token) {
    +    safety_assert(
    +        false !== stripos($bot_regex_value, $token),
    +        "TEST 11: BOT_GENERIC_REGEX must contain '{$token}' keyword"
    +    );
    +}
    +
     echo "All {$assertions} assertions passed in browscap-bot-safety-net-test.php\n";
    
  • tests/e2e/chart-sql-expression-validation.spec.ts+4 22 modified
    @@ -25,38 +25,20 @@ import {
       uninstallMuPluginByName,
     } from './helpers/setup';
     import { BASE_URL } from './helpers/env';
    -
    -// ─── Helpers ─────────────────────────────────────────────────────────────────
    -
    -/**
    - * Obtain the chart nonce via the nonce-helper AJAX endpoint.
    - * More reliable than page-scraping slimstat_chart_vars.nonce.
    - */
    -async function getChartNonce(page: import('@playwright/test').Page): Promise<string> {
    -  const res = await page.request.post(`${BASE_URL}/wp-admin/admin-ajax.php`, {
    -    form: { action: 'test_create_nonce', nonce_action: 'slimstat_chart_nonce' },
    -  });
    -  if (!res.ok()) throw new Error(`test_create_nonce failed: HTTP ${res.status()}`);
    -  const body = await res.json();
    -  if (!body?.success || !body?.data?.nonce) {
    -    throw new Error(`test_create_nonce returned unexpected body: ${JSON.stringify(body)}`);
    -  }
    -  return body.data.nonce;
    -}
    +import { CHART_TEST_RANGE, getChartNonce } from './helpers/chart';
     
     /**
      * Call slimstat_fetch_chart_data AJAX with a custom data1 expression.
    - * Uses a fixed past range (2026-02-01 → 2026-03-31) — no DB rows needed
    - * because validateSqlExpression() runs before the SQL query.
    + * Uses a fixed past range — no DB rows needed because
    + * validateSqlExpression() runs before the SQL query.
      */
     async function callChartWithExpression(
       page: import('@playwright/test').Page,
       nonce: string,
       data1Expression: string
     ): Promise<{ status: number; body: any }> {
       const args = JSON.stringify({
    -    start: 1738368000, // 2026-02-01 00:00 UTC
    -    end:   1743379199, // 2026-03-31 23:59 UTC
    +    ...CHART_TEST_RANGE,
         chart_data: {
           data1: data1Expression,
           data2: 'COUNT( DISTINCT ip )',
    
  • tests/e2e/chart-where-allowlist-validation.spec.ts+126 0 added
    @@ -0,0 +1,126 @@
    +/**
    + * E2E: chart_data.where allowlist validation — Patchstack SQLi (CVSS 8.5).
    + *
    + * Validates the registry-based allowlist in Chart::sqlFor() that compares the
    + * client-supplied `chart_data.where` (after whitespace normalization) against
    + * every WHERE clause registered in wp_slimstat_reports::$reports. Anything
    + * not in the allowlist throws \Exception, caught by ajaxFetchChartData() and
    + * returned as { success: false, data: { message } }.
    + *
    + * Tests:
    + *   1. Legit where (slim_p1_19_01 Search Terms clause) → success: true
    + *   2. Legit where with extra whitespace             → success: true (normalization)
    + *   3. Patchstack PoC: IF(1=1,SLEEP(2),0)            → success: false AND elapsed < 1500ms
    + *   4. Stacked-statement injection                    → success: false
    + *   5. No `where` provided (slim_p1_01-style report)  → success: true
    + *   6. Empty `where` string                           → success: true (early return)
    + *
    + * Source: Patchstack disclosure 2026-04 / fix shipped 5.4.12.
    + */
    +import { test, expect } from '@playwright/test';
    +import {
    +  closeDb,
    +  installMuPluginByName,
    +  uninstallMuPluginByName,
    +} from './helpers/setup';
    +import { BASE_URL } from './helpers/env';
    +import { CHART_TEST_RANGE, getChartNonce } from './helpers/chart';
    +
    +// Whitespace-equivalent of the registered slim_p1_19_01 (Search Terms) where clause.
    +const LEGIT_WHERE = 'searchterms <> "_" AND searchterms IS NOT NULL AND searchterms <> ""';
    +
    +async function callChartWithWhere(
    +  page: import('@playwright/test').Page,
    +  nonce: string,
    +  where: string | null
    +): Promise<{ status: number; body: any; elapsedMs: number }> {
    +  const chartData: Record<string, string> = {
    +    data1: 'COUNT(id)',
    +    data2: 'COUNT( DISTINCT ip )',
    +  };
    +  if (where !== null) {
    +    chartData.where = where;
    +  }
    +
    +  const args = JSON.stringify({
    +    ...CHART_TEST_RANGE,
    +    chart_data: chartData,
    +  });
    +
    +  const t0 = Date.now();
    +  const res = await page.request.post(`${BASE_URL}/wp-admin/admin-ajax.php`, {
    +    form: { action: 'slimstat_fetch_chart_data', nonce, args, granularity: 'monthly' },
    +  });
    +  const elapsedMs = Date.now() - t0;
    +
    +  return { status: res.status(), body: res.ok() ? await res.json() : null, elapsedMs };
    +}
    +
    +// ─── Suite ────────────────────────────────────────────────────────────────────
    +
    +test.describe('Chart where-clause allowlist — Patchstack SQLi regression', () => {
    +  test.setTimeout(60_000);
    +
    +  let sharedNonce: string;
    +
    +  test.beforeAll(async ({ browser }) => {
    +    installMuPluginByName('nonce-helper-mu-plugin.php');
    +    const ctx  = await browser.newContext();
    +    const page = await ctx.newPage();
    +    sharedNonce = await getChartNonce(page);
    +    await ctx.close();
    +  });
    +
    +  test.afterAll(async () => {
    +    uninstallMuPluginByName('nonce-helper-mu-plugin.php');
    +    await closeDb();
    +  });
    +
    +  test('legit where (slim_p1_19_01 Search Terms) is accepted', async ({ page }) => {
    +    const { status, body } = await callChartWithWhere(page, sharedNonce, LEGIT_WHERE);
    +
    +    expect(status).toBe(200);
    +    expect(body?.success, `Expected success:true, got: ${JSON.stringify(body?.data)}`).toBe(true);
    +  });
    +
    +  test('legit where with extra whitespace is accepted (normalization)', async ({ page }) => {
    +    const padded = '   searchterms   <>   "_"   AND  searchterms\tIS NOT NULL   AND searchterms <> ""   ';
    +    const { status, body } = await callChartWithWhere(page, sharedNonce, padded);
    +
    +    expect(status).toBe(200);
    +    expect(body?.success, `Expected success:true, got: ${JSON.stringify(body?.data)}`).toBe(true);
    +  });
    +
    +  test('Patchstack PoC: IF(1=1,SLEEP(2),0) is rejected before SQL executes', async ({ page }) => {
    +    const { status, body, elapsedMs } = await callChartWithWhere(page, sharedNonce, 'IF(1=1,SLEEP(2),0)');
    +
    +    expect(status).toBe(200);
    +    expect(body?.success, 'SQLi payload must be rejected').toBe(false);
    +    expect(typeof body?.data?.message).toBe('string');
    +    // SLEEP(2) would push response well past 2000ms. Asserting < 1500ms proves
    +    // the payload was rejected before the query layer.
    +    expect(elapsedMs).toBeLessThan(1500);
    +  });
    +
    +  test('stacked-statement injection is rejected', async ({ page }) => {
    +    const { status, body } = await callChartWithWhere(page, sharedNonce, '1=1; DROP TABLE wp_slim_stats--');
    +
    +    expect(status).toBe(200);
    +    expect(body?.success, 'Stacked-statement payload must be rejected').toBe(false);
    +    expect(typeof body?.data?.message).toBe('string');
    +  });
    +
    +  test('no `where` provided (slim_p1_01-style report) still succeeds', async ({ page }) => {
    +    const { status, body } = await callChartWithWhere(page, sharedNonce, null);
    +
    +    expect(status).toBe(200);
    +    expect(body?.success, `Expected success:true, got: ${JSON.stringify(body?.data)}`).toBe(true);
    +  });
    +
    +  test('empty `where` string still succeeds (early-return guard)', async ({ page }) => {
    +    const { status, body } = await callChartWithWhere(page, sharedNonce, '');
    +
    +    expect(status).toBe(200);
    +    expect(body?.success, `Expected success:true, got: ${JSON.stringify(body?.data)}`).toBe(true);
    +  });
    +});
    
  • tests/e2e/cve-2026-7634-user-agent-xss.spec.ts+283 0 added
    @@ -0,0 +1,283 @@
    +/**
    + * E2E roundtrip test for CVE-2026-7634 — Stored XSS via User-Agent header.
    + *
    + * Reproduces the published Wordfence PoC plus the three additional
    + * Storage::updateRow() callers in Ajax.php (navigation, outbound, event)
    + * to prove the storage-layer fix closes every reachable path.
    + *
    + * Reported by Supakiad S. (m3ez) - E-CQURITY (Thailand).
    + */
    +import { test, expect, Page } from '@playwright/test';
    +import { BASE_URL, ADMIN_USER, ADMIN_PASS } from './helpers/env';
    +import {
    +  getPool,
    +  closeDb,
    +  clearStatsTable,
    +  setSlimstatOptions,
    +  snapshotSlimstatOptions,
    +  restoreSlimstatOptions,
    +} from './helpers/setup';
    +
    +declare global {
    +  interface Window {
    +    __xss_fired?: boolean;
    +  }
    +}
    +
    +interface UserAgentRow {
    +  user_agent: string;
    +}
    +type StatsRow = Record<string, unknown> & { id: number; user_agent: string; browser_type?: number; outbound_resource?: string | null };
    +type QueryResult<T> = [T[], unknown];
    +
    +const XSS_PAYLOAD =
    +  'Mozilla/5.0 (Windows NT 10.0) <img src=x onerror=alert(/XSS_94821/)>';
    +const XSS_MARKER = 'onerror=alert';
    +
    +async function ensureLoggedIn(page: Page): Promise<void> {
    +  const url = page.url();
    +  if (url === 'about:blank' || !url.includes('wp-login.php')) {
    +    await page.goto(`${BASE_URL}/wp-login.php`);
    +  }
    +  if (page.url().includes('wp-login.php')) {
    +    await page.fill('#user_login', ADMIN_USER);
    +    await page.fill('#user_pass', ADMIN_PASS);
    +    await page.click('#wp-submit');
    +    await page.waitForURL('**/wp-admin/**', { timeout: 30_000 });
    +  }
    +}
    +
    +async function getLatestUserAgent(): Promise<string | null> {
    +  const [rows] = (await getPool().execute(
    +    'SELECT user_agent FROM wp_slim_stats ORDER BY id DESC LIMIT 1'
    +  )) as QueryResult<UserAgentRow>;
    +  return rows.length > 0 ? rows[0].user_agent : null;
    +}
    +
    +async function getLatestRow(): Promise<StatsRow | null> {
    +  const [rows] = (await getPool().execute(
    +    'SELECT * FROM wp_slim_stats ORDER BY id DESC LIMIT 1'
    +  )) as QueryResult<StatsRow>;
    +  return rows.length > 0 ? rows[0] : null;
    +}
    +
    +async function getRowById(id: number): Promise<StatsRow | null> {
    +  const [rows] = (await getPool().execute(
    +    'SELECT * FROM wp_slim_stats WHERE id = ? LIMIT 1',
    +    [id],
    +  )) as QueryResult<StatsRow>;
    +  return rows.length > 0 ? rows[0] : null;
    +}
    +
    +/**
    + * Polls the supplied async getter until it returns a non-null value or the
    + * timeout elapses. Replaces fixed waitForTimeout sleeps after server-side
    + * tracking writes, where the DB write is async relative to the HTTP response.
    + */
    +async function waitForStored<T>(
    +  getter: () => Promise<T | null>,
    +  timeoutMs = 3_000,
    +): Promise<T | null> {
    +  const start = Date.now();
    +  while (Date.now() - start < timeoutMs) {
    +    const value = await getter();
    +    if (value !== null) return value;
    +    await new Promise((r) => setTimeout(r, 100));
    +  }
    +  return null;
    +}
    +
    +test.describe('CVE-2026-7634 — Stored XSS via User-Agent header', () => {
    +  test.setTimeout(90_000);
    +
    +  test.beforeAll(async ({}, _testInfo) => {
    +    await snapshotSlimstatOptions();
    +  });
    +
    +  test.beforeEach(async ({ page }) => {
    +    await clearStatsTable();
    +    // Enable the vulnerable rendering path and force server-side tracking.
    +    await setSlimstatOptions(page, {
    +      show_complete_user_agent_tooltip: 'on',
    +      javascript_mode: 'off',
    +      enable_browscap: 'no',
    +    });
    +  });
    +
    +  test.afterAll(async () => {
    +    await restoreSlimstatOptions();
    +    await closeDb();
    +  });
    +
    +  // ─── Test 1: Reproduce the published PoC verbatim ───────────────────
    +
    +  test('redirect path (Processor::updateContentType) sanitizes user_agent', async ({
    +    page,
    +    request,
    +  }) => {
    +    // The advisory uses /sample-page (no trailing slash) which triggers a 301.
    +    const response = await request.get(`${BASE_URL}/sample-page`, {
    +      headers: { 'User-Agent': XSS_PAYLOAD },
    +      maxRedirects: 0,
    +    });
    +    expect(response.status(), '301 redirect must fire to trigger updateContentType').toBe(301);
    +
    +    // Wait for wp_redirect_status → updateRow() to flush by polling the DB.
    +    const stored = await waitForStored(getLatestUserAgent);
    +    expect(stored, 'A row must be stored on the redirect path').not.toBeNull();
    +    expect(stored, 'user_agent must not contain raw <img tag').not.toContain('<img');
    +    expect(stored, 'user_agent must not contain onerror handler').not.toContain('onerror');
    +    expect(stored, 'sanitized UA still contains the benign prefix').toContain('Mozilla/5.0');
    +
    +    // Render the admin Browsers report and confirm no dialog fires.
    +    let dialogTriggered = false;
    +    page.on('dialog', async (d) => {
    +      dialogTriggered = true;
    +      await d.dismiss();
    +    });
    +
    +    await ensureLoggedIn(page);
    +    const adminResp = await page.goto(`${BASE_URL}/wp-admin/admin.php?page=slimview3`, {
    +      waitUntil: 'domcontentloaded',
    +    });
    +    expect(adminResp?.status(), 'Browsers report must load').toBeLessThan(500);
    +
    +    // Hover the help icon to trigger the tooltip rendering as the advisory describes.
    +    const helpIcon = page.locator('.slimstat-tooltip-trigger.corner').first();
    +    if (await helpIcon.count() > 0) {
    +      await helpIcon.hover().catch(() => {});
    +    }
    +    // Any XSS would fire synchronously during DOM parsing or the hover handler;
    +    // wait for network to settle so we know the page is fully resolved before asserting.
    +    await page.waitForLoadState('networkidle').catch(() => {});
    +
    +    expect(dialogTriggered, 'XSS must not execute in admin Browsers report').toBe(false);
    +
    +    const html = await page.content();
    +    expect(html, 'rendered HTML must not contain onerror=').not.toContain(XSS_MARKER);
    +  });
    +
    +  // ─── Test 2: Outbound link path (Ajax.php:377) ──────────────────────
    +
    +  test('outbound link tracking path sanitizes user_agent on update', async ({ request, page }) => {
    +    // Seed a row first so the outbound update has something to attach to.
    +    const seed = await request.get(`${BASE_URL}/`, {
    +      headers: { 'User-Agent': XSS_PAYLOAD },
    +    });
    +    expect(seed.status()).toBeLessThan(400);
    +
    +    // Poll for the seed row to land; bot-exclusion or other settings may suppress it.
    +    const seeded = await waitForStored(getLatestRow);
    +    if (!seeded) {
    +      test.skip(true, 'Seed pageview did not land — server-side tracking may be disabled');
    +      return;
    +    }
    +
    +    // Trigger an outbound link update. The POST resolves synchronously with
    +    // the server response, so the row state is observable immediately after.
    +    const outboundUrl = 'https://external.example.com/some-target';
    +    await request.post(`${BASE_URL}/wp-admin/admin-ajax.php`, {
    +      headers: { 'User-Agent': XSS_PAYLOAD },
    +      form: {
    +        action: 'slimtrack',
    +        id: String(seeded.id),
    +        res: Buffer.from(outboundUrl).toString('base64url'),
    +      },
    +    });
    +
    +    // Without outbound_resource set, the assertions would only re-prove seed-time sanitization.
    +    const after = await getRowById(seeded.id);
    +    if (!after || !after.outbound_resource) {
    +      test.skip(true, 'outbound AJAX did not update the seeded row — Storage::updateRow path not exercised');
    +      return;
    +    }
    +    expect(after.outbound_resource, 'outbound update set the external URL').toContain(outboundUrl);
    +    expect(after.user_agent, 'outbound update must not store raw <img tag').not.toContain('<img');
    +    expect(after.user_agent, 'outbound update must not store onerror handler').not.toContain('onerror');
    +  });
    +
    +  // ─── Test 3: Direct DB seed → admin render is safe (rendering layer) ─
    +
    +  test('admin renders existing malicious row safely (output layer defense)', async ({ page }) => {
    +    // Pre-fix data could already exist in the wild. Defense-in-depth means
    +    // wp_kses_post() must defang it at render time even if storage was bypassed.
    +    await getPool().execute(
    +      'INSERT INTO wp_slim_stats (ip, resource, dt, user_agent, browser, browser_version, visit_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
    +      [
    +        '127.0.0.1',
    +        '/e2e-cve-2026-7634/',
    +        Math.floor(Date.now() / 1000),
    +        '<script>window.__xss_fired = true;</script>Mozilla/5.0',
    +        'Chrome',
    +        '120',
    +        Date.now(),
    +      ]
    +    );
    +
    +    let dialogTriggered = false;
    +    page.on('dialog', async (d) => {
    +      dialogTriggered = true;
    +      await d.dismiss();
    +    });
    +
    +    await ensureLoggedIn(page);
    +    await page.goto(`${BASE_URL}/wp-admin/admin.php?page=slimview3`, {
    +      waitUntil: 'domcontentloaded',
    +    });
    +    // Inline <script> in the DOM would have executed during DCL; wait for the
    +    // network to settle so deferred scripts/dialogs also have a chance to fire.
    +    await page.waitForLoadState('networkidle').catch(() => {});
    +
    +    // Even if the script tag survived in the DB row, wp_kses_post() must strip it
    +    // before output, so the inline script must never execute.
    +    const xssFired = await page.evaluate(() => window.__xss_fired === true);
    +    expect(xssFired, 'inline <script> in stored UA must not execute').toBe(false);
    +    expect(dialogTriggered, 'no alert dialogs from rendered tooltips').toBe(false);
    +
    +    const html = await page.content();
    +    expect(html, 'rendered HTML must not contain a working <script> from the UA').not.toContain(
    +      '<script>window.__xss_fired'
    +    );
    +  });
    +
    +  // ─── Test 4: Regression — bot detection still works after sanitization ─
    +
    +  test('Googlebot UA is still classified as a bot after sanitize_text_field', async ({
    +    request,
    +    page,
    +  }) => {
    +    const botUA =
    +      'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
    +
    +    await request.get(`${BASE_URL}/`, { headers: { 'User-Agent': botUA } });
    +
    +    // Bot exclusion may suppress storage; poll briefly and skip cleanly if so.
    +    const row = await waitForStored(getLatestRow);
    +    if (!row) {
    +      test.skip(true, 'No row stored — server-side tracking may exclude bots by setting');
    +      return;
    +    }
    +    expect(row.user_agent, 'bot UA preserved through sanitization').toContain('Googlebot');
    +    expect(row.browser_type as number, 'Googlebot must remain classified as crawler after sanitize_text_field').toBe(1);
    +  });
    +
    +  // ─── Test 5: Regression — legitimate HTML in tooltips still renders ──
    +
    +  test('admin tooltips with legitimate HTML still render formatted (wp_kses_post)', async ({
    +    page,
    +  }) => {
    +    await ensureLoggedIn(page);
    +    await page.goto(`${BASE_URL}/wp-admin/admin.php?page=slimview1`, {
    +      waitUntil: 'domcontentloaded',
    +    });
    +    await page.waitForLoadState('networkidle').catch(() => {});
    +
    +    const html = await page.content();
    +    // The Visitors / Activity report tooltip at line 111 contains <strong> and
    +    // <span class="little-color-box">. After wp_kses_post we must still see at
    +    // least one of those tags rendered (not escaped to literal text).
    +    const hasFormattedTooltip = /<span class="little-color-box[^"]*">/.test(html) ||
    +      /slimstat-tooltip-content[^>]*>[^<]*<strong>/.test(html);
    +    expect(hasFormattedTooltip, 'tooltip HTML must survive wp_kses_post').toBe(true);
    +  });
    +});
    
  • tests/e2e/helpers/chart.ts+28 1 modified
    @@ -8,7 +8,34 @@ import { execSync } from 'child_process';
     import * as fs from 'fs';
     import * as path from 'path';
     import { getPool } from './setup';
    -import { WP_ROOT } from './env';
    +import { WP_ROOT, BASE_URL } from './env';
    +
    +// ─── HTTP-driven AJAX helpers (used by browser-context specs) ───────────────
    +
    +/**
    + * Fixed past UTC range used by chart AJAX specs. Picked so existing rows
    + * (or their absence) don't influence allowlist/validation outcomes.
    + *   start: 2026-02-01 00:00 UTC
    + *   end:   2026-03-31 23:59 UTC
    + */
    +export const CHART_TEST_RANGE = { start: 1738368000, end: 1743379199 } as const;
    +
    +/**
    + * Obtain a `slimstat_chart_nonce` via the nonce-helper MU plugin.
    + * Caller must have installed `nonce-helper-mu-plugin.php` in beforeAll.
    + * Auth context is the test runner's WP session (admin, per harness).
    + */
    +export async function getChartNonce(page: import('@playwright/test').Page): Promise<string> {
    +  const res = await page.request.post(`${BASE_URL}/wp-admin/admin-ajax.php`, {
    +    form: { action: 'test_create_nonce', nonce_action: 'slimstat_chart_nonce' },
    +  });
    +  if (!res.ok()) throw new Error(`test_create_nonce failed: HTTP ${res.status()}`);
    +  const body = await res.json();
    +  if (!body?.success || !body?.data?.nonce) {
    +    throw new Error(`test_create_nonce returned unexpected body: ${JSON.stringify(body)}`);
    +  }
    +  return body.data.nonce;
    +}
     
     // ─── WP-CLI chart AJAX simulation ───────────────────────────────────────────
     
    
  • tests/reports-output-escaping-test.php+61 0 modified
    @@ -287,4 +287,65 @@ function extract_href(string $html): string
     $html = render_column('fingerprint', "test\x00<script>xss</script>");
     assert_not_contains('<script>xss</script>', $html, 'Null byte must not bypass escaping');
     
    +// ─── CVE-2026-7634: Browsers report user_agent tooltip XSS ──────────
    +
    +function render_browser_row(string $user_agent, string $tooltip_setting): string
    +{
    +    wp_slimstat::$settings['show_complete_user_agent_tooltip'] = $tooltip_setting;
    +
    +    $test_data = [
    +        [
    +            'browser'         => 'Chrome',
    +            'browser_version' => '120',
    +            'user_agent'      => $user_agent,
    +            'counthits'       => 1,
    +        ],
    +    ];
    +
    +    ob_start();
    +    wp_slimstat_reports::raw_results_to_html([
    +        'columns'   => 'browser',
    +        'type'      => 'top',
    +        'raw'       => make_data_callback($test_data),
    +        'where'     => '',
    +        'filter_op' => 'equals',
    +    ]);
    +    return ob_get_clean();
    +}
    +
    +// Test 14: Malicious user_agent rendered with tooltip ON has script-like markup defanged.
    +$html = render_browser_row('Mozilla/5.0 <img src=x onerror=alert(/XSS_94821/)>', 'on');
    +assert_contains('slimstat-tooltip-content', $html, 'Tooltip container is emitted when setting is on');
    +assert_not_contains('onerror=', $html, 'onerror attribute must be stripped from rendered tooltip');
    +assert_contains('Mozilla/5.0', $html, 'Benign UA prefix still rendered');
    +
    +// Test 15: <script> in user_agent is removed from rendered tooltip (wp_kses_post strips script).
    +$html = render_browser_row('<script>alert(1)</script>UA', 'on');
    +assert_not_contains('<script>alert(1)</script>', $html, 'script tag must be stripped from tooltip');
    +
    +// Test 16: Tooltip-OFF baseline — no tooltip span emitted, gating still works.
    +$html = render_browser_row('Mozilla/5.0', 'off');
    +assert_not_contains('slimstat-tooltip-content', $html, 'No tooltip span when setting is off');
    +
    +// Test 17: inline_help() preserves legitimate HTML used in existing tooltips (regression guard).
    +$rendered = wp_slimstat_reports::inline_help('<strong>Tip:</strong> read the <a href="/docs">docs</a>', false);
    +assert_contains('<strong>', $rendered, 'wp_kses_post preserves <strong> in tooltip text');
    +assert_contains('</strong>', $rendered, 'wp_kses_post preserves </strong>');
    +assert_contains('<a href="/docs">', $rendered, 'wp_kses_post preserves safe <a href> tags');
    +
    +// Test 18: inline_help() strips event-handler attributes.
    +$rendered = wp_slimstat_reports::inline_help('<img src=x onerror=alert(1)>', false);
    +assert_not_contains('onerror', $rendered, 'wp_kses_post strips onerror handler');
    +
    +// Test 19: inline_help() strips <script> tags entirely.
    +$rendered = wp_slimstat_reports::inline_help('<script>alert(1)</script>after', false);
    +assert_not_contains('<script', $rendered, 'wp_kses_post strips <script> tags');
    +
    +// Test 20: inline_help() preserves common formatting tags used in report tooltips (<em>, <br>, <p>, <span class>).
    +$rendered = wp_slimstat_reports::inline_help('<em>note</em><br><p><span class="x">hi</span></p>', false);
    +assert_contains('<em>', $rendered, 'wp_kses_post preserves <em>');
    +assert_contains('<br', $rendered, 'wp_kses_post preserves <br>');
    +assert_contains('<p>', $rendered, 'wp_kses_post preserves <p>');
    +assert_contains('<span class="x">', $rendered, 'wp_kses_post preserves <span class>');
    +
     echo "All {$assertions} assertions passed in reports-output-escaping-test.php\n";
    
  • tests/storage-update-sanitization-test.php+306 0 added
    @@ -0,0 +1,306 @@
    +<?php
    +
    +/**
    + * Tests for Storage::updateRow() sanitization parity with Storage::insertRow().
    + *
    + * Covers: CVE-2026-7634 — Storage::updateRow() lacked the sanitize_text_field()
    + * loop that Storage::insertRow() applies, allowing raw HTML/JS in user_agent
    + * (and other columns) to overwrite the sanitized row when a request triggered
    + * a redirect (Processor::updateContentType) or AJAX update (Ajax::process).
    + *
    + * Run: php tests/storage-update-sanitization-test.php
    + */
    +
    +declare(strict_types=1);
    +
    +// ─── Fake Query / wpdb that captures the SQL payload ───────────────
    +
    +namespace SlimStat\Utils {
    +
    +    class FakeQueryRecorder
    +    {
    +        public static array $setClauses = [];
    +        public static array $setRawClauses = [];
    +        public static array $setRawParams = [];
    +        public static ?int $where_id = null;
    +        public static int $executeCalls = 0;
    +
    +        public static function reset(): void
    +        {
    +            self::$setClauses     = [];
    +            self::$setRawClauses  = [];
    +            self::$setRawParams   = [];
    +            self::$where_id       = null;
    +            self::$executeCalls   = 0;
    +        }
    +    }
    +
    +    class Query
    +    {
    +        public static function update($table)
    +        {
    +            return new self();
    +        }
    +
    +        public function ignore($flag = true)
    +        {
    +            return $this;
    +        }
    +
    +        public function where($field, $operator, $value)
    +        {
    +            if ('id' === $field) {
    +                FakeQueryRecorder::$where_id = (int) $value;
    +            }
    +            return $this;
    +        }
    +
    +        public function set($values)
    +        {
    +            FakeQueryRecorder::$setClauses = $values;
    +            return $this;
    +        }
    +
    +        public function setRaw($column, $expression, $params = [])
    +        {
    +            FakeQueryRecorder::$setRawClauses[$column] = $expression;
    +            FakeQueryRecorder::$setRawParams[$column]  = $params;
    +            return $this;
    +        }
    +
    +        public function execute()
    +        {
    +            FakeQueryRecorder::$executeCalls++;
    +            return 1;
    +        }
    +    }
    +}
    +
    +namespace {
    +
    +    $assertions = 0;
    +
    +    function assert_same($expected, $actual, string $message): void
    +    {
    +        global $assertions;
    +        $assertions++;
    +
    +        if ($expected !== $actual) {
    +            fwrite(STDERR, "FAIL: {$message} (expected " . var_export($expected, true) . ', got ' . var_export($actual, true) . ")\n");
    +            exit(1);
    +        }
    +    }
    +
    +    function assert_true($actual, string $message): void
    +    {
    +        global $assertions;
    +        $assertions++;
    +
    +        if ($actual !== true) {
    +            fwrite(STDERR, "FAIL: {$message} (expected true, got " . var_export($actual, true) . ")\n");
    +            exit(1);
    +        }
    +    }
    +
    +    function assert_false($actual, string $message): void
    +    {
    +        global $assertions;
    +        $assertions++;
    +
    +        if ($actual !== false) {
    +            fwrite(STDERR, "FAIL: {$message} (expected false, got " . var_export($actual, true) . ")\n");
    +            exit(1);
    +        }
    +    }
    +
    +    function assert_not_contains(string $needle, string $haystack, string $message): void
    +    {
    +        global $assertions;
    +        $assertions++;
    +
    +        if (strpos($haystack, $needle) !== false) {
    +            fwrite(STDERR, "FAIL: {$message}\n  Expected NOT to contain: '{$needle}'\n  In: '{$haystack}'\n");
    +            exit(1);
    +        }
    +    }
    +
    +    function assert_contains(string $needle, string $haystack, string $message): void
    +    {
    +        global $assertions;
    +        $assertions++;
    +
    +        if (strpos($haystack, $needle) === false) {
    +            fwrite(STDERR, "FAIL: {$message}\n  Expected to contain: '{$needle}'\n  In: '{$haystack}'\n");
    +            exit(1);
    +        }
    +    }
    +
    +    // ─── WordPress function stubs ──────────────────────────────────────
    +
    +    if (!function_exists('sanitize_text_field')) {
    +        function sanitize_text_field($str)
    +        {
    +            $str = (string) $str;
    +            $str = strip_tags($str);
    +            $str = preg_replace('/[\r\n\t ]+/', ' ', $str);
    +            return trim($str);
    +        }
    +    }
    +
    +    if (!function_exists('sanitize_url')) {
    +        function sanitize_url($url)
    +        {
    +            $url = (string) $url;
    +            $url = trim($url);
    +            if (preg_match('#^(javascript|data|vbscript):#i', $url)) {
    +                return '';
    +            }
    +            return strip_tags($url);
    +        }
    +    }
    +
    +    if (!function_exists('wp_unslash')) {
    +        function wp_unslash($value)
    +        {
    +            if (is_array($value)) {
    +                return array_map('wp_unslash', $value);
    +            }
    +            return is_string($value) ? stripslashes($value) : $value;
    +        }
    +    }
    +
    +    // Stub global $wpdb so Storage doesn't crash on prefix lookup.
    +    $GLOBALS['wpdb'] = new class {
    +        public string $prefix = 'wp_';
    +    };
    +
    +    // Load the SUT.
    +    require_once __DIR__ . '/../src/Tracker/Storage.php';
    +
    +    // ─── Test 1: user_agent with XSS payload is stripped ───────────────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    $result = \SlimStat\Tracker\Storage::updateRow([
    +        'id'         => 42,
    +        'user_agent' => 'Mozilla/5.0 <img src=x onerror=alert(/XSS/)>',
    +    ]);
    +    assert_same(42, $result, 'updateRow returns the id on success');
    +    assert_same(42, \SlimStat\Utils\FakeQueryRecorder::$where_id, 'WHERE id is bound to the input id');
    +    assert_same(1, \SlimStat\Utils\FakeQueryRecorder::$executeCalls, 'execute() is called exactly once');
    +    $ua = \SlimStat\Utils\FakeQueryRecorder::$setClauses['user_agent'] ?? null;
    +    assert_same('Mozilla/5.0', $ua, 'user_agent has HTML tags stripped via sanitize_text_field');
    +    assert_not_contains('<img', $ua ?? '', 'user_agent must not retain <img tag');
    +    assert_not_contains('onerror', $ua ?? '', 'user_agent must not retain onerror handler');
    +
    +    // ─── Test 2: <script> in user_agent is fully removed ──────────────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'         => 1,
    +        'user_agent' => '<script>alert(1)</script>Mozilla/5.0',
    +    ]);
    +    $ua = \SlimStat\Utils\FakeQueryRecorder::$setClauses['user_agent'] ?? null;
    +    assert_same('alert(1)Mozilla/5.0', $ua, 'sanitize_text_field strips <script> tags but keeps inner text');
    +    assert_not_contains('<script', $ua ?? '', 'no script tag survives');
    +
    +    // ─── Test 3: referer is sanitized as URL (sanitize_url) ───────────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'      => 1,
    +        'referer' => 'https://example.com/?q=<script>alert(1)</script>',
    +    ]);
    +    $referer = \SlimStat\Utils\FakeQueryRecorder::$setClauses['referer'] ?? null;
    +    assert_not_contains('<script', $referer ?? '', 'referer must not contain script tag');
    +
    +    // ─── Test 4: notes array — each element sanitized then imploded ───
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'    => 1,
    +        'notes' => ['user:1', 'pre:yes', '<script>alert(1)</script>'],
    +    ]);
    +    $notesParams = \SlimStat\Utils\FakeQueryRecorder::$setRawParams['notes'] ?? [];
    +    assert_true(!empty($notesParams), 'setRaw was called for notes column');
    +    $notesString = $notesParams[0] ?? '';
    +    assert_contains('[user:1]', $notesString, 'notes preserves benign markers');
    +    assert_contains('[pre:yes]', $notesString, 'notes preserves benign markers');
    +    assert_not_contains('<script', $notesString, 'notes stripped of script tag (HTML tags removed by sanitize_text_field)');
    +    assert_not_contains('</script', $notesString, 'no closing script tag survives');
    +
    +    // ─── Test 5a: outbound_resource — javascript: scheme rejected entirely ─
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'                => 1,
    +        'outbound_resource' => 'javascript:alert(1)',
    +    ]);
    +    // sanitize_url returns '' for javascript: scheme; the empty value then
    +    // fails the !empty($data['outbound_resource']) gate, so no UPDATE is
    +    // performed for this field. This is stricter (and safer) than pre-fix.
    +    assert_true(empty(\SlimStat\Utils\FakeQueryRecorder::$setRawParams['outbound_resource'] ?? []), 'javascript: outbound_resource must not reach setRaw');
    +    assert_true(!array_key_exists('outbound_resource', \SlimStat\Utils\FakeQueryRecorder::$setClauses), 'javascript: outbound_resource must not appear in SET');
    +    assert_same(0, \SlimStat\Utils\FakeQueryRecorder::$executeCalls, 'no SQL is executed when the only update field is sanitized away');
    +
    +    // ─── Test 5b: outbound_resource — valid URL still flows through setRaw ─
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'                => 1,
    +        'outbound_resource' => 'https://example.com/landing',
    +    ]);
    +    $outboundParams = \SlimStat\Utils\FakeQueryRecorder::$setRawParams['outbound_resource'] ?? [];
    +    assert_true(!empty($outboundParams), 'valid outbound_resource still uses setRaw');
    +    assert_same('https://example.com/landing', $outboundParams[0] ?? null, 'valid URL preserved through both sanitization passes');
    +
    +    // ─── Test 6: locale codes preserved exactly ───────────────────────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'       => 1,
    +        'language' => 'en-US',
    +        'country'  => 'us',
    +        'browser'  => 'Chrome',
    +        'platform' => 'windows',
    +    ]);
    +    assert_same('en-US', \SlimStat\Utils\FakeQueryRecorder::$setClauses['language'] ?? null, 'language code preserved');
    +    assert_same('us', \SlimStat\Utils\FakeQueryRecorder::$setClauses['country'] ?? null, 'country code preserved');
    +    assert_same('Chrome', \SlimStat\Utils\FakeQueryRecorder::$setClauses['browser'] ?? null, 'browser name preserved');
    +    assert_same('windows', \SlimStat\Utils\FakeQueryRecorder::$setClauses['platform'] ?? null, 'platform name preserved');
    +
    +    // ─── Test 7: empty data returns false (no SQL run) ────────────────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    $result = \SlimStat\Tracker\Storage::updateRow([]);
    +    assert_false($result, 'updateRow returns false on empty input');
    +    assert_same(0, \SlimStat\Utils\FakeQueryRecorder::$executeCalls, 'execute() not called for empty input');
    +
    +    // ─── Test 8: missing id returns false ─────────────────────────────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    $result = \SlimStat\Tracker\Storage::updateRow(['user_agent' => 'Mozilla/5.0']);
    +    assert_false($result, 'updateRow returns false when id missing');
    +    assert_same(0, \SlimStat\Utils\FakeQueryRecorder::$executeCalls, 'execute() not called when id missing');
    +
    +    // ─── Test 9: redirect content_type passes through unchanged ───────
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'           => 1,
    +        'content_type' => 'redirect:301',
    +    ]);
    +    assert_same('redirect:301', \SlimStat\Utils\FakeQueryRecorder::$setClauses['content_type'] ?? null, 'content_type redirect marker preserved');
    +
    +    // ─── Test 10: id is unset before sanitization (never appears in SET) ─
    +
    +    \SlimStat\Utils\FakeQueryRecorder::reset();
    +    \SlimStat\Tracker\Storage::updateRow([
    +        'id'         => 99,
    +        'user_agent' => 'Mozilla',
    +    ]);
    +    assert_true(!array_key_exists('id', \SlimStat\Utils\FakeQueryRecorder::$setClauses), 'id must not appear in SET clauses');
    +    assert_same(99, \SlimStat\Utils\FakeQueryRecorder::$where_id, 'id is bound to WHERE clause only');
    +
    +    fwrite(STDOUT, "OK: {$assertions} assertions passed (Storage::updateRow sanitization)\n");
    +    exit(0);
    +}
    
  • tests/Unit/Utils/UADetectorBotTest.php+58 0 modified
    @@ -105,4 +105,62 @@ public function test_simple_googlebot_ua_detected_as_crawler(): void
     
             $this->assertSame(1, $browser['browser_type'], 'Simple Googlebot UA must be browser_type=1');
         }
    +
    +    /**
    +     * Extended bot coverage — UAs that previously slipped through
    +     * BOT_GENERIC_REGEX because they lacked a bot keyword or URL.
    +     *
    +     * @test
    +     * @dataProvider vendorBotProvider
    +     */
    +    public function test_vendor_bot_detected_as_crawler(string $label, string $ua): void
    +    {
    +        $browser = \SlimStat\Utils\UADetector::get_browser($ua);
    +        $this->assertSame(1, $browser['browser_type'], "{$label} must be browser_type=1");
    +    }
    +
    +    public static function vendorBotProvider(): array
    +    {
    +        return [
    +            'Mediapartners-Google'          => ['Mediapartners-Google', 'Mediapartners-Google'],
    +            'Google-InspectionTool desktop' => ['Google-InspectionTool desktop', 'Mozilla/5.0 (compatible; Google-InspectionTool/1.0)'],
    +            'Google-InspectionTool mobile'  => ['Google-InspectionTool mobile', 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.117 Mobile Safari/537.36 (compatible; Google-InspectionTool/1.0)'],
    +            'Google-Site-Verification'      => ['Google-Site-Verification', 'Mozilla/5.0 (compatible; Google-Site-Verification/1.0)'],
    +            'Google Favicon'                => ['Google Favicon', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36 Google Favicon'],
    +            'GoogleOther'                   => ['GoogleOther', 'GoogleOther'],
    +            'GoogleOther-Image'             => ['GoogleOther-Image', 'GoogleOther-Image/1.0'],
    +            'GoogleAgent-Mariner'           => ['GoogleAgent-Mariner', 'GoogleAgent-Mariner'],
    +            'Google-Safety'                 => ['Google-Safety', 'Google-Safety'],
    +            'DuplexWeb-Google'              => ['DuplexWeb-Google', 'Mozilla/5.0 (Linux; Android 11; Pixel 2) AppleWebKit/537.36 DuplexWeb-Google/1.0'],
    +            'BingPreview'                   => ['BingPreview', 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36 BingPreview/1.0b'],
    +            'YandexDirect'                  => ['YandexDirect', 'YandexDirect/3.0'],
    +            'YandexFavicons'                => ['YandexFavicons', 'YandexFavicons/1.0'],
    +            'WhatsApp preview'              => ['WhatsApp preview', 'WhatsApp/2.19.81 A'],
    +            'SkypeUriPreview'               => ['SkypeUriPreview', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 SkypeUriPreview Preview/0.5'],
    +            'anthropic-ai'                  => ['anthropic-ai', 'anthropic-ai/1.0'],
    +            'cohere-ai'                     => ['cohere-ai', 'cohere-ai'],
    +        ];
    +    }
    +
    +    /**
    +     * Real browsers must never be flagged as crawlers. Guards against regex
    +     * false positives introduced by future vendor-token additions.
    +     *
    +     * @test
    +     * @dataProvider realBrowserProvider
    +     */
    +    public function test_real_browser_not_flagged(string $label, string $ua): void
    +    {
    +        $browser = \SlimStat\Utils\UADetector::get_browser($ua);
    +        $this->assertNotSame(1, $browser['browser_type'], "{$label} must NOT be browser_type=1");
    +    }
    +
    +    public static function realBrowserProvider(): array
    +    {
    +        return [
    +            'Edge'            => ['Real Edge', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0'],
    +            'Safari iOS'      => ['Real Safari iOS', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'],
    +            'Chrome Android'  => ['Real Chrome Android', 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36'],
    +        ];
    +    }
     }
    
  • wp-slimstat.php+2 2 modified
    @@ -3,7 +3,7 @@
      * Plugin Name: SlimStat Analytics
      * Plugin URI: https://wp-slimstat.com/
      * Description: The leading web analytics plugin for WordPress
    - * Version: 5.4.11
    + * Version: 5.4.12
      * Author: Jason Crouse, VeronaLabs
      * Text Domain: wp-slimstat
      * Domain Path: /languages
    @@ -20,7 +20,7 @@
     }
     
     // Set the plugin version and directory
    -define('SLIMSTAT_ANALYTICS_VERSION', '5.4.11');
    +define('SLIMSTAT_ANALYTICS_VERSION', '5.4.12');
     define('SLIMSTAT_FILE', __FILE__);
     define('SLIMSTAT_DIR', __DIR__);
     define('SLIMSTAT_URL', plugins_url('', __FILE__));
    

Vulnerability mechanics

Root cause

"Missing input sanitization in Storage::updateRow() allows raw HTML/JS in the User-Agent header to be stored in the database."

Attack vector

An unauthenticated attacker sends an HTTP request with a malicious `User-Agent` header containing HTML/JavaScript (e.g., `<img src=x onerror=alert(/XSS/)>`). When the request triggers a 301 redirect (e.g., accessing `/sample-page` without a trailing slash), `Processor::updateContentType` calls `Storage::updateRow()`, which previously wrote the unsanitized header directly into the `wp_slim_stats` table [patch_id=2868999]. The stored payload later executes when an administrator visits the Slimstat Browsers report page (`admin.php?page=slimview3`) and hovers over the tooltip trigger icon, but only if the `show_complete_user_agent_tooltip` setting is explicitly enabled (disabled by default) [ref_id=1].

Affected code

The vulnerability resides in `Storage::updateRow()` within `src/Tracker/Storage.php`. Unlike `Storage::insertRow()`, the `updateRow()` method lacked a `sanitize_text_field()` loop, allowing raw HTML/JS in the `user_agent` (and other columns) to be written directly to the database when a request triggered a redirect (`Processor::updateContentType`) or AJAX update (`Ajax::process`) [patch_id=2868999]. The stored payload is rendered in the admin Browsers report via a tooltip when the `show_complete_user_agent_tooltip` setting is enabled.

What the fix does

The patch adds a `sanitize_text_field()` loop inside `Storage::updateRow()` that mirrors the existing sanitization in `Storage::insertRow()`, stripping HTML tags from the `user_agent` and other text columns before they reach the SQL `SET` clause [patch_id=2868999]. The included test file (`tests/storage-update-sanitization-test.php`) verifies that payloads like `<img src=x onerror=alert(/XSS/)>` are reduced to `Mozilla/5.0`, that `javascript:` scheme URLs in `outbound_resource` are rejected entirely, and that benign data (locale codes, bot UAs) passes through unchanged. The E2E test (`cve-2026-7634-user-agent-xss.spec.ts`) confirms the fix closes every reachable path — redirect, outbound link AJAX, and direct DB seed — and that `wp_kses_post()` at render time provides defense-in-depth.

Preconditions

  • configThe 'show_complete_user_agent_tooltip' setting must be explicitly enabled by an administrator (disabled by default) for the stored payload to be rendered and executed.
  • networkAttacker must be able to send HTTP requests to the WordPress site (no authentication required).
  • inputThe request must trigger a code path that calls Storage::updateRow(), such as a 301 redirect or an AJAX update.

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

14

News mentions

0

No linked articles in our index yet.