PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 3.0.1
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v3.0.1
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / admin / includes / Traits / AjaxRequest.php
commercebird / admin / includes / Traits Last commit date
AjaxRequest.php 4 weeks ago LogWriter.php 7 months ago OptionStatus.php 10 months ago Singleton.php 1 year ago index.php 1 year ago
AjaxRequest.php
382 lines
1 <?php
2
3 namespace CommerceBird\Admin\Traits;
4
5 use CommerceBird\Admin\Template;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 trait AjaxRequest {
12
13 /**
14 * When true, the verify() method will immediately return without performing
15 * any of the usual security checks. This is only intended for use from
16 * automated unit tests.
17 *
18 * @var bool
19 */
20 private static bool $skip_verify_for_tests = false;
21
22 /**
23 * Array to store registered AJAX requests.
24 *
25 * @var array
26 */
27 private array $request = array();
28
29 /**
30 * Array to store registered AJAX response.
31 *
32 * @var array
33 */
34 private array $response = array( 'message' => 'Saved' );
35
36 /**
37 * Array to store registered AJAX posted data.
38 *
39 * @var array
40 */
41 private array $data = array();
42
43 /**
44 * Array to store registered AJAX errors.
45 *
46 * @var array
47 */
48 private array $errors = array();
49
50 /**
51 * Load all registered AJAX actions
52 *
53 * Loops through the self::ACTIONS array and registers each action
54 * with WordPress using the add_action hook.
55 */
56 private function load_actions() {
57 foreach ( self::ACTIONS as $action => $handler ) {
58 add_action(
59 $this->action( $action ),
60 array( $this, $handler ),
61 );
62 }
63 }
64
65 /**
66 * Serve data to AJAX request.
67 */
68 /**
69 * @visibleForTesting
70 */
71 public function _test_get_response(): array {
72 return $this->response;
73 }
74
75 /**
76 * @visibleForTesting
77 */
78 public function _test_get_errors(): array {
79 return $this->errors;
80 }
81
82 /**
83 * Toggle the verify bypass flag used in tests.
84 *
85 * @param bool $skip
86 */
87 public static function _test_set_skip_verify( bool $skip ): void {
88 self::$skip_verify_for_tests = $skip;
89 }
90
91 /**
92 * Serve data to AJAX request.
93 */
94 private function serve(): void {
95 if ( count( $this->errors ) > 0 ) {
96 wp_send_json_error( $this->errors );
97 }
98
99 wp_send_json_success( $this->response );
100 }
101
102 /**
103 * Verify AJAX request with enhanced security.
104 *
105 * @param array $keys The keys to verify (optional).
106 */
107 private function verify( array $keys = array() ): void {
108 // testing bypass.
109 if ( self::$skip_verify_for_tests ) {
110 return;
111 }
112 // Early exit: these Ajax handlers should never be called by logged-out users.
113 // WordPress wp_ajax_ actions are only for logged-in users, so this shouldn't happen.
114 if ( ! is_user_logged_in() ) {
115 // If we reach here, something is wrong with the action registration.
116 // Just exit silently to prevent frontend errors.
117 wp_die( '', '', array( 'response' => 200 ) );
118 }
119
120 // Check if the request is coming from the admin area (for logged-in users).
121 $referer = wp_get_referer();
122 if ( ! $this->is_valid_admin_referer( $referer ) ) {
123 $this->errors = array( 'message' => 'Invalid request origin' );
124 $this->serve();
125 return;
126 }
127
128 // Enhanced CSRF protection with proper nonce verification.
129 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
130 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
131 $nonce_action = Template::NAME;
132
133 if ( ! wp_verify_nonce( $security_token, $nonce_action ) ) {
134 $this->errors = array( 'message' => 'Security check failed' );
135 $this->serve();
136 return;
137 }
138
139 // Rate limiting check - only for actions that make Zoho API calls.
140 if ( $this->requires_zoho_api_call() && ! $this->check_zoho_rate_limit() ) {
141 $this->errors = array( 'message' => 'Zoho API rate limit exceeded. Please wait and try again.' );
142 $this->serve();
143 return;
144 }
145
146 // Capability check - ensure user has proper permissions (same as menu page).
147 if ( ! current_user_can( 'manage_woocommerce' ) ) {
148 $this->errors = array( 'message' => 'Insufficient permissions' );
149 $this->serve();
150 return;
151 }
152
153 // Initialize response and errors.
154 $this->response = array( 'success' => true );
155 $this->errors = array();
156 $this->request = array_map( 'sanitize_text_field', wp_unslash( $_REQUEST ) );
157
158 // Attempt to retrieve JSON if POST is empty.
159 if ( empty( $_POST ) ) {
160 $contents = trim( file_get_contents( 'php://input' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended.
161
162 if ( $this->is_json( $contents ) ) {
163 $json_data = json_decode( $contents, true );
164
165 if ( ! empty( $json_data ) ) {
166 // Deep sanitization of JSON data.
167 $this->data = empty( $keys ) ? $this->sanitize_deep( $json_data ) : $this->extract_data( $this->sanitize_deep( $json_data ), $keys );
168 }
169 }
170 } else {
171 // Sanitize POST data.
172 $this->data = empty( $keys ) ? $this->sanitize_deep( $_POST ) : $this->extract_data( $this->sanitize_deep( $_POST ), $keys );
173 }
174
175 // Log security events for audit.
176 $this->log_security_event();
177 }
178
179 /**
180 * Validate that request referer belongs to this site's admin area.
181 *
182 * This supports local development setups where the admin referer may include
183 * a different port than admin_url(), while still requiring the same host and
184 * an admin path prefix.
185 *
186 * @param string|false $referer Request referer URL.
187 * @return bool
188 */
189 private function is_valid_admin_referer( $referer ): bool {
190 if ( empty( $referer ) ) {
191 return false;
192 }
193
194 $admin_url = admin_url();
195
196 // Fast path when URLs match exactly.
197 if ( strpos( $referer, $admin_url ) === 0 ) {
198 return true;
199 }
200
201 $referer_parts = wp_parse_url( $referer );
202 $admin_parts = wp_parse_url( $admin_url );
203
204 if ( ! is_array( $referer_parts ) || ! is_array( $admin_parts ) ) {
205 return false;
206 }
207
208 $referer_host = $referer_parts['host'] ?? '';
209 $admin_host = $admin_parts['host'] ?? '';
210
211 if ( '' === $referer_host || '' === $admin_host || $referer_host !== $admin_host ) {
212 return false;
213 }
214
215 $admin_path = untrailingslashit( $admin_parts['path'] ?? '/wp-admin' );
216 $referer_path = $referer_parts['path'] ?? '';
217
218 return '' !== $referer_path && strpos( $referer_path, $admin_path ) === 0;
219 }
220
221 /**
222 * Utility to check if a string is JSON.
223 */
224 private function is_json( string $string ): bool {
225 json_decode( $string );
226 return json_last_error() === JSON_ERROR_NONE;
227 }
228
229 /**
230 * Extracts data from an array using the given keys.
231 *
232 * @param array $sanitized The array from which to extract data.
233 * @param array $keys The keys to use for extraction.
234 *
235 * @return array The extracted data.
236 */
237 public function extract_data( array $sanitized, array $keys ): array {
238 return array_intersect_key( $sanitized, array_flip( $keys ) );
239 }
240
241 /**
242 * Register AJAX actions.
243 *
244 * @param string $action The action to register.
245 * @return string The action name.
246 */
247 private function action( $action ): string {
248 return sprintf( 'wp_ajax_%s-%s', Template::NAME, $action );
249 }
250
251 /**
252 * Check if the current action requires Zoho API calls.
253 * Override this method in classes that need specific rate limiting rules.
254 *
255 * @return bool
256 */
257 protected function requires_zoho_api_call(): bool {
258 // Get the current action being processed.
259 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Action name used for rate limiting check.
260 $current_action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
261
262 // Actions that are internal AJAX calls (no Zoho API interaction).
263 $internal_ajax_actions = array(
264 'get_zoho_categories', // Fetches cached categories data.
265 'get_zoho_taxes', // Fetches cached tax data.
266 'get_zoho_locations', // Fetches cached location data.
267 'get_zoho_prices', // Fetches cached price data.
268 'get_wc_taxes', // WordPress internal data.
269 'save_zoho_', // All save operations are internal settings.
270 'get_zoho_connect', // Connection settings retrieval.
271 'reset_zoho_', // Reset operations are internal.
272 'is_connected', // Connection check uses cached data.
273 );
274
275 // Check if current action is internal (should NOT be rate limited).
276 foreach ( $internal_ajax_actions as $internal_action ) {
277 if ( strpos( $current_action, $internal_action ) !== false ) {
278 return false;
279 }
280 }
281
282 // Actions that make actual Zoho API calls (external requests - SHOULD be rate limited).
283 $zoho_api_actions = array(
284 'zoho_ajax_call_item', // Product sync to Zoho.
285 'zoho_ajax_call_variable_item_from_zoho', // Fetch variable products from Zoho.
286 'zoho_ajax_call_item_from_zoho', // Fetch simple products from Zoho.
287 'zoho_ajax_call_composite_item_from_zoho', // Fetch composite products from Zoho.
288 'zoho_ajax_call_composite_item', // Sync composite products to Zoho.
289 'zoho_ajax_call_parent_categories', // Sync parent categories (makes Zoho API calls).
290 'zoho_ajax_call_subcategories', // Sync subcategories (makes Zoho API calls).
291 'zoho_ajax_call_subcategories_start', // Start async subcategory sync.
292 'zoho_ajax_call_subcategories_batch', // Process subcategory sync batch.
293 'zoho_ajax_call_subcategories_status', // Get subcategory sync status.
294 'zoho_ajax_call_remove_duplicates', // Remove duplicates (makes Zoho API calls).
295 'import_zoho_contacts', // Contact sync from Zoho.
296 'convert_guest', // Convert guest customers (Zoho API calls).
297 );
298
299 // Check if current action makes Zoho API calls.
300 foreach ( $zoho_api_actions as $api_action ) {
301 if ( strpos( $current_action, $api_action ) !== false ) {
302 return true;
303 }
304 }
305
306 // Default to no rate limiting for unknown actions.
307 return false;
308 }
309
310 /**
311 * Check Zoho API rate limit (based on Zoho's own rate limiting).
312 *
313 * @return bool
314 */
315 private function check_zoho_rate_limit(): bool {
316 // Check if Zoho has indicated rate limit exceeded.
317 $zoho_rate_limit_exceeded = get_option( 'cmbird_zoho_rate_limit_exceeded', false );
318
319 if ( $zoho_rate_limit_exceeded ) {
320 // Check if enough time has passed since the rate limit was hit.
321 $rate_limit_time = get_option( 'cmbird_zoho_rate_limit_time', 0 );
322 $current_time = time();
323
324 // Zoho typically resets limits every minute, so wait 65 seconds to be safe.
325 if ( ( $current_time - $rate_limit_time ) < 65 ) {
326 return false;
327 } else {
328 // Enough time has passed, reset the rate limit flag.
329 update_option( 'cmbird_zoho_rate_limit_exceeded', false );
330 delete_option( 'cmbird_zoho_rate_limit_time' );
331 }
332 }
333
334 return true;
335 }
336
337 /**
338 * Deep sanitization of data arrays.
339 *
340 * @param mixed $data Data to sanitize.
341 * @return mixed Sanitized data.
342 */
343 private function sanitize_deep( $data ) {
344 if ( is_array( $data ) ) {
345 return array_map( array( $this, 'sanitize_deep' ), $data );
346 }
347
348 if ( is_string( $data ) ) {
349 return sanitize_text_field( $data );
350 }
351
352 return $data;
353 }
354
355 /**
356 * Log security events for audit trail.
357 */
358 private function log_security_event(): void {
359 $log_data = array(
360 'timestamp' => current_time( 'mysql' ),
361 'user_id' => get_current_user_id(),
362 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
363 'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown',
364 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
365 'action' => isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : 'unknown',
366 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
367 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown',
368 );
369
370 // Store in transient for security monitoring.
371 $security_log = get_transient( 'commercebird_security_log' ) ?: array();
372 $security_log[] = $log_data;
373
374 // Keep only last 100 entries.
375 if ( count( $security_log ) > 100 ) {
376 $security_log = array_slice( $security_log, -100 );
377 }
378
379 set_transient( 'commercebird_security_log', $security_log, DAY_IN_SECONDS );
380 }
381 }
382