Why AI plugins are the usual suspects
Every AI, chatbot and LLM plugin needs the block editor to talk to its own backend. The fastest way to make that work is to register a route and pass permission_callback a value that always says yes. Nine times out of ten that value is '__return_true'.
That single string turns a model call into an anonymous, unauthenticated, unmetered endpoint. Anyone who can reach /wp-json/ai/v1/generate can burn your OpenAI credits, use your site as a free proxy, or push prompts through a route that was only ever designed for wp-admin. WordPress has required a permission_callback since 5.5, but __return_true satisfies the requirement while doing precisely nothing.
This tutorial builds a WP-CLI command that boots the REST server, walks rest_get_server()->get_routes(), classifies every endpoint’s permission callback, groups the output by namespace, and returns CI-friendly exit codes. It finishes with a companion mu-plugin that records 401/403 ratios per route so you can see who is probing.
Prerequisites
- WordPress 6.4 or newer, WP-CLI 2.7 or newer, PHP 7.4 or newer (the command uses typed properties and arrow functions).
- Shell access to the site (SSH or a local copy). Never run audits against production for the first time.
- Write access to
wp-content/mu-plugins/. Create the directory if it does not exist. - A staging clone with the same plugin set as production, so the route inventory matches.
What get_routes() actually gives you
rest_get_server() returns the WP_REST_Server singleton. The first call instantiates the server and fires rest_api_init, which is exactly why the audit must run after WordPress has loaded — every plugin registers its routes on that hook.
get_routes() returns an associative array keyed by route string. Each value is a list of endpoints, and each endpoint is an array with four keys:
- methods — an array like
array( 'GET' => true, 'POST' => true ). - callback — the handler.
- permission_callback — the thing we care about. It lives at endpoint level, not inside
args. - args — the schema and sanitisation rules.
Two consequences matter. First, core routes are in the same array, so an unfiltered audit is noisy — /wp/v2/posts is supposed to be publicly readable. Second, register_rest_route() merges into an existing route key, so a plugin can quietly add a POST handler to a route that already existed.
Step 1 — Scaffold the command as an mu-plugin
Drop the file in wp-content/mu-plugins/rest-audit.php. An mu-plugin is always loaded, so the command survives a plugin being deactivated mid-incident — which is precisely when you want to run it.
<?php
/**
* Plugin Name: WP Tuts Hub REST Permission Auditor
* Description: WP-CLI command that flags REST routes with a missing or permissive permission_callback.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
class WPTH_REST_Audit_Command extends WP_CLI_Command {
/**
* Flag REST routes that are reachable without a real permission check.
*
* ## OPTIONS
*
* [--namespace=<namespace>]
* : Limit the audit to one namespace, e.g. "ai/v1".
*
* [--format=<format>]
* : table, csv or json. Default: table.
*
* [--strict]
* : Exit with code 1 when anything is flagged.
*
* ## EXAMPLES
*
* wp wpth rest-audit --namespace=ai/v1 --strict
*
* @when after_wp_load
*/
public function __invoke( array $args, array $assoc_args ): void {
$namespace = isset( $assoc_args['namespace'] ) ? trim( (string) $assoc_args['namespace'], '/' ) : '';
$format = $assoc_args['format'] ?? 'table';
$strict = (bool) ( $assoc_args['strict'] ?? false );
// First call boots WP_REST_Server and fires rest_api_init.
$routes = rest_get_server()->get_routes();
$rows = array();
foreach ( $routes as $route => $handlers ) {
$ns = $this->namespace_of( $route );
if ( '' !== $namespace && $ns !== $namespace ) {
continue;
}
foreach ( $handlers as $handler ) {
$methods = isset( $handler['methods'] )
? array_keys( array_filter( (array) $handler['methods'] ) )
: array();
$callback = $handler['permission_callback'] ?? null;
$verdict = $this->classify( $callback );
$rows[] = array(
'namespace' => $ns,
'route' => $route,
'methods' => implode( ',', $methods ),
'verdict' => $verdict,
'risk' => $this->risk( $verdict, $methods ),
'callback' => $this->describe( $callback ),
);
}
}
if ( empty( $rows ) ) {
WP_CLI::warning( sprintf( 'No REST routes matched namespace "%s".', $namespace ) );
WP_CLI::halt( 2 );
}
usort(
$rows,
static fn( $a, $b ) => array( $a['namespace'], $a['route'] ) <=> array( $b['namespace'], $b['route'] )
);
WP_CLI\Utils\format_items( $format, $rows, array( 'namespace', 'route', 'methods', 'verdict', 'risk', 'callback' ) );
$flagged = array_filter(
$rows,
static fn( $r ) => in_array( $r['verdict'], array( 'missing', 'public', 'review' ), true )
);
WP_CLI::log( sprintf( '%d endpoints checked, %d flagged.', count( $rows ), count( $flagged ) ) );
if ( $strict && ! empty( $flagged ) ) {
WP_CLI::halt( 1 );
}
}
}
Step 2 — Classify each permission_callback
The classification is where the value lives. A callback can be a string function name, an array of object and method, a closure, or null. Closures are the awkward case: you cannot call them safely during an audit (they may touch the database or the current user), so read their source instead.
Append these helpers to the class, then register the command at the bottom of the file.
private function namespace_of( string $route ): string {
$parts = explode( '/', trim( $route, '/' ) );
if ( isset( $parts[1] ) && preg_match( '/^v\d+$/', $parts[1] ) ) {
return $parts[0] . '/' . $parts[1];
}
return $parts[0] ?? '';
}
private function classify( $callback ): string {
if ( null === $callback ) {
return 'missing';
}
if ( '__return_true' === $callback ) {
return 'public';
}
if ( '__return_false' === $callback ) {
return 'denied';
}
if ( is_string( $callback ) || is_array( $callback ) ) {
return 'callback';
}
if ( $callback instanceof Closure ) {
return $this->closure_returns_true( $callback ) ? 'public' : 'review';
}
return 'review';
}
private function risk( string $verdict, array $methods ): string {
$writes = array_intersect( $methods, array( 'POST', 'PUT', 'PATCH', 'DELETE' ) );
if ( in_array( $verdict, array( 'missing', 'public' ), true ) ) {
return $writes ? 'high' : 'medium';
}
return 'review' === $verdict ? 'medium' : 'low';
}
private function describe( $callback ): string {
if ( null === $callback ) {
return '(none)';
}
if ( is_string( $callback ) ) {
return $callback;
}
if ( is_array( $callback ) ) {
$target = is_object( $callback[0] ) ? get_class( $callback[0] ) : (string) $callback[0];
return $target . '::' . $callback[1];
}
if ( $callback instanceof Closure ) {
$ref = new ReflectionFunction( $callback );
return sprintf( 'Closure@%s:%d', basename( (string) $ref->getFileName() ), $ref->getStartLine() );
}
return gettype( $callback );
}
private function closure_returns_true( Closure $closure ): bool {
$ref = new ReflectionFunction( $closure );
$file = $ref->getFileName();
if ( ! $file || ! is_readable( $file ) ) {
return false;
}
$lines = array_slice( file( $file ), $ref->getStartLine() - 1, $ref->getEndLine() - $ref->getStartLine() + 1 );
$body = preg_replace( '/\s+/', '', implode( '', $lines ) );
return (bool) preg_match( '/(\{return(true|1);\}|=>true;?|=>1;?)$/', $body );
}
}
WP_CLI::add_command( 'wpth rest-audit', 'WPTH_REST_Audit_Command' );
The verdicts are deliberately blunt. missing means no permission_callback key at all — rare since 5.5, but it still happens with routes injected through the rest_endpoints filter. public means __return_true, or a closure whose entire body is return true;. denied is __return_false, which is safe but usually a bug. callback is a named function or method — assume it does something real, but read it. review is a closure the heuristic could not read, which happens with opcache-only deployments and code generated at runtime.
The risk column is the part that saves you time. A public GET on a read-only route is often intentional. A public POST on /ai/v1/generate is a billing incident waiting to happen.
Step 3 — Filter, format and read the output
Run wp wpth rest-audit --format=json on staging and pipe it through jq to see the shape. A typical AI plugin looks like this:
[
{
"namespace": "ai\/v1",
"route": "\/ai\/v1\/generate",
"methods": "POST",
"verdict": "public",
"risk": "high",
"callback": "__return_true"
},
{
"namespace": "ai\/v1",
"route": "\/ai\/v1\/models",
"methods": "GET",
"verdict": "missing",
"risk": "medium",
"callback": "(none)"
}
]
To discover which namespaces exist at all, strip the route column and deduplicate:
wp wpth rest-audit --format=csv | tail -n +2 | cut -d, -f1 | sort -u
You will usually see wp/v2, wc/v3, ai/v1, chatbot/v1 and one or two vendor namespaces. That list is your scope for the next step.
Step 4 — Wire it into CI with meaningful exit codes
Three exit codes do the work: 0 clean, 1 flagged under --strict, 2 the namespace filter matched nothing. That third code matters more than it looks. If a plugin is renamed or deactivated, an audit that only checks for failures goes green and you learn nothing.
#!/usr/bin/env bash
set -uo pipefail
wp wpth rest-audit --namespace=ai/v1 --strict --format=table
status=$?
case "$status" in
0) echo "OK: ai/v1 is locked down" ;;
1) echo "FAIL: unprotected route in ai/v1" >&2 ;;
2) echo "FAIL: ai/v1 not registered - plugin renamed or disabled?" >&2 ;;
*) echo "FAIL: unexpected exit $status" >&2 ;;
esac
exit "$status"
Note the deliberate absence of set -e. With it, the script would exit before status=$? could capture the code and your case block would never run.
Step 5 — Companion mu-plugin: log 401/403 ratios per route
The audit tells you what is exposed. The logger tells you whether anyone found it. Hook rest_post_dispatch, which fires after every REST response and receives the result, the server and the request.
Do not call update_option() inside the filter — that is one write per request. Buffer in a static array and flush once on shutdown.
<?php
/**
* Plugin Name: REST Auth Probe Logger
* Description: Buffers 401/403 counts per REST route so probing is visible.
*/
defined( 'ABSPATH' ) || exit;
class WPTH_REST_Probe_Log {
private static array $buffer = array();
public static function init(): void {
add_filter( 'rest_post_dispatch', array( __CLASS__, 'capture' ), 10, 3 );
add_action( 'shutdown', array( __CLASS__, 'flush' ) );
}
public static function capture( $result, $server, WP_REST_Request $request ) {
$data = is_wp_error( $result ) ? (array) $result->get_error_data() : array();
$status = is_wp_error( $result ) ? (int) ( $data['status'] ?? 0 ) : (int) $result->get_status();
$route = $request->get_route();
self::$buffer[ $route ]['total'] = ( self::$buffer[ $route ]['total'] ?? 0 ) + 1;
if ( 401 === $status || 403 === $status ) {
self::$buffer[ $route ][ $status ] = ( self::$buffer[ $route ][ $status ] ?? 0 ) + 1;
}
return $result;
}
public static function flush(): void {
if ( empty( self::$buffer ) ) {
return;
}
$index = get_option( 'wpth_rest_probe_index', array() );
foreach ( self::$buffer as $route => $counts ) {
foreach ( $counts as $key => $n ) {
$index[ $route ][ $key ] = ( $index[ $route ][ $key ] ?? 0 ) + $n;
}
}
update_option( 'wpth_rest_probe_index', $index, false );
}
}
WPTH_REST_Probe_Log::init();
The distinction between the two codes is the signal. 401 means no credentials or invalid ones — a client that never authenticated. 403 means the request authenticated but the permission callback refused it. A route that should be public but shows a rising 401 count is being probed by something that expects a login. A route showing 403s from the same IP is a logged-in user or application password holder hitting a capability wall.
The option write is one per request, which is fine on a normal site and not fine on a high-traffic API. If you serve thousands of REST requests a minute, push the buffer into a persistent object cache or a custom table instead of wp_options.
Step 6 — Read the probe report
Add a second command to the audit class that turns the stored counters into a sorted table. Insert this method inside WPTH_REST_Audit_Command, before the closing brace.
/**
* Report 401/403 ratios per REST route.
*
* ## OPTIONS
*
* [--min-total=<n>]
* : Ignore routes with fewer than n requests. Default: 20.
*
* @when after_wp_load
*/
public function probe_report( array $args, array $assoc_args ): void {
$min = (int) ( $assoc_args['min-total'] ?? 20 );
$index = get_option( 'wpth_rest_probe_index', array() );
$rows = array();
foreach ( $index as $route => $counts ) {
$total = (int) ( $counts['total'] ?? 0 );
if ( $total < $min ) {
continue;
}
$denied = (int) ( $counts['401'] ?? 0 ) + (int) ( $counts['403'] ?? 0 );
$rows[] = array(
'route' => $route,
'total' => $total,
'401' => (int) ( $counts['401'] ?? 0 ),
'403' => (int) ( $counts['403'] ?? 0 ),
'denied%' => round( $denied / $total * 100, 1 ),
);
}
usort( $rows, static fn( $a, $b ) => $b['denied%'] <=> $a['denied%'] );
WP_CLI\Utils\format_items( 'table', $rows, array( 'route', 'total', '401', '403', 'denied%' ) );
}
Then register the second command after the class definition: WP_CLI::add_command( 'wpth probe-report', 'WPTH_REST_Audit_Command' );
Run wp wpth probe-report --min-total=50 weekly. Anything above roughly 30% denied on a route that is not a login endpoint deserves a look at the raw access log for the source IPs.
Troubleshooting
- “No REST routes matched namespace” — the namespace is the first two path segments only.
/ai/v1/generatebelongs toai/v1, notai. Run thecutone-liner above to list the real values. - Command not found — the
WP_CLIconstant guard returned early because the file was loaded outside WP-CLI, or the mu-plugin directory is not where you think. Confirm withwp eval 'var_dump( defined( "WP_CLI" ) );'. - Everything shows as
review— the plugin uses closures everywhere and your deployment strips source (opcache withopcache.save_comments=0does not strip files, but a build step might). Fall back togrep -rn "permission_callback" wp-content/plugins/. - 401 counts look inflated — expired application passwords and cookie sessions generate 401s legitimately. Compare against the same route’s
totalrather than the absolute number. - Option table bloat — the index is stored with
autoload = false, so it will not slow normal page loads, but delete it withwp option delete wpth_rest_probe_indexafter you have finished investigating.
Next steps
- Replace
__return_trueon write routes with a real capability check such ascurrent_user_can( 'edit_posts' ), or a nonce plus capability pair for editor-driven calls. - Add rate limiting in front of any AI namespace, so a leaked route cannot be used as a free inference proxy.
- Schedule both commands in a nightly cron and ship the JSON output to your log pipeline as a diffable artefact.
- Extend the audit to flag routes that accept
__return_trueonDELETEspecifically — those are the ones that delete data on an anonymous request.