PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / includes / class-rest-api-security.php

class-rest-api-security.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.3, at includes/class-rest-api-security.php

412 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API Security Class
4 *
5 * Manages REST API access restrictions
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Rest_Api_Security
17 *
18 * Controls access to WordPress REST API endpoints
19 */
20 class Vigilante_Rest_Api_Security {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * REST API options
31 *
32 * @var array
33 */
34 private $options;
35
36 /**
37 * Constructor
38 *
39 * @param Vigilante_Settings $settings Settings instance.
40 */
41 public function __construct( $settings ) {
42 $this->settings = $settings;
43 $this->options = $settings->get_section( 'rest_api_security' );
44
45 if ( empty( $this->options['enabled'] ) ) {
46 return;
47 }
48
49 // Apply REST API restrictions
50 add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest_api' ), 99 );
51
52 // Block user enumeration
53 if ( ! empty( $this->options['block_user_enumeration'] ) ) {
54 add_filter( 'rest_endpoints', array( $this, 'restrict_user_endpoints' ) );
55 }
56
57 // Disable JSONP
58 if ( ! empty( $this->options['disable_jsonp'] ) ) {
59 add_filter( 'rest_jsonp_enabled', '__return_false' );
60 }
61 }
62
63 /**
64 * Restrict REST API access based on settings
65 *
66 * @param WP_Error|null|bool $result Current authentication status.
67 * @return WP_Error|null|bool
68 */
69 public function restrict_rest_api( $result ) {
70 // If already an error, return it
71 if ( is_wp_error( $result ) ) {
72 return $result;
73 }
74
75 // Logged in users always have access
76 if ( is_user_logged_in() ) {
77 return $result;
78 }
79
80 // Get current mode
81 $mode = $this->options['mode'] ?? 'selective';
82
83 // Open mode - allow all
84 if ( 'open' === $mode ) {
85 return $result;
86 }
87
88 // Authenticated only - block all unauthenticated except hard-coded
89 // essentials (oEmbed, Site Health) and endpoints from compatible
90 // plugins. Does NOT honour the selective-mode "public list".
91 if ( 'authenticated_only' === $mode ) {
92 if ( $this->is_allowed_unauthenticated_endpoint() ) {
93 return $result;
94 }
95
96 return new WP_Error(
97 'rest_not_logged_in',
98 'REST API access requires authentication.',
99 array( 'status' => 401 )
100 );
101 }
102
103 // Selective mode - allow plugin-compat endpoints and the user-managed
104 // "public" list, block anything in protected_endpoints, let everything
105 // else through.
106 if ( 'selective' === $mode ) {
107 if ( $this->is_plugin_compatibility_endpoint() ) {
108 return $result;
109 }
110
111 if ( $this->is_selective_public_endpoint() ) {
112 return $result;
113 }
114
115 if ( $this->is_protected_endpoint() ) {
116 return new WP_Error(
117 'rest_forbidden',
118 'This endpoint requires authentication.',
119 array( 'status' => 403 )
120 );
121 }
122 }
123
124 return $result;
125 }
126
127 /**
128 * Check if current request is to a protected endpoint
129 *
130 * @return bool
131 */
132 private function is_protected_endpoint() {
133 $current_route = $this->get_current_route();
134
135 if ( empty( $current_route ) ) {
136 return false;
137 }
138
139 $protected = $this->options['protected_endpoints'] ?? array();
140
141 foreach ( $protected as $endpoint ) {
142 $endpoint = trim( $endpoint );
143 if ( empty( $endpoint ) ) {
144 continue;
145 }
146 // Prefix match: /wp/v2/users matches /wp/v2/users and /wp/v2/users/123
147 if ( strpos( $current_route, $endpoint ) === 0 ) {
148 return true;
149 }
150 }
151
152 return false;
153 }
154
155 /**
156 * Check if current route matches one of the user-managed "publicly allowed"
157 * endpoints used by selective mode. Defaults: /wp/v2/posts, /wp/v2/pages,
158 * /wp/v2/categories, /wp/v2/tags, /oembed/.
159 *
160 * Kept separate from plugin-compatibility so the two reasons for letting
161 * a request through stay distinguishable.
162 *
163 * @return bool
164 */
165 private function is_selective_public_endpoint() {
166 $current_route = $this->get_current_route();
167 if ( empty( $current_route ) ) {
168 return false;
169 }
170
171 // Vigilante_Settings::list_or_shipped(): an empty list of a setting with
172 // no field on screen is a leftover, not a choice. See its docblock.
173 $allowed_public = Vigilante_Settings::list_or_shipped(
174 $this->options['allowed_public_endpoints'] ?? array(),
175 array( 'rest_api_security', 'allowed_public_endpoints' )
176 );
177
178 foreach ( $allowed_public as $endpoint ) {
179 if ( strpos( $current_route, $endpoint ) === 0 ) {
180 return true;
181 }
182 }
183
184 return false;
185 }
186
187 /**
188 * Backward-compatible wrapper: combines compat plugin endpoints and the
189 * selective-mode public list. Kept in case any external code calls it.
190 *
191 * @return bool
192 */
193 private function is_plugin_allowed_endpoint() {
194 return $this->is_plugin_compatibility_endpoint() || $this->is_selective_public_endpoint();
195 }
196
197 /**
198 * Check if the current route matches a known plugin-compatibility prefix
199 * (WooCommerce, Contact Form 7, Gravity Forms, WPForms, Elementor, Jetpack).
200 *
201 * Only returns true when the corresponding compatibility toggle is on. Used
202 * by both is_plugin_allowed_endpoint() (selective mode) and
203 * is_allowed_unauthenticated_endpoint() (authenticated_only mode), so that
204 * authenticated_only doesn't accidentally let public endpoints through but
205 * still doesn't break installed e-commerce / form plugins that legitimately
206 * call REST without a logged-in user.
207 *
208 * @return bool
209 */
210 private function is_plugin_compatibility_endpoint() {
211 $current_route = $this->get_current_route();
212
213 if ( empty( $current_route ) ) {
214 return false;
215 }
216
217 $compatibility = $this->options['plugin_compatibility'] ?? array();
218
219 // WooCommerce
220 if ( ! empty( $compatibility['woocommerce'] ) ) {
221 if ( preg_match( '/^\/(wc|wc-blocks|wc-auth)\//i', $current_route ) ) {
222 return true;
223 }
224 }
225
226 // Contact Form 7
227 if ( ! empty( $compatibility['contact_form_7'] ) ) {
228 if ( preg_match( '/^\/contact-form-7\//i', $current_route ) ) {
229 return true;
230 }
231 }
232
233 // Gravity Forms
234 if ( ! empty( $compatibility['gravity_forms'] ) ) {
235 if ( preg_match( '/^\/(gf|gravityforms)\//i', $current_route ) ) {
236 return true;
237 }
238 }
239
240 // WPForms
241 if ( ! empty( $compatibility['wpforms'] ) ) {
242 if ( preg_match( '/^\/wpforms\//i', $current_route ) ) {
243 return true;
244 }
245 }
246
247 // Elementor
248 if ( ! empty( $compatibility['elementor'] ) ) {
249 if ( preg_match( '/^\/elementor\//i', $current_route ) ) {
250 return true;
251 }
252 }
253
254 // Jetpack
255 if ( ! empty( $compatibility['jetpack'] ) ) {
256 if ( preg_match( '/^\/jetpack\//i', $current_route ) ) {
257 return true;
258 }
259 }
260
261 return false;
262 }
263
264 /**
265 * Check if endpoint is allowed for unauthenticated access (basic needs)
266 *
267 * @return bool
268 */
269 private function is_allowed_unauthenticated_endpoint() {
270 $current_route = $this->get_current_route();
271
272 // Always allow these for basic functionality
273 $always_allowed = array(
274 '/oembed/', // oEmbed for embeds
275 '/wp-site-health/', // Site health
276 );
277
278 foreach ( $always_allowed as $endpoint ) {
279 if ( strpos( $current_route, $endpoint ) === 0 ) {
280 return true;
281 }
282 }
283
284 // In authenticated_only mode we do NOT honour the 'allowed_public_endpoints'
285 // list — that list is the "what counts as public" definition for the
286 // selective mode. authenticated_only is meant to be strict, so only
287 // hard-coded essentials above plus compatibility endpoints from
288 // installed plugins are let through.
289 return $this->is_plugin_compatibility_endpoint();
290 }
291
292 /**
293 * Get current REST API route
294 *
295 * @return string
296 */
297 private function get_current_route() {
298 $rest_route = '';
299
300 if ( isset( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
301 $rest_route = $GLOBALS['wp']->query_vars['rest_route'];
302 } elseif ( isset( $_GET['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
303 $rest_route = sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
304 }
305
306 // Clean up route
307 $rest_route = '/' . ltrim( $rest_route, '/' );
308
309 return $rest_route;
310 }
311
312 /**
313 * Restrict user endpoints for unauthenticated users
314 *
315 * @param array $endpoints REST endpoints.
316 * @return array
317 */
318 public function restrict_user_endpoints( $endpoints ) {
319 if ( is_user_logged_in() ) {
320 return $endpoints;
321 }
322
323 // Remove user endpoints
324 $user_endpoints = array(
325 '/wp/v2/users',
326 '/wp/v2/users/(?P<id>[\d]+)',
327 '/wp/v2/users/me',
328 );
329
330 foreach ( $user_endpoints as $endpoint ) {
331 if ( isset( $endpoints[ $endpoint ] ) ) {
332 unset( $endpoints[ $endpoint ] );
333 }
334 }
335
336 return $endpoints;
337 }
338
339 /**
340 * Get REST API security status
341 *
342 * @return array
343 */
344 public function get_status() {
345 return array(
346 'enabled' => ! empty( $this->options['enabled'] ),
347 'mode' => $this->options['mode'] ?? 'selective',
348 'user_enum_blocked' => ! empty( $this->options['block_user_enumeration'] ),
349 'jsonp_disabled' => ! empty( $this->options['disable_jsonp'] ),
350 'protected_endpoints' => count( $this->options['protected_endpoints'] ?? array() ),
351 // Counted from the same value the check uses, or the screen would
352 // report zero while five endpoints are exempt.
353 'allowed_endpoints' => count( Vigilante_Settings::list_or_shipped( $this->options['allowed_public_endpoints'] ?? array(), array( 'rest_api_security', 'allowed_public_endpoints' ) ) ),
354 );
355 }
356
357 /**
358 * Test if a specific endpoint would be accessible
359 *
360 * @param string $endpoint Endpoint to test.
361 * @param bool $authenticated Whether user is authenticated.
362 * @return array Test result.
363 */
364 public function test_endpoint_access( $endpoint, $authenticated = false ) {
365 $result = array(
366 'endpoint' => $endpoint,
367 'authenticated' => $authenticated,
368 'allowed' => false,
369 'reason' => '',
370 );
371
372 // Logged in users always have access
373 if ( $authenticated ) {
374 $result['allowed'] = true;
375 $result['reason'] = 'Authenticated users have full access';
376 return $result;
377 }
378
379 $mode = $this->options['mode'] ?? 'selective';
380
381 if ( 'open' === $mode ) {
382 $result['allowed'] = true;
383 $result['reason'] = 'REST API is in open mode';
384 return $result;
385 }
386
387 if ( 'authenticated_only' === $mode ) {
388 $result['allowed'] = false;
389 $result['reason'] = 'REST API requires authentication';
390 return $result;
391 }
392
393 // Selective mode
394 $protected = $this->options['protected_endpoints'] ?? array();
395
396 foreach ( $protected as $protected_endpoint ) {
397 $protected_endpoint = trim( $protected_endpoint );
398 if ( empty( $protected_endpoint ) ) {
399 continue;
400 }
401 if ( strpos( $endpoint, $protected_endpoint ) === 0 ) {
402 $result['allowed'] = false;
403 $result['reason'] = 'Endpoint is protected';
404 return $result;
405 }
406 }
407
408 $result['allowed'] = true;
409 $result['reason'] = 'Endpoint is not protected';
410 return $result;
411 }
412 }