Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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(),