inc/security/rest-api.php module for your RX theme: REST hardening, auth controls, headers, rate limits, endpoint filtering, logging hooks, and safe compatibility switches so it does not break WordPress admin/editor features.Below is an advanced inc/security/rest-api.php file for your RX theme. It adds many REST API security features while keeping WordPress editor/admin compatibility safer.
WordPress recommends using hooks like rest_authentication_errors to restrict REST access, and rest_pre_dispatch can stop/alter requests before the endpoint callback runs. Application Passwords are built into WordPress for REST/API access, so this file also lets you disable them when you do not need external API clients.
Add this file here:
rx-theme/
└── inc/
└── security/
└── rest-api.php
Then load it from functions.php:
require_once get_template_directory() . '/inc/security/rest-api.php';
inc/security/rest-api.php
<?php
/**
* RX Theme - Advanced REST API Security
*
* File: inc/security/rest-api.php
*
* Features:
* - Hide REST links from HTML/head/headers.
* - Disable or limit public REST API access.
* - Protect sensitive endpoints: users, settings, themes, plugins, comments, media write routes.
* - Optional allowlist / blocklist support.
* - Optional Application Password disabling.
* - REST rate limiting by IP.
* - Suspicious request blocking.
* - Method restriction.
* - Query parameter hardening.
* - Username/user enumeration protection.
* - CORS tightening.
* - REST response header hardening.
* - Logging hooks for blocked requests.
*
* IMPORTANT:
* This file is designed for a theme, but security logic is often better as a plugin.
* Test carefully after adding because some plugins, Gutenberg, WooCommerce, SEO plugins,
* mobile apps, Jetpack, and external tools may need REST access.
*/
defined( 'ABSPATH' ) || exit;
if ( ! class_exists( 'RX_REST_API_Security' ) ) :
final class RX_REST_API_Security {
/**
* Option/config key.
*
* Since this is a theme file, we use filters/constants instead of admin settings.
*/
const VERSION = '1.0.0';
/**
* Runtime blocked reason.
*
* @var string
*/
private static $blocked_reason = '';
/**
* Bootstrap.
*/
public static function init() {
/**
* Remove discovery links and REST exposure.
*/
add_action( 'init', array( __CLASS__, 'remove_rest_discovery' ), 20 );
/**
* Main authentication/access gate.
*/
add_filter( 'rest_authentication_errors', array( __CLASS__, 'rest_authentication_gate' ), 99 );
/**
* Endpoint allow/block control.
*/
add_filter( 'rest_endpoints', array( __CLASS__, 'filter_rest_endpoints' ), 99 );
/**
* Early request firewall.
*/
add_filter( 'rest_pre_dispatch', array( __CLASS__, 'rest_pre_dispatch_firewall' ), 10, 3 );
/**
* Validate request before callbacks.
*/
add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'before_rest_callbacks' ), 10, 3 );
/**
* Harden REST responses.
*/
add_filter( 'rest_post_dispatch', array( __CLASS__, 'harden_rest_response' ), 10, 3 );
/**
* Remove sensitive fields from REST output.
*/
add_filter( 'rest_prepare_user', array( __CLASS__, 'sanitize_user_response' ), 10, 3 );
add_filter( 'rest_prepare_comment', array( __CLASS__, 'sanitize_comment_response' ), 10, 3 );
add_filter( 'rest_prepare_attachment', array( __CLASS__, 'sanitize_attachment_response' ), 10, 3 );
/**
* CORS hardening.
*/
add_action( 'rest_api_init', array( __CLASS__, 'cors_hardening' ), 15 );
/**
* Application Password control.
*/
add_filter( 'wp_is_application_passwords_available', array( __CLASS__, 'control_application_passwords' ), 99 );
add_filter( 'wp_is_application_passwords_available_for_user', array( __CLASS__, 'control_application_passwords_for_user' ), 99, 2 );
/**
* REST nonce lifetime control.
*/
add_filter( 'nonce_life', array( __CLASS__, 'maybe_adjust_nonce_life' ) );
/**
* Extra security headers for REST requests.
*/
add_action( 'send_headers', array( __CLASS__, 'send_rest_security_headers' ), 20 );
/**
* Optional: disable XML-RPC because XML-RPC and REST are both common attack targets.
*/
if ( self::config( 'disable_xmlrpc', true ) ) {
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'wp_headers', array( __CLASS__, 'remove_x_pingback_header' ) );
}
}
/**
* Default config.
*
* You can override using:
* add_filter( 'rx_rest_security_config', function( $config ) { ... return $config; } );
*
* Or with constants in wp-config.php / functions.php:
* define( 'RX_REST_PUBLIC_MODE', false );
* define( 'RX_REST_DISABLE_APPLICATION_PASSWORDS', true );
*/
private static function defaults() {
return array(
/**
* Main switch.
*/
'enabled' => true,
/**
* If false, public visitors are blocked except allowlisted routes.
* If true, public read routes like posts/pages can work.
*/
'public_rest_enabled' => defined( 'RX_REST_PUBLIC_MODE' ) ? (bool) RX_REST_PUBLIC_MODE : true,
/**
* Allow logged-in users to use REST.
*/
'allow_logged_in_users' => true,
/**
* Minimum capability required for private REST access.
* Good choices:
* - read
* - edit_posts
* - manage_options
*/
'private_min_capability' => 'read',
/**
* Keep Gutenberg/admin working.
*/
'allow_admin_editor_context' => true,
/**
* Disable Application Passwords unless you use external apps/API integrations.
*/
'disable_application_passwords' => defined( 'RX_REST_DISABLE_APPLICATION_PASSWORDS' ) ? (bool) RX_REST_DISABLE_APPLICATION_PASSWORDS : true,
/**
* Allow Application Passwords only for administrators if enabled.
*/
'application_passwords_admin_only' => true,
/**
* Hide links.
*/
'remove_rest_links' => true,
'remove_rest_header_link' => true,
'remove_rsd_link' => true,
'remove_wlwmanifest' => true,
'remove_oembed_links' => false,
/**
* Block user enumeration routes and fields.
*/
'block_users_endpoint_public' => true,
'block_author_query_public' => true,
'remove_user_rest_fields' => true,
/**
* Block sensitive namespaces.
*/
'block_sensitive_namespaces_public' => true,
/**
* Block public write methods.
*/
'block_public_write_methods' => true,
/**
* Rate limiting.
*/
'rate_limit_enabled' => true,
'rate_limit_window_seconds' => 60,
'rate_limit_max_requests_public' => 80,
'rate_limit_max_requests_logged_in' => 240,
'rate_limit_block_seconds' => 300,
/**
* Request size hardening.
*/
'max_query_args' => 35,
'max_route_length' => 220,
'max_param_length' => 2000,
'max_per_page_public' => 50,
'max_search_length' => 120,
/**
* HTTP method hardening.
*/
'allowed_public_methods' => array( 'GET', 'HEAD', 'OPTIONS' ),
'allowed_logged_in_methods' => array( 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ),
/**
* Public allowlist.
* Regex patterns against REST route, e.g. /wp/v2/posts
*/
'public_allowlist' => array(
'#^/$#',
'#^/wp/v2$#',
'#^/wp/v2/types#',
'#^/wp/v2/taxonomies#',
'#^/wp/v2/categories#',
'#^/wp/v2/tags#',
'#^/wp/v2/posts#',
'#^/wp/v2/pages#',
'#^/wp/v2/media#',
'#^/wp/v2/search#',
'#^/wp/v2/block-renderer#',
'#^/oembed/1\.0#',
),
/**
* Always-block public routes.
*/
'public_blocklist' => array(
'#^/wp/v2/users#',
'#^/wp/v2/settings#',
'#^/wp/v2/themes#',
'#^/wp/v2/plugins#',
'#^/wp/v2/block-directory#',
'#^/wp/v2/global-styles#',
'#^/wp/v2/templates#',
'#^/wp/v2/template-parts#',
'#^/wp/v2/comments#',
'#^/wp/v2/statuses#',
'#^/wp/v2/users/me#',
),
/**
* Sensitive namespaces blocked for public requests.
*/
'sensitive_namespace_patterns' => array(
'#^/wp-site-health/#',
'#^/wp-block-editor/#',
'#^/wp/v2/users#',
'#^/wp/v2/settings#',
'#^/wp/v2/themes#',
'#^/wp/v2/plugins#',
'#^/wp/v2/global-styles#',
'#^/wp/v2/templates#',
'#^/wp/v2/template-parts#',
'#^/wc/#',
'#^/wc-analytics/#',
'#^/yoast/#',
'#^/rankmath/#',
'#^/elementor/#',
),
/**
* Bad parameters/patterns.
*/
'blocked_query_keys' => array(
'author',
'author_name',
'users',
'roles',
'capabilities',
'password',
'pwd',
'pass',
'token',
'secret',
),
'blocked_value_patterns' => array(
'#<script#i',
'#javascript:#i',
'#data:text/html#i',
'#base64,#i',
'#union\s+select#i',
'#information_schema#i',
'#\.\./#',
'#etc/passwd#i',
'#wp-config\.php#i',
'#GLOBALS#',
'#_REQUEST#',
'#_SERVER#',
),
/**
* Allowed CORS origins.
* Empty means same-origin only.
*/
'allowed_cors_origins' => array(),
/**
* Logging.
*/
'log_blocked_requests' => defined( 'WP_DEBUG' ) && WP_DEBUG,
'log_to_error_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG,
/**
* Header hardening.
*/
'security_headers' => true,
/**
* Optional XML-RPC disable.
*/
'disable_xmlrpc' => true,
/**
* Nonce lifetime adjustment.
* false = WordPress default.
* Example: 12 * HOUR_IN_SECONDS
*/
'nonce_life' => false,
);
}
/**
* Get merged config.
*/
private static function get_config() {
$config = self::defaults();
/**
* Customize RX REST security config.
*/
$config = apply_filters( 'rx_rest_security_config', $config );
if ( defined( 'RX_REST_SECURITY_ENABLED' ) ) {
$config['enabled'] = (bool) RX_REST_SECURITY_ENABLED;
}
return is_array( $config ) ? $config : self::defaults();
}
/**
* Get config value.
*/
private static function config( $key, $default = null ) {
$config = self::get_config();
return array_key_exists( $key, $config ) ? $config[ $key ] : $default;
}
/**
* Remove REST discovery links.
*/
public static function remove_rest_discovery() {
if ( ! self::config( 'enabled', true ) ) {
return;
}
if ( self::config( 'remove_rest_links', true ) ) {
remove_action( 'wp_head', 'rest_output_link_wp_head', 10 );
remove_action( 'template_redirect', 'rest_output_link_header', 11 );
remove_action( 'xmlrpc_rsd_apis', 'rest_output_rsd' );
}
if ( self::config( 'remove_rest_header_link', true ) ) {
remove_action( 'template_redirect', 'rest_output_link_header', 11 );
}
if ( self::config( 'remove_rsd_link', true ) ) {
remove_action( 'wp_head', 'rsd_link' );
}
if ( self::config( 'remove_wlwmanifest', true ) ) {
remove_action( 'wp_head', 'wlwmanifest_link' );
}
if ( self::config( 'remove_oembed_links', false ) ) {
remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
remove_action( 'wp_head', 'wp_oembed_add_host_js' );
}
}
/**
* Main REST gate.
*
* @param mixed $result Previous auth result.
* @return mixed
*/
public static function rest_authentication_gate( $result ) {
if ( ! self::config( 'enabled', true ) ) {
return $result;
}
if ( ! empty( $result ) ) {
return $result;
}
$route = self::current_rest_route();
$method = self::current_method();
if ( self::is_safe_admin_editor_context() ) {
return $result;
}
if ( self::is_logged_in_and_allowed() ) {
if ( ! self::method_allowed_for_logged_in( $method ) ) {
return self::block( 'rx_rest_method_forbidden', __( 'This REST API method is not allowed.', 'rx-theme' ), 405 );
}
return $result;
}
if ( self::config( 'block_public_write_methods', true ) && ! self::method_allowed_for_public( $method ) ) {
return self::block( 'rx_rest_public_write_forbidden', __( 'Public REST API write access is disabled.', 'rx-theme' ), 403 );
}
if ( self::config( 'block_users_endpoint_public', true ) && self::route_matches( $route, array( '#^/wp/v2/users#' ) ) ) {
return self::block( 'rx_rest_users_forbidden', __( 'Public user access is disabled.', 'rx-theme' ), 403 );
}
if ( self::config( 'block_sensitive_namespaces_public', true ) && self::route_matches( $route, self::config( 'sensitive_namespace_patterns', array() ) ) ) {
return self::block( 'rx_rest_sensitive_route_forbidden', __( 'This REST API route is private.', 'rx-theme' ), 403 );
}
if ( self::route_matches( $route, self::config( 'public_blocklist', array() ) ) ) {
return self::block( 'rx_rest_route_blocked', __( 'This REST API route is blocked.', 'rx-theme' ), 403 );
}
if ( ! self::config( 'public_rest_enabled', true ) ) {
if ( self::route_matches( $route, self::config( 'public_allowlist', array() ) ) ) {
return $result;
}
return self::block( 'rx_rest_login_required', __( 'REST API access requires login.', 'rx-theme' ), 401 );
}
return $result;
}
/**
* Filter registered endpoints.
*
* This removes routes from public discovery.
*
* @param array $endpoints Endpoints.
* @return array
*/
public static function filter_rest_endpoints( $endpoints ) {
if ( ! self::config( 'enabled', true ) ) {
return $endpoints;
}
if ( is_admin() || is_user_logged_in() ) {
return $endpoints;
}
$blocklist = self::config( 'public_blocklist', array() );
foreach ( $endpoints as $route => $handlers ) {
if ( self::route_matches( $route, $blocklist ) ) {
unset( $endpoints[ $route ] );
}
}
return $endpoints;
}
/**
* Early REST firewall.
*
* @param mixed $result Response to replace the requested version with.
* @param WP_REST_Server $server Server instance.
* @param WP_REST_Request $request Request used.
* @return mixed
*/
public static function rest_pre_dispatch_firewall( $result, $server, $request ) {
if ( ! self::config( 'enabled', true ) ) {
return $result;
}
if ( ! $request instanceof WP_REST_Request ) {
return $result;
}
$route = $request->get_route();
$method = $request->get_method();
if ( self::is_safe_admin_editor_context() ) {
return $result;
}
$rate_check = self::rate_limit_check( $route, $method );
if ( is_wp_error( $rate_check ) ) {
return $rate_check;
}
$basic_check = self::basic_request_hardening( $request );
if ( is_wp_error( $basic_check ) ) {
return $basic_check;
}
$param_check = self::parameter_firewall( $request );
if ( is_wp_error( $param_check ) ) {
return $param_check;
}
return $result;
}
/**
* Before endpoint callbacks.
*
* @param mixed $response Response.
* @param array $handler Handler.
* @param WP_REST_Request $request Request.
* @return mixed
*/
public static function before_rest_callbacks( $response, $handler, $request ) {
if ( ! self::config( 'enabled', true ) ) {
return $response;
}
if ( ! $request instanceof WP_REST_Request ) {
return $response;
}
$route = $request->get_route();
if ( self::is_safe_admin_editor_context() ) {
return $response;
}
if ( ! is_user_logged_in() && self::config( 'block_author_query_public', true ) ) {
$params = $request->get_params();
if ( isset( $params['author'] ) || isset( $params['author_name'] ) ) {
return self::block( 'rx_rest_author_query_blocked', __( 'Author query is blocked for public REST requests.', 'rx-theme' ), 403 );
}
}
if ( ! is_user_logged_in() && self::route_matches( $route, self::config( 'public_blocklist', array() ) ) ) {
return self::block( 'rx_rest_route_blocked_before_callback', __( 'This REST API route is blocked.', 'rx-theme' ), 403 );
}
return $response;
}
/**
* Harden outgoing REST response.
*
* @param WP_HTTP_Response $response Response.
* @param WP_REST_Server $server Server.
* @param WP_REST_Request $request Request.
* @return WP_HTTP_Response
*/
public static function harden_rest_response( $response, $server, $request ) {
if ( ! self::config( 'enabled', true ) || ! self::config( 'security_headers', true ) ) {
return $response;
}
if ( $response instanceof WP_HTTP_Response ) {
$response->header( 'X-RX-REST-Security', 'active' );
$response->header( 'X-Content-Type-Options', 'nosniff' );
$response->header( 'X-Frame-Options', 'SAMEORIGIN' );
$response->header( 'Referrer-Policy', 'strict-origin-when-cross-origin' );
$response->header( 'Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()' );
/**
* Avoid excessive caching of private authenticated REST responses.
*/
if ( is_user_logged_in() ) {
$response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
$response->header( 'Pragma', 'no-cache' );
}
}
return $response;
}
/**
* Sanitize user response.
*
* @param WP_REST_Response $response Response.
* @param WP_User $user User object.
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public static function sanitize_user_response( $response, $user, $request ) {
if ( ! self::config( 'enabled', true ) || ! self::config( 'remove_user_rest_fields', true ) ) {
return $response;
}
if ( is_user_logged_in() && current_user_can( 'list_users' ) ) {
return $response;
}
$data = $response->get_data();
$remove = array(
'slug',
'link',
'avatar_urls',
'meta',
'yoast_head',
'yoast_head_json',
'rank_math_seo',
'description',
'url',
'registered_date',
'roles',
'capabilities',
'extra_capabilities',
);
foreach ( $remove as $key ) {
if ( array_key_exists( $key, $data ) ) {
unset( $data[ $key ] );
}
}
$response->set_data( $data );
return $response;
}
/**
* Sanitize comment response.
*
* @param WP_REST_Response $response Response.
* @param WP_Comment $comment Comment.
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public static function sanitize_comment_response( $response, $comment, $request ) {
if ( ! self::config( 'enabled', true ) ) {
return $response;
}
if ( is_user_logged_in() && current_user_can( 'moderate_comments' ) ) {
return $response;
}
$data = $response->get_data();
$remove = array(
'author_email',
'author_ip',
'author_user_agent',
'author_url',
'meta',
);
foreach ( $remove as $key ) {
if ( array_key_exists( $key, $data ) ) {
unset( $data[ $key ] );
}
}
$response->set_data( $data );
return $response;
}
/**
* Sanitize attachment response.
*
* @param WP_REST_Response $response Response.
* @param WP_Post $post Attachment post.
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public static function sanitize_attachment_response( $response, $post, $request ) {
if ( ! self::config( 'enabled', true ) ) {
return $response;
}
if ( is_user_logged_in() && current_user_can( 'upload_files' ) ) {
return $response;
}
$data = $response->get_data();
$remove = array(
'meta',
'author',
'comment_status',
'ping_status',
'template',
'yoast_head',
'yoast_head_json',
'rank_math_seo',
);
foreach ( $remove as $key ) {
if ( array_key_exists( $key, $data ) ) {
unset( $data[ $key ] );
}
}
$response->set_data( $data );
return $response;
}
/**
* CORS hardening.
*/
public static function cors_hardening() {
if ( ! self::config( 'enabled', true ) ) {
return;
}
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
add_filter( 'rest_pre_serve_request', array( __CLASS__, 'send_custom_cors_headers' ), 10, 4 );
}
/**
* Send custom CORS headers.
*
* @param bool $served Whether already served.
* @param WP_HTTP_Response $result Result.
* @param WP_REST_Request $request Request.
* @param WP_REST_Server $server Server.
* @return bool
*/
public static function send_custom_cors_headers( $served, $result, $request, $server ) {
$origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : '';
$allowed_origins = self::config( 'allowed_cors_origins', array() );
$site_origin = home_url();
$allow = false;
if ( empty( $origin ) ) {
$allow = false;
} elseif ( self::same_origin( $origin, $site_origin ) ) {
$allow = true;
} elseif ( is_array( $allowed_origins ) && in_array( $origin, $allowed_origins, true ) ) {
$allow = true;
}
if ( $allow ) {
header( 'Access-Control-Allow-Origin: ' . $origin );
header( 'Access-Control-Allow-Credentials: true' );
header( 'Vary: Origin', false );
header( 'Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS' );
header( 'Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Type, X-Requested-With' );
} elseif ( ! empty( $origin ) ) {
header_remove( 'Access-Control-Allow-Origin' );
}
return $served;
}
/**
* Disable or limit Application Passwords.
*
* @param bool $available Available.
* @return bool
*/
public static function control_application_passwords( $available ) {
if ( ! self::config( 'enabled', true ) ) {
return $available;
}
if ( self::config( 'disable_application_passwords', true ) ) {
return false;
}
return $available;
}
/**
* Disable or limit Application Passwords per user.
*
* @param bool $available Available.
* @param WP_User $user User.
* @return bool
*/
public static function control_application_passwords_for_user( $available, $user ) {
if ( ! self::config( 'enabled', true ) ) {
return $available;
}
if ( self::config( 'disable_application_passwords', true ) ) {
return false;
}
if ( self::config( 'application_passwords_admin_only', true ) ) {
return $user instanceof WP_User && user_can( $user, 'manage_options' );
}
return $available;
}
/**
* Optional nonce lifetime.
*
* @param int $life Nonce life.
* @return int
*/
public static function maybe_adjust_nonce_life( $life ) {
$custom = self::config( 'nonce_life', false );
if ( false === $custom ) {
return $life;
}
$custom = absint( $custom );
return $custom > 0 ? $custom : $life;
}
/**
* Send REST security headers.
*/
public static function send_rest_security_headers() {
if ( ! self::config( 'enabled', true ) || ! self::config( 'security_headers', true ) ) {
return;
}
if ( ! self::is_rest_request() ) {
return;
}
header( 'X-RX-REST-Security: active' );
header( 'X-Content-Type-Options: nosniff' );
header( 'X-Frame-Options: SAMEORIGIN' );
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
header( 'Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()' );
}
/**
* Remove X-Pingback header.
*
* @param array $headers Headers.
* @return array
*/
public static function remove_x_pingback_header( $headers ) {
if ( isset( $headers['X-Pingback'] ) ) {
unset( $headers['X-Pingback'] );
}
return $headers;
}
/**
* Basic request hardening.
*
* @param WP_REST_Request $request Request.
* @return true|WP_Error
*/
private static function basic_request_hardening( $request ) {
$route = $request->get_route();
$method = $request->get_method();
if ( strlen( $route ) > absint( self::config( 'max_route_length', 220 ) ) ) {
return self::block( 'rx_rest_route_too_long', __( 'REST route is too long.', 'rx-theme' ), 414 );
}
if ( is_user_logged_in() ) {
if ( ! self::method_allowed_for_logged_in( $method ) ) {
return self::block( 'rx_rest_method_not_allowed', __( 'REST method is not allowed.', 'rx-theme' ), 405 );
}
} else {
if ( ! self::method_allowed_for_public( $method ) ) {
return self::block( 'rx_rest_public_method_not_allowed', __( 'Public REST method is not allowed.', 'rx-theme' ), 405 );
}
}
return true;
}
/**
* Parameter firewall.
*
* @param WP_REST_Request $request Request.
* @return true|WP_Error
*/
private static function parameter_firewall( $request ) {
$params = $request->get_params();
if ( ! is_array( $params ) ) {
return true;
}
$max_args = absint( self::config( 'max_query_args', 35 ) );
if ( count( $params ) > $max_args ) {
return self::block( 'rx_rest_too_many_params', __( 'Too many REST parameters.', 'rx-theme' ), 400 );
}
$blocked_keys = self::config( 'blocked_query_keys', array() );
if ( ! is_user_logged_in() && self::config( 'block_author_query_public', true ) ) {
foreach ( $blocked_keys as $blocked_key ) {
if ( array_key_exists( $blocked_key, $params ) ) {
return self::block( 'rx_rest_blocked_param', __( 'This REST parameter is blocked.', 'rx-theme' ), 403 );
}
}
}
if ( isset( $params['per_page'] ) && ! is_user_logged_in() ) {
$max_per_page = absint( self::config( 'max_per_page_public', 50 ) );
if ( absint( $params['per_page'] ) > $max_per_page ) {
return self::block( 'rx_rest_per_page_too_large', __( 'REST per_page value is too large.', 'rx-theme' ), 400 );
}
}
if ( isset( $params['search'] ) && is_string( $params['search'] ) ) {
$max_search = absint( self::config( 'max_search_length', 120 ) );
if ( strlen( $params['search'] ) > $max_search ) {
return self::block( 'rx_rest_search_too_long', __( 'REST search value is too long.', 'rx-theme' ), 400 );
}
}
foreach ( $params as $key => $value ) {
$check = self::inspect_param_value( $key, $value );
if ( is_wp_error( $check ) ) {
return $check;
}
}
return true;
}
/**
* Inspect parameter recursively.
*
* @param string $key Key.
* @param mixed $value Value.
* @return true|WP_Error
*/
private static function inspect_param_value( $key, $value ) {
$max_length = absint( self::config( 'max_param_length', 2000 ) );
if ( is_array( $value ) ) {
foreach ( $value as $child_key => $child_value ) {
$result = self::inspect_param_value( (string) $child_key, $child_value );
if ( is_wp_error( $result ) ) {
return $result;
}
}
return true;
}
if ( is_object( $value ) ) {
return self::block( 'rx_rest_object_param_blocked', __( 'Object parameters are not allowed here.', 'rx-theme' ), 400 );
}
if ( is_string( $value ) ) {
if ( strlen( $value ) > $max_length ) {
return self::block( 'rx_rest_param_too_long', __( 'REST parameter value is too long.', 'rx-theme' ), 400 );
}
$patterns = self::config( 'blocked_value_patterns', array() );
foreach ( $patterns as $pattern ) {
if ( @preg_match( $pattern, $value ) ) {
return self::block( 'rx_rest_malicious_param', __( 'Suspicious REST parameter blocked.', 'rx-theme' ), 403 );
}
}
}
return true;
}
/**
* REST rate limiting.
*
* @param string $route Route.
* @param string $method Method.
* @return true|WP_Error
*/
private static function rate_limit_check( $route, $method ) {
if ( ! self::config( 'rate_limit_enabled', true ) ) {
return true;
}
if ( self::is_safe_admin_editor_context() ) {
return true;
}
$ip = self::get_client_ip();
if ( empty( $ip ) ) {
return true;
}
$window = max( 10, absint( self::config( 'rate_limit_window_seconds', 60 ) ) );
$block_seconds = max( 60, absint( self::config( 'rate_limit_block_seconds', 300 ) ) );
$limit = is_user_logged_in()
? absint( self::config( 'rate_limit_max_requests_logged_in', 240 ) )
: absint( self::config( 'rate_limit_max_requests_public', 80 ) );
$hash = md5( $ip . '|' . ( is_user_logged_in() ? get_current_user_id() : 'public' ) );
$count_key = 'rx_rest_rl_count_' . $hash;
$block_key = 'rx_rest_rl_block_' . $hash;
if ( get_transient( $block_key ) ) {
return self::block( 'rx_rest_rate_limited', __( 'Too many REST API requests. Please try again later.', 'rx-theme' ), 429 );
}
$count = absint( get_transient( $count_key ) );
$count++;
set_transient( $count_key, $count, $window );
if ( $count > $limit ) {
set_transient( $block_key, 1, $block_seconds );
self::log_blocked_request(
'rate_limit',
array(
'ip' => $ip,
'route' => $route,
'method' => $method,
'count' => $count,
'limit' => $limit,
)
);
return self::block( 'rx_rest_rate_limited', __( 'Too many REST API requests. Please try again later.', 'rx-theme' ), 429 );
}
return true;
}
/**
* Block helper.
*
* @param string $code Error code.
* @param string $message Error message.
* @param int $status HTTP status.
* @return WP_Error
*/
private static function block( $code, $message, $status = 403 ) {
self::$blocked_reason = $code;
self::log_blocked_request(
$code,
array(
'message' => $message,
'status' => $status,
'route' => self::current_rest_route(),
'method' => self::current_method(),
'ip' => self::get_client_ip(),
'user_id' => get_current_user_id(),
)
);
return new WP_Error(
$code,
$message,
array(
'status' => absint( $status ),
)
);
}
/**
* Logging helper.
*
* @param string $reason Reason.
* @param array $data Data.
*/
private static function log_blocked_request( $reason, $data = array() ) {
if ( ! self::config( 'log_blocked_requests', false ) ) {
return;
}
/**
* Action for custom security logging.
*/
do_action( 'rx_rest_security_blocked_request', $reason, $data );
if ( self::config( 'log_to_error_log', false ) ) {
error_log(
'[RX REST Security] ' . wp_json_encode(
array(
'reason' => $reason,
'data' => $data,
'time' => current_time( 'mysql' ),
)
)
);
}
}
/**
* Check safe admin/editor context.
*
* @return bool
*/
private static function is_safe_admin_editor_context() {
if ( ! self::config( 'allow_admin_editor_context', true ) ) {
return false;
}
if ( ! is_user_logged_in() ) {
return false;
}
if ( is_admin() ) {
return true;
}
$referer = wp_get_referer();
if ( $referer && false !== strpos( $referer, admin_url() ) ) {
return true;
}
$nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ) : '';
if ( $nonce && wp_verify_nonce( $nonce, 'wp_rest' ) ) {
return true;
}
return false;
}
/**
* Check if logged-in user is allowed.
*
* @return bool
*/
private static function is_logged_in_and_allowed() {
if ( ! is_user_logged_in() ) {
return false;
}
if ( ! self::config( 'allow_logged_in_users', true ) ) {
return false;
}
$capability = self::config( 'private_min_capability', 'read' );
if ( empty( $capability ) ) {
return true;
}
return current_user_can( $capability );
}
/**
* Is method allowed publicly?
*
* @param string $method Method.
* @return bool
*/
private static function method_allowed_for_public( $method ) {
$allowed = self::config( 'allowed_public_methods', array( 'GET', 'HEAD', 'OPTIONS' ) );
return in_array( strtoupper( $method ), array_map( 'strtoupper', $allowed ), true );
}
/**
* Is method allowed for logged-in user?
*
* @param string $method Method.
* @return bool
*/
private static function method_allowed_for_logged_in( $method ) {
$allowed = self::config( 'allowed_logged_in_methods', array( 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ) );
return in_array( strtoupper( $method ), array_map( 'strtoupper', $allowed ), true );
}
/**
* Get current REST route.
*
* @return string
*/
private static function current_rest_route() {
if ( isset( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
return '/' . ltrim( sanitize_text_field( wp_unslash( $GLOBALS['wp']->query_vars['rest_route'] ) ), '/' );
}
if ( isset( $_GET['rest_route'] ) ) {
return '/' . ltrim( sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ), '/' );
}
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$prefix = trailingslashit( rest_get_url_prefix() );
if ( false !== strpos( $uri, '/' . $prefix ) ) {
$parts = explode( '/' . $prefix, $uri, 2 );
if ( isset( $parts[1] ) ) {
$route = strtok( $parts[1], '?' );
return '/' . ltrim( $route, '/' );
}
}
return '/';
}
/**
* Current request method.
*
* @return string
*/
private static function current_method() {
return isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
}
/**
* Does route match regex patterns?
*
* @param string $route Route.
* @param array $patterns Patterns.
* @return bool
*/
private static function route_matches( $route, $patterns ) {
if ( empty( $patterns ) || ! is_array( $patterns ) ) {
return false;
}
foreach ( $patterns as $pattern ) {
if ( @preg_match( $pattern, $route ) ) {
return true;
}
}
return false;
}
/**
* Is this a REST request?
*
* @return bool
*/
private static function is_rest_request() {
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return true;
}
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$prefix = '/' . rest_get_url_prefix() . '/';
return false !== strpos( $uri, $prefix ) || isset( $_GET['rest_route'] );
}
/**
* Client IP helper.
*
* Be careful with proxy headers. This uses safe-ish fallback.
*
* @return string
*/
private static function get_client_ip() {
$keys = array(
'HTTP_CF_CONNECTING_IP',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
);
foreach ( $keys as $key ) {
if ( ! empty( $_SERVER[ $key ] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return $ip;
}
}
}
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$forwarded = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
$parts = explode( ',', $forwarded );
$ip = trim( $parts[0] );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return $ip;
}
}
return '';
}
/**
* Same origin check.
*
* @param string $origin Origin.
* @param string $site Site.
* @return bool
*/
private static function same_origin( $origin, $site ) {
$origin_parts = wp_parse_url( $origin );
$site_parts = wp_parse_url( $site );
if ( empty( $origin_parts['host'] ) || empty( $site_parts['host'] ) ) {
return false;
}
$origin_scheme = isset( $origin_parts['scheme'] ) ? $origin_parts['scheme'] : '';
$site_scheme = isset( $site_parts['scheme'] ) ? $site_parts['scheme'] : '';
return strtolower( $origin_parts['host'] ) === strtolower( $site_parts['host'] )
&& strtolower( $origin_scheme ) === strtolower( $site_scheme );
}
}
endif;
RX_REST_API_Security::init();
Optional custom settings in functions.php
Place this after the require_once line if you want stricter control:
add_filter( 'rx_rest_security_config', function( $config ) {
/**
* Very strict mode:
* Public users can only access allowlisted read routes.
*/
$config['public_rest_enabled'] = false;
/**
* Keep this true unless you use WordPress mobile app,
* external API tools, n8n, Make, Zapier, custom apps, etc.
*/
$config['disable_application_passwords'] = true;
/**
* Logged-in REST access needs at least read capability.
* Use edit_posts for stricter author/editor/admin only.
*/
$config['private_min_capability'] = 'read';
/**
* Add your own safe public route.
*/
$config['public_allowlist'][] = '#^/rx/v1/public#';
/**
* If your site uses WooCommerce public REST, remove or adjust wc blocking.
*/
// $config['sensitive_namespace_patterns'] = array_filter(
// $config['sensitive_namespace_patterns'],
// function( $pattern ) {
// return '#^/wc/#' !== $pattern && '#^/wc-analytics/#' !== $pattern;
// }
// );
return $config;
} );
Safer recommended mode for normal blog/theme
For a normal WordPress blog, I recommend this:
add_filter( 'rx_rest_security_config', function( $config ) {
$config['public_rest_enabled'] = true;
$config['disable_application_passwords'] = true;
$config['block_users_endpoint_public'] = true;
$config['block_author_query_public'] = true;
$config['rate_limit_enabled'] = true;
return $config;
} );
This keeps public posts/pages REST readable but blocks the most sensitive routes like users/settings/plugins/themes.