PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.10
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.10
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 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-rest-api-security.php

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

405 lines 12.1 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 $allowed_public = $this->options['allowed_public_endpoints'] ?? array();
172
173 foreach ( $allowed_public as $endpoint ) {
174 if ( strpos( $current_route, $endpoint ) === 0 ) {
175 return true;
176 }
177 }
178
179 return false;
180 }
181
182 /**
183 * Backward-compatible wrapper: combines compat plugin endpoints and the
184 * selective-mode public list. Kept in case any external code calls it.
185 *
186 * @return bool
187 */
188 private function is_plugin_allowed_endpoint() {
189 return $this->is_plugin_compatibility_endpoint() || $this->is_selective_public_endpoint();
190 }
191
192 /**
193 * Check if the current route matches a known plugin-compatibility prefix
194 * (WooCommerce, Contact Form 7, Gravity Forms, WPForms, Elementor, Jetpack).
195 *
196 * Only returns true when the corresponding compatibility toggle is on. Used
197 * by both is_plugin_allowed_endpoint() (selective mode) and
198 * is_allowed_unauthenticated_endpoint() (authenticated_only mode), so that
199 * authenticated_only doesn't accidentally let public endpoints through but
200 * still doesn't break installed e-commerce / form plugins that legitimately
201 * call REST without a logged-in user.
202 *
203 * @return bool
204 */
205 private function is_plugin_compatibility_endpoint() {
206 $current_route = $this->get_current_route();
207
208 if ( empty( $current_route ) ) {
209 return false;
210 }
211
212 $compatibility = $this->options['plugin_compatibility'] ?? array();
213
214 // WooCommerce
215 if ( ! empty( $compatibility['woocommerce'] ) ) {
216 if ( preg_match( '/^\/(wc|wc-blocks|wc-auth)\//i', $current_route ) ) {
217 return true;
218 }
219 }
220
221 // Contact Form 7
222 if ( ! empty( $compatibility['contact_form_7'] ) ) {
223 if ( preg_match( '/^\/contact-form-7\//i', $current_route ) ) {
224 return true;
225 }
226 }
227
228 // Gravity Forms
229 if ( ! empty( $compatibility['gravity_forms'] ) ) {
230 if ( preg_match( '/^\/(gf|gravityforms)\//i', $current_route ) ) {
231 return true;
232 }
233 }
234
235 // WPForms
236 if ( ! empty( $compatibility['wpforms'] ) ) {
237 if ( preg_match( '/^\/wpforms\//i', $current_route ) ) {
238 return true;
239 }
240 }
241
242 // Elementor
243 if ( ! empty( $compatibility['elementor'] ) ) {
244 if ( preg_match( '/^\/elementor\//i', $current_route ) ) {
245 return true;
246 }
247 }
248
249 // Jetpack
250 if ( ! empty( $compatibility['jetpack'] ) ) {
251 if ( preg_match( '/^\/jetpack\//i', $current_route ) ) {
252 return true;
253 }
254 }
255
256 return false;
257 }
258
259 /**
260 * Check if endpoint is allowed for unauthenticated access (basic needs)
261 *
262 * @return bool
263 */
264 private function is_allowed_unauthenticated_endpoint() {
265 $current_route = $this->get_current_route();
266
267 // Always allow these for basic functionality
268 $always_allowed = array(
269 '/oembed/', // oEmbed for embeds
270 '/wp-site-health/', // Site health
271 );
272
273 foreach ( $always_allowed as $endpoint ) {
274 if ( strpos( $current_route, $endpoint ) === 0 ) {
275 return true;
276 }
277 }
278
279 // In authenticated_only mode we do NOT honour the 'allowed_public_endpoints'
280 // list — that list is the "what counts as public" definition for the
281 // selective mode. authenticated_only is meant to be strict, so only
282 // hard-coded essentials above plus compatibility endpoints from
283 // installed plugins are let through.
284 return $this->is_plugin_compatibility_endpoint();
285 }
286
287 /**
288 * Get current REST API route
289 *
290 * @return string
291 */
292 private function get_current_route() {
293 $rest_route = '';
294
295 if ( isset( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
296 $rest_route = $GLOBALS['wp']->query_vars['rest_route'];
297 } elseif ( isset( $_GET['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
298 $rest_route = sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
299 }
300
301 // Clean up route
302 $rest_route = '/' . ltrim( $rest_route, '/' );
303
304 return $rest_route;
305 }
306
307 /**
308 * Restrict user endpoints for unauthenticated users
309 *
310 * @param array $endpoints REST endpoints.
311 * @return array
312 */
313 public function restrict_user_endpoints( $endpoints ) {
314 if ( is_user_logged_in() ) {
315 return $endpoints;
316 }
317
318 // Remove user endpoints
319 $user_endpoints = array(
320 '/wp/v2/users',
321 '/wp/v2/users/(?P<id>[\d]+)',
322 '/wp/v2/users/me',
323 );
324
325 foreach ( $user_endpoints as $endpoint ) {
326 if ( isset( $endpoints[ $endpoint ] ) ) {
327 unset( $endpoints[ $endpoint ] );
328 }
329 }
330
331 return $endpoints;
332 }
333
334 /**
335 * Get REST API security status
336 *
337 * @return array
338 */
339 public function get_status() {
340 return array(
341 'enabled' => ! empty( $this->options['enabled'] ),
342 'mode' => $this->options['mode'] ?? 'selective',
343 'user_enum_blocked' => ! empty( $this->options['block_user_enumeration'] ),
344 'jsonp_disabled' => ! empty( $this->options['disable_jsonp'] ),
345 'protected_endpoints' => count( $this->options['protected_endpoints'] ?? array() ),
346 'allowed_endpoints' => count( $this->options['allowed_public_endpoints'] ?? array() ),
347 );
348 }
349
350 /**
351 * Test if a specific endpoint would be accessible
352 *
353 * @param string $endpoint Endpoint to test.
354 * @param bool $authenticated Whether user is authenticated.
355 * @return array Test result.
356 */
357 public function test_endpoint_access( $endpoint, $authenticated = false ) {
358 $result = array(
359 'endpoint' => $endpoint,
360 'authenticated' => $authenticated,
361 'allowed' => false,
362 'reason' => '',
363 );
364
365 // Logged in users always have access
366 if ( $authenticated ) {
367 $result['allowed'] = true;
368 $result['reason'] = 'Authenticated users have full access';
369 return $result;
370 }
371
372 $mode = $this->options['mode'] ?? 'selective';
373
374 if ( 'open' === $mode ) {
375 $result['allowed'] = true;
376 $result['reason'] = 'REST API is in open mode';
377 return $result;
378 }
379
380 if ( 'authenticated_only' === $mode ) {
381 $result['allowed'] = false;
382 $result['reason'] = 'REST API requires authentication';
383 return $result;
384 }
385
386 // Selective mode
387 $protected = $this->options['protected_endpoints'] ?? array();
388
389 foreach ( $protected as $protected_endpoint ) {
390 $protected_endpoint = trim( $protected_endpoint );
391 if ( empty( $protected_endpoint ) ) {
392 continue;
393 }
394 if ( strpos( $endpoint, $protected_endpoint ) === 0 ) {
395 $result['allowed'] = false;
396 $result['reason'] = 'Endpoint is protected';
397 return $result;
398 }
399 }
400
401 $result['allowed'] = true;
402 $result['reason'] = 'Endpoint is not protected';
403 return $result;
404 }
405 }