Categories
Code

How to lock down or disable WordPress REST API Endpoints.

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

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 '/path/regex' => array( $callback, $bitmask ) or '/path/regex' => array( array( $callback, $bitmask ).

https://developer.wordpress.org/reference/hooks/rest_endpoints/#parameters

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.

WP_Error instance can be returned if an error occurs, and this should match the format used by API methods internally (that is, the status data should be used). A callback can return true to indicate that the authentication method was used, and it succeeded.

https://developer.wordpress.org/reference/hooks/rest_authentication_errors/

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).

Treat yourself to this timeless classic.