The Exploit
An unauthenticated attacker with the ability to publish public singular content (such as a bbPress forum topic) can inject arbitrary JavaScript into analytics tracking code by poisoning their author display name. The SEOPress plugin must have the 'Track Authors' custom dimension enabled in Google Analytics 4 or Matomo settings.
POST /forum/new-topic HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
bbp_forum_id=1&bbp_topic_title=Legit+Question&bbp_topic_content=Hello&user_login=attacker&[email protected]&user_url=&pass1=Password123&pass2=Password123&user_nicename=attacker&display_name=%27);alert('XSS');//
The attacker observes no immediate response error. When any user visits the newly created forum topic (or any page where the poisoned author's posts appear), the injected JavaScript executes in their browser. The Google Analytics 4 or Matomo tracking script on the page contains the unescaped display name:
gtag('event', 'Authors', {'cd_author': '');alert('XSS');//', 'non_interaction': true});
The payload breaks out of the string context and executes arbitrary code in the victim's browser session, stealing session cookies, CSRF tokens, or performing actions on the victim's behalf.
What the Patch Did
Before:
$seopress_google_analytics_event['cd_author'] = "gtag('event', '" . __( 'Authors', 'wp-seopress' ) . "', {'cd_author': '" . get_the_author() . "', 'non_interaction': true});";
After:
$seopress_google_analytics_event['cd_author'] = "gtag('event', " . seopress_js_string( __( 'Authors', 'wp-seopress' ) ) . ", {'cd_author': " . seopress_js_string( get_the_author() ) . ", 'non_interaction': true});";
The patch introduces output escaping at the point of use via a custom seopress_js_string() function. This function escapes values destined for JavaScript string literals—converting single quotes, double quotes, backslashes, and other metacharacters into their JavaScript escape sequences. The function treats the output as a complete JSON-safe string and wraps it appropriately, breaking the attacker's ability to close the string context and inject arbitrary code. Previously, the plugin directly concatenated get_the_author() (a user-controlled value from the WordPress author display name field) into a JavaScript string without any escaping, trusting instead on HTML context escaping (esc_html() applied downstream) which is ineffective inside a JavaScript execution context.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
The dataflow is straightforward: an attacker sets their WordPress user's display name (the display_name field in the wp_users table or author meta) to a value containing JavaScript metacharacters. This value is retrieved by get_the_author() in the seopress_google_analytics_footer_code() function (invoked during page render) and concatenated directly into a JavaScript event tracking call without any output encoding. Because the sink is a JavaScript string literal context—not HTML—the trust boundary crossed is between user-controlled metadata and executable script. The attacker-controlled input reaches the sink through the WordPress user object, bypassing standard HTML-escape defences.
Why It Works
The load-bearing line is seopress_js_string( get_the_author() ). Removing it restores the vulnerability; the attacker's payload executes again. The engineer added parallel escaping calls around other dynamic values (__() translation strings, category names, tag names, domain parsing results) because all of them share the same sink context—JavaScript string literals embedded in gtag() and _paq.push() calls. A single unescaped variable in any of these positions re-opens the XSS gate. The fix recognizes that output escaping is context-specific: HTML escaping (esc_html() or esc_attr()) is useless in JavaScript contexts because the JavaScript parser does not interpret HTML entities. Only JavaScript string escaping—converting quotes and backslashes into their \x or \' equivalents—provides a fence. The patch applies this principle consistently across all ten affected functions in both Google Analytics and Matomo modules.
Hardening Checklist
-
Audit all string concatenations into script blocks: Use
wp_json_encode()or a dedicated JavaScript escaping function (likeseopress_js_string()) for every dynamic value inserted into<script>tags oron*=event attributes. Do not use HTML escape functions in JavaScript contexts. -
Require input validation and output escaping in the same diff: When a patch adds a new dynamic value to a script block, enforce a rule that the commit must also add an escaping call. Use pre-commit hooks or code review checklists to catch naked string concatenations.
-
Test XSS via WordPress author metadata: Create unit tests that poison
wp_users.display_name,wp_posts.post_author, and user meta fields with payloads like');alert(1);//, then verify these values do not appear unescaped in rendered script blocks. -
Centralize escaping functions: Define a single
seopress_js_string()equivalent in your plugin's core and document its use for all JavaScript sinks. Avoid ad-hoc escaping that may be incomplete. -
Lint JavaScript output: Use static analysis tools (e.g., ESLint with a custom rule or grep-based checks in CI) to flag patterns like
"' . $var . '"in PHP templates, alerting developers to likely XSS sinks before code review.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-96564