get_route(); $method = strtoupper( $request->get_method() ); // Check exact match first, then pattern match. $matched_rule = null; if ( isset( $rules[ $route ] ) ) { $matched_rule = $rules[ $route ]; } else { // Try matching registered route patterns against the request route. foreach ( $rules as $pattern => $rule ) { // Convert REST API route patterns to regex. $regex = self::route_to_regex( $pattern ); if ( $regex && preg_match( $regex, $route ) ) { $matched_rule = $rule; break; } } } if ( ! $matched_rule ) { return $result; } // Check if endpoint is fully disabled. if ( ! empty( $matched_rule['disabled'] ) ) { return new \WP_Error( 'rest_endpoint_disabled', sprintf( /* translators: %s: REST API endpoint */ \__( 'This REST API endpoint (%s) has been disabled.', '0-day-analytics' ), $route ), array( 'status' => 403 ) ); } // Check if specific method is disabled. if ( ! empty( $matched_rule['disabled_methods'] ) && in_array( $method, $matched_rule['disabled_methods'], true ) ) { return new \WP_Error( 'rest_method_disabled', sprintf( /* translators: %s: HTTP method, %s: REST API endpoint */ \__( 'The %1$s method has been disabled for this endpoint (%2$s).', '0-day-analytics' ), $method, $route ), array( 'status' => 403 ) ); } return $result; } /** * Filter the REST API index to remove obfuscated routes. * * @param \WP_REST_Response $response The response data. * * @return \WP_REST_Response * * @since 5.0.0 */ public static function filter_rest_index( \WP_REST_Response $response ): \WP_REST_Response { $rules = self::get_endpoint_rules(); if ( empty( $rules ) ) { return $response; } $data = $response->get_data(); if ( ! isset( $data['routes'] ) || ! is_array( $data['routes'] ) ) { return $response; } foreach ( $data['routes'] as $route => $route_data ) { $should_hide = false; // Check exact match. if ( isset( $rules[ $route ] ) ) { $rule = $rules[ $route ]; if ( ! empty( $rule['obfuscate'] ) || ! empty( $rule['disabled'] ) ) { $should_hide = true; } } if ( $should_hide ) { unset( $data['routes'][ $route ] ); } } $response->set_data( $data ); return $response; } /** * Filter namespace index responses. * * @param \WP_REST_Response $response The response data. * @param \WP_REST_Request $request The request. * * @return \WP_REST_Response * * @since 5.0.0 */ public static function filter_namespace_index( \WP_REST_Response $response, \WP_REST_Request $request ): \WP_REST_Response { $rules = self::get_endpoint_rules(); if ( empty( $rules ) ) { return $response; } $data = $response->get_data(); if ( ! isset( $data['routes'] ) || ! is_array( $data['routes'] ) ) { return $response; } foreach ( $data['routes'] as $route => $route_data ) { if ( isset( $rules[ $route ] ) ) { $rule = $rules[ $route ]; if ( ! empty( $rule['obfuscate'] ) || ! empty( $rule['disabled'] ) ) { unset( $data['routes'][ $route ] ); } } } $response->set_data( $data ); return $response; } /** * Get all registered REST API endpoints. * * @return array Array of endpoint data. * * @since 5.0.0 */ public static function get_all_endpoints(): array { if ( null !== self::$endpoints_cache ) { return self::$endpoints_cache; } $server = \rest_get_server(); $routes = $server->get_routes(); $endpoints = array(); foreach ( $routes as $route => $handlers ) { $methods = array(); $namespace = ''; $show_in_index = true; $is_public = false; foreach ( $handlers as $handler ) { if ( isset( $handler['methods'] ) ) { $methods = array_merge( $methods, array_keys( (array) $handler['methods'] ) ); } if ( ! empty( $handler['namespace'] ) ) { $namespace = $handler['namespace']; } if ( isset( $handler['show_in_index'] ) && ! $handler['show_in_index'] ) { $show_in_index = false; } // Check if endpoint is publicly accessible (no permission required). if ( ! isset( $handler['permission_callback'] ) || empty( $handler['permission_callback'] ) || '__return_true' === $handler['permission_callback'] ) { $is_public = true; } } // Deduce namespace from route if not explicitly set. if ( empty( $namespace ) ) { $parts = explode( '/', ltrim( $route, '/' ) ); if ( count( $parts ) >= 2 ) { $namespace = $parts[0] . '/' . $parts[1]; } elseif ( ! empty( $parts[0] ) ) { $namespace = $parts[0]; } } $methods = array_unique( array_map( 'strtoupper', $methods ) ); sort( $methods ); $endpoints[] = array( 'route' => $route, 'route_hash' => substr( md5( $route ), 0, 12 ), 'methods' => $methods, 'namespace' => $namespace, 'show_in_index' => $show_in_index, 'is_public' => $is_public, 'status' => 'active', ); } self::$endpoints_cache = $endpoints; return $endpoints; } /** * Get all unique namespaces from registered endpoints. * * @return array Sorted list of namespaces. * * @since 5.0.0 */ public static function get_all_namespaces(): array { $endpoints = self::get_all_endpoints(); $namespaces = array_unique( array_column( $endpoints, 'namespace' ) ); $namespaces = array_filter( $namespaces, function ( $ns ) { return '' !== $ns; } ); sort( $namespaces ); return array_values( $namespaces ); } /** * Get all unique HTTP methods from registered endpoints. * * @return array Sorted list of methods. * * @since 5.0.0 */ public static function get_all_methods(): array { $endpoints = self::get_all_endpoints(); $all_methods = array(); foreach ( $endpoints as $ep ) { $all_methods = array_merge( $all_methods, $ep['methods'] ); } $all_methods = array_unique( $all_methods ); sort( $all_methods ); return array_values( $all_methods ); } /** * Get endpoint rules from the database. * * @return array Associative array of route => rules. * * @since 5.0.0 */ public static function get_endpoint_rules(): array { if ( null !== self::$rules_cache ) { return self::$rules_cache; } $rules = \get_option( self::OPTION_NAME, array() ); if ( ! is_array( $rules ) ) { $rules = array(); } // Validate rule structure. foreach ( $rules as $route => $rule ) { if ( ! is_array( $rule ) ) { unset( $rules[ $route ] ); continue; } // Ensure disabled_methods only contains valid HTTP methods. if ( isset( $rule['disabled_methods'] ) ) { $valid_methods = array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD' ); $rules[ $route ]['disabled_methods'] = array_values( array_intersect( (array) $rule['disabled_methods'], $valid_methods ) ); } } self::$rules_cache = $rules; return $rules; } /** * Save endpoint rules to the database. * * @param array $rules The rules to save. * * @return bool True on success. * * @since 5.0.0 */ public static function save_endpoint_rules( array $rules ): bool { // Validate before saving. $clean_rules = array(); foreach ( $rules as $route => $rule ) { if ( ! is_string( $route ) || ! is_array( $rule ) ) { continue; } $clean_rule = array(); if ( ! empty( $rule['disabled'] ) ) { $clean_rule['disabled'] = true; } if ( ! empty( $rule['disabled_methods'] ) && is_array( $rule['disabled_methods'] ) ) { $valid_methods = array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD' ); $clean_rule['disabled_methods'] = array_values( array_intersect( array_map( 'strtoupper', $rule['disabled_methods'] ), $valid_methods ) ); } if ( ! empty( $rule['obfuscate'] ) ) { $clean_rule['obfuscate'] = true; } if ( ! empty( $clean_rule ) ) { $clean_rules[ $route ] = $clean_rule; } } self::$rules_cache = $clean_rules; return \update_option( self::OPTION_NAME, $clean_rules, false ); } /** * Convert a REST API route pattern to a regex for matching. * * @param string $route The route pattern (e.g. /wp/v2/posts/(?P[\d]+)). * * @return string|false Regex pattern or false if not a pattern. * * @since 5.0.0 */ private static function route_to_regex( string $route ) { // Only convert if it looks like a regex pattern. if ( false === strpos( $route, '(' ) && false === strpos( $route, '[' ) ) { return false; } // Escape forward slashes and anchor the pattern. $regex = '#^' . $route . '$#'; return $regex; } } }