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.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| KRITISCH | Internal REST route reachable without authentication | Internal reporting data returned to anonymous callers |
| HOCH | User enumeration via the public REST collection | Author accounts and identifiers listed |
| HOCH | Administrative variant shares one public route | Write path guarded only by a request parameter |
| MITTEL | Mail authentication not enforced | No enforcement policy for the sending domain |
| MITTEL | Content security policy missing on the main surface | No policy on any first-party page |
| MITTEL | Service identifiers in page source | Non-production endpoint named in comments |
The attack path
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
# 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
$ 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
$ dig +short TXT example.org | grep -i spf
$ dig +short TXT _dmarc.example.org
Remediation
Fail closed before the handler runs
// 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:
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.