Script kiddies hate this one little trick!
WordPress REST API is a powerful tool. However, with great power comes great responsibility. Such as, for example, to not accidentally disclose information about your users. Yet, this is exactly what happens when you hit /wp/v2/users
endpoint. You can view users or you can view specific user information by adding the id argument. E.g. /wp/v2/users/31337
I can respect WordPress’ stance (as I understand it). “It’s the Web, people will access your site, WordPress is a blogging software”. But I don’t have to agree with it. IMO default permissions are too open for many cases.
There are several ways of restricting access to REST endpoints. I’m going to cover some of them.
TL;DR
- If you want to nuke the REST API, use the
rest_endpoints
filter. It doesn’t disable API completely. Instead, it can be used to remove routes, so that the API will return a404
error. - If you simply want block some parts and you don’t care much about the context, use
rest_authentication_errors
. - If you need more granular control of what needs to be disabled depending on the passed arguments, use
rest_pre_dispatch
,rest_request_before_callbacks
orrest_dispatch_request
.
Note on cookie-based auth security.
REST API requires requests to be signed with a nonce to mitigate the possibility of a CSRF attack: if someone steals your user’s cookies, they won’t be able to pose as that user without that nonce. You can use capability checks (e.g., current_user_can in all of the approaches listed below, but, in case you see unintended results in your testing, the chances are that you may have forgotten to pass the correct nonce to the request. Be sure to check the REST API cookbook for more details on cookie-based auth.
The hardcore way: rest_endpoints
You may have seen advice to use the rest_endpoints
filter. I think it’s not a good option for the majority of the cases. Here’s an example:
// Nuclear option: disable all endpoints
add_filter( 'rest_endpoints', function( $endpoints ) {
// No REST for the restless
$endpoints = [];
return $endpoints;
} );
There, easy, right? All endpoints are gone, and we’re entirely “safe.” The only problem? Now you might have a broken site. Both Core and User-land code (plugins) use REST API extensively, so filtering out endpoints like that can break things. Depending on what was filtered, it can blow up either spectacularly (try the above with Gutenberg on) or subtly.
My issue with that approach is that the filter is unwieldy because it’s a huge array with routes as keys. From the docs:
(array) The available endpoints. An array of matching regex patterns, each mapped to an array of callbacks for the endpoint. These take the format
https://developer.wordpress.org/reference/hooks/rest_endpoints/#parameters'/path/regex' => array( $callback, $bitmask )
or'/path/regex' => array( array( $callback, $bitmask )
.
Now you either have to iterate over the array to figure out which endpoints to remove or call unset
on specific ones:
// Remove a specific or several endpoints
add_filter( 'rest_endpoints', function( $endpoints ) {
// Unset a specific endpoint
unset( $endpoints['/wp/v2/users/(?P<id>[\\d]+)'] );
// or iterate and figure out which ones to remove
foreach( $endpoints as $route => $handler ) {
// somehow decide this endpoint needs to be removed
}
return $endpoints;
} );
If you want to use a nuclear option to “disable” REST API, this is your jam. Removing endpoints using this approach results in a 404
error:
{
"code": "rest_no_route",
"message": "No route was found matching the URL and request method",
"data": {
"status": 404
}
}
More balanced approach: rest_authentication_errors
I think this method, while not without drawbacks, is preferable.
Filters REST authentication errors.
A WP_Error instance can be returned if an error occurs, and this should match the format used by API methods internally (that is, the
https://developer.wordpress.org/reference/hooks/rest_authentication_errors/status
data should be used). A callback can returntrue
to indicate that the authentication method was used, and it succeeded.
NB: You have to be mindful about the return value in this filter, returning true
effectively disables the nonce check in the Core’s rest_cookie_check_errors, thus leaving your site vulnerable.
But there are situations where you wouldn’t care as much about the nonce check. Suppose you want to bump the default permissions for all endpoints to be at least edit_posts
except for pages
endpoint:
// More balanced approach
add_filter( 'rest_authentication_errors', function( $maybe_error ) {
// Only allow authors and up, except for the pages endpoint
if ( ! ( current_user_can( 'edit_posts' ) || false !== stripos( $_SERVER['REQUEST_URI'], 'wp/v2/pages' ) ) ) {
return new \WP_Error(
'rest_auth_required',
'No REST for the restless!',
['status' => 401 ]
);
}
// Pass through
return $maybe_error;
} );
Let’s unpack what’s happening here: we’re checking the permissions and the specific path. If a user request is legit, we achieved our goal. If it’s an attacker that stole cookies, somehow they become “unauthenticated” in rest_cookie_check_errors. So the worst-case scenario here is a fallback to default permissions. To be on the safer side, though, it makes sense to modify the above code to also include the nonce check.
This method also has a drawback: no context is available about the request itself. It’s why the example above relies on $_SERVER
to detect the need for intervention.
But wait, there’s more!
Are you still with me? Because there are, indeed, more places where we can intercept the result and block the request.
rest_pre_dispatch
Allow hijacking the request before dispatching by returning a non-empty. The returned value will be used to serve the request instead.
https://developer.wordpress.org/reference/hooks/rest_pre_dispatch/
// More context
add_filter( 'rest_pre_dispatch', function( $res, \WP_REST_Server $wp_rest_server, \WP_REST_Request $request ) {
if ( this_needs_to_be_restricted() )
return new \WP_Error(
'rest_auth_required',
'No REST for the restless!',
[ 'status' => 401 ]
);
return $res;
}, 10, 3 );
What’s nice about this filter is that it runs very early during routing & dispatch, and you have access to the WP_REST_Server
instance and to the request itself. This allows for finer granularity, as well as reducing wasted CPU cycles.
Logic-wise its description tells us that it’s for serving something as a substitute for the routed and processed request, something like serving a cached response for a very slow endpoint comes to mind.
So while it’s totally fine to return a WP_Error
here, to me, it sounds a bit dirty.
rest_request_before_callbacks
Allows plugins to perform additional validation after a request is initialized and matched to a registered route, but before it is executed.
Note that this filter will not be called for requests that fail to authenticate or match to a registered route.
https://developer.wordpress.org/reference/hooks/rest_request_before_callbacks/
add_filter( 'rest_request_before_callbacks', function( $response, $handler, $request ) {
if ( endpoint_needs_to_be_blocked() )
return new \WP_Error(
'rest_auth_required',
'No REST for the restless!',
[ 'status' => 401 ]
);
return $response;
}, 10, 3 );
This filter happens after all request parameters were validated and sanitized. If validation fails, this filter won’t be called.
rest_dispatch_request
Filters the REST dispatch request result.
https://developer.wordpress.org/reference/hooks/rest_dispatch_request/
This filter fires right before the request is dispatched (meaning it’s handler function/method is called).
This filter doesn’t fire if the route’s permission callback returned WP_Error
.
add_filter( 'rest_dispatch_request', function( $res, \WP_REST_Request $request, string $route, $handler ) {
if ( ! current_user_can( 'edit_others_posts' ) )
return new \WP_Error(
'rest_auth_required',
'No REST for the restless!',
[ 'status' => 401 ]
);
return $res;
}, 10, 4 );
rest_request_after_callbacks
Filters the response immediately after executing any REST API callbacks.
https://developer.wordpress.org/reference/hooks/rest_request_after_callbacks/
add_filter( 'rest_request_after_callbacks', function( $response, $handler, $request ) {
// you know the drill at this point
return $response;
} );
Filtering at this point is wasteful, you have already generated the response, why block it now?
Conclusion
Hopefully, you’ve learned something new after reading this post (I know I did while writing it).