A permission callback is not decoration. In the WordPress REST API it is the line that decides whether the request reaches your code at all. Return true there and the route is public, even if the callback reads data that only exists in the admin area.
That is CVE-2026-39513. Easy Appointments, a booking plugin with about 10,000 active installations at disclosure, exposed an endpoint that returned appointment rows and their custom fields. The route used __return_true as its permission_callback. No login, no capability check and no meaningful default filter. A request with no location, service or worker selected returned the full appointment table.
The line that made the route public was unusually easy to read. Version 3.12.21 registered it like this in ea-blocks/ea-blocks.php:
add_action('rest_api_init', function () {
register_rest_route('wp/v2/eablocks', '/ea_appointments/', [
'methods' => 'GET',
'callback' => 'easy_ea_block_get_appointments',
'permission_callback' => '__return_true', // Secure this if needed
]);
});The comment is almost a summary of the bug. WordPress requires a permission callback on modern REST routes, so developers sometimes add __return_true to satisfy the API and plan to revisit access control later. The route stops producing a warning. It also becomes deliberately accessible to everyone.
The public path was:
/wp-json/wp/v2/eablocks/ea_appointments/The callback accepted three optional filters:
function easy_ea_block_get_appointments(WP_REST_Request $request)
{
$location = intval($request->get_param('location'));
$service = intval($request->get_param('service'));
$worker = intval($request->get_param('worker'));
$data['location'] = $location;
$data['service'] = $service;
$data['worker'] = $worker;
return easy_ea_block_get_all_appointments($data);
}Each value was converted to an integer. That part was fine. The problem was what happened when the caller left all three out. Every value became zero, and the query builder only added a condition for non-empty values:
$query = "SELECT * FROM $tableName WHERE 1
{$location}{$service}{$worker}{$status}{$search}
ORDER BY id DESC";
$sql = $wpdb->prepare($query, $params);
$apps = $wpdb->get_results($sql, OBJECT_K);With no filters, the effective condition was just WHERE 1. The callback selected every column from every appointment, not merely the date ranges needed by a public calendar.
It then took the returned appointment IDs and joined the plugin's custom fields onto each row:
$ids = array_keys((array) $apps);
if (!empty($ids)) {
$fields = easy_ea_block_get_fields_for_apps($ids);
foreach ($fields as $f) {
if (isset($apps[$f->app_id])) {
$apps[$f->app_id]->{$f->slug} = $f->value;
}
}
}
return array_values((array) $apps);Those fields are defined by the site. In a normal booking form they can include a customer's name, email address, telephone number and whatever the business asks before accepting an appointment. The precise columns vary between installations, which is exactly why a generic public endpoint cannot make a safe assumption about them.
There are two useful distinctions here.
First, this was not an IDOR. The caller did not need to guess another customer's appointment ID. Omitting the filters returned the collection. The broken boundary was the route itself, so missing authorisation is the more accurate class.
Second, this was not caused by SQL injection. The numeric filters used %d placeholders and the appointment IDs used for the field lookup came from the database. The queries did what the developer wrote. The wrong person was allowed to ask them to run.
That distinction matters when writing the report. If you call every data leak an IDOR or every suspicious query an injection, the proposed fix drifts away from the bug. Here the fix belongs in permission_callback, before the data callback starts.
Version 3.12.22 replaced the public callback with this:
'permission_callback' => function ($request) {
$nonce = $request->get_header('X-WP-Nonce');
if (!wp_verify_nonce($nonce, 'wp_rest')) {
return new WP_Error(
'rest_forbidden',
'Invalid nonce',
['status' => 403]
);
}
return current_user_can('manage_options');
}The same release added a REST nonce to the calendar script. The important check is the last line. A nonce tells WordPress that the request came through a flow that received that token. It does not decide whether the current user should see appointment data. current_user_can('manage_options') supplies the actual authorisation rule and keeps anonymous and low-privilege users out.
The capability may be stricter than the plugin ultimately needs. A plugin-specific appointment-management capability would let a site delegate bookings without granting broad administrative access. That is a product decision. From a security perspective, the patched route at least has an explicit boundary that defaults to denial.
This bug came from looking at registrations before callbacks. That order saves time. A large plugin can contain hundreds of database calls, most of them unreachable to an anonymous visitor. Route declarations tell you which callbacks matter and under what identity they run.
For REST endpoints, I start with these searches:
# Route registrations
grep -rn "register_rest_route" wp-content/plugins/target/
# Explicitly public permission callbacks
grep -rn "permission_callback.*__return_true" wp-content/plugins/target/
# Callbacks that may return true unconditionally
grep -rn "permission_callback" wp-content/plugins/target/
# Data-heavy operations worth tracing from those callbacks
grep -rnE 'SELECT \*|get_results\(|get_col\(|get_posts\(' wp-content/plugins/target/__return_true is not a vulnerability by itself. Search suggestions, public product catalogues and availability windows may be intentionally public. The question is whether every field in the response is meant for any visitor. A calendar needs occupied time slots. It rarely needs the full appointment record and the answers from the booking form.
Once I find a public route, the review is mechanical:
- Write down the route, method, callback and permission callback.
- Call it from a clean session, without cookies, an
Authorizationheader or anX-WP-Nonceheader. - Leave optional filters out first. Developers often test the filtered UI path and miss that the empty path broadens the query.
- Trace every returned field to its source.
SELECT *and metadata joins deserve a full list, not a glance at the first JSON object. - Decide what identity should own or administer the record. Then compare that rule with what the permission callback enforces.
The empty-filter case is worth repeating. Optional filters are usually treated as a convenience for the client. In a collection endpoint they also define the maximum result set. If each condition is appended only when the parameter is present, no parameters often means no restrictions. Test the request that the UI sends, then test the smallest valid request the router accepts.
For confirmation, use a local copy or a site you are authorised to test and seed it with synthetic appointments. One record with recognisable fake values is enough. Send the request without a session and show that the values come back. You do not need to enumerate a real booking database to prove the boundary is missing.
The report went to Patchstack on 17 February 2026. The vendor fixed it in 3.12.22, and the CVE was published on 13 April. The patch was small because the root cause was small: one route promised WordPress that every caller was authorised.
The useful lesson is not "grep for __return_true and report every hit". It is to read route registration as security policy. permission_callback is executable policy. If it says everyone can enter, the callback and every function below it must be safe for everyone. In CVE-2026-39513, the code below it returned the booking database.
