~/blog/wordpress-rest-authorisation-gapsPOSTED
by Shady Nathan Tawfik · June 27, 2026 · 4 min

A Plugin Can Be Current And Still Hand Out Records To Anyone

A fully patched content management system carried a plugin with current releases and twelve findings. The pattern was authorisation: routes that answered before the permission callback ran.

Interactive[blog-2.0]

TL;DR: A fully patched content platform still handed out records to anyone, because the permission callback ran after the handler. I show how I read the route listing and found the write path guarded by a request parameter.

The scenario

I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.

A professional consultancy site on a current content management release with a plugin ecosystem managed by a third-party agency. Assessment covered REST route authorisation, user enumeration, mail authentication and content security policy on the public surface.

I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.

The pattern

The pattern I recognised — and that I keep finding in similar estates:

Patch management answers the question which versions are known to be vulnerable. It does not answer the question whether the installed versions enforce what the site assumes they enforce. A current plugin with twelve findings is not a contradiction; it is the normal outcome when nobody reads the permission callbacks of routes nobody uses.

The structural reason is that REST endpoints are registered separately from the pages that link to them. A plugin author registers a route for an internal tool, marks it public because the front end needs the data, and the administrative variant either reuses the same route with a parameter or forgets it entirely. The permission callback lives in a different file from the handler, which is exactly the arrangement that lets one ship without the other.

What makes this worth a dedicated engagement is that the symptoms look identical to an out-of-date install. Version banners, user enumeration and an open endpoint all read as patching failures in a report. They are not the same thing, and the remediation differs completely: one requires an update, the other requires a decision about who is allowed to see which rows.

TL;DR: A fully patched content management system carried a plugin with current releases and twelve findings. The pattern was authorisation: routes that answered before the permission callback ran.

Findings

SeverityFindingEvidence
KRITISCHInternal REST route reachable without authenticationInternal reporting data returned to anonymous callers
HOCHUser enumeration via the public REST collectionAuthor accounts and identifiers listed
HOCHAdministrative variant shares one public routeWrite path guarded only by a request parameter
MITTELMail authentication not enforcedNo enforcement policy for the sending domain
MITTELContent security policy missing on the main surfaceNo policy on any first-party page
MITTELService identifiers in page sourceNon-production endpoint named in comments

The attack path

Anonymous requestPublic REST routeHandler answers firstPermission callback runs laterData already sentWrite path reachable via parameter

Reproduction in my lab

Every command below targets a lab container I control. Nothing here is aimed at a live system.

Enumerate the routes a current install registers

~/code/bash bash
# target: your own lab instance
$ curl -s http://localhost:8080/wp-json/ | python3 -c '
import json,sys
d = json.load(sys.stdin)
for ns in d["namespaces"]:
    print(ns)'

# every namespace exposes a route listing; read it
$ curl -s http://localhost:8080/wp-json/reporting/v1/ | python3 -m json.tool | head -40

# now ask the route without any credentials
$ curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8080/wp-json/reporting/v1/entries'
$ curl -s 'http://localhost:8080/wp-json/reporting/v1/entries' | head -c 300

Detection in your own estate

Find routes that answer without a permission check

~/code/bash bash
$ curl -s https://example.org/wp-json/ | python3 -c '
import json,sys
d = json.load(sys.stdin)
for ns in d["namespaces"]:
    print(ns)' | while read -r ns; do
      curl -s "https://example.org/wp-json/$ns" | python3 -c '
import json,sys
try:
    routes = json.load(sys.stdin).get("routes", {})
except Exception:
    raise SystemExit
for r, v in routes.items():
    methods = [m for m in v.get("methods", []) if m != "OPTIONS"]
    if methods:
        print(",".join(methods), r)'
done | sort | uniq

Check the mail authentication chain for the sending domain

~/code/bash bash
$ dig +short TXT example.org | grep -i spf
$ dig +short TXT _dmarc.example.org

Remediation

Fail closed before the handler runs

~/code/php php
// every route answers only after the permission callback succeeded
add_action( 'rest_api_init', function () {
    register_rest_route( 'reporting/v1', '/entries', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'reporting_entries',
        'permission_callback' => function ( $request ) {
            $user = wp_get_current_user();
            if ( ! $user || ! current_user_can( 'read_reporting' ) ) {
                return new WP_Error( 'forbidden', 'Not allowed.', array( 'status' => 403 ) );
            }
            return true;
        },
        'args'                => array( 'tenant' => array( 'validate_callback' => 'reporting_validate_tenant' ) ),
    ) );
});

// and never accept the tenant from the request alone
function reporting_validate_tenant( $value, $request, $key ) {
    $allowed = reporting_tenants_for_current_user();
    return in_array( (int) $value, $allowed, true ) ? $value
        : new WP_Error( 'invalid_tenant', 'Not your tenant.', array( 'status' => 403 ) );
}

Once an attacker gains access, they can perform the following actions:

Internal REST route reachable without authentication
User enumeration via the public REST collection
Administrative variant shares one public route
Mail authentication not enforced
Content security policy missing on the main surface
Service identifiers in page source

Takeaways

  • A current plugin is not an authorised plugin.
  • Read the permission callbacks of routes nobody links to.
  • Separate read and write paths; a parameter is not a permission.
  • Version banners look like patch failures and produce the wrong fix.

For educational purposes only. Every reproducible command targets my own lab environment (localhost), never a live system. © 2026