rest-api.php

I’ll build this as a strong 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.

Leave a Reply

Your email address will not be published. Required fields are marked *