PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.8.3
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.8.3
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 months ago LogWriter.php 7 months ago OptionStatus.php 10 months ago Singleton.php 1 year ago index.php 1 year ago
AjaxRequest.php
340 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 ( ! $referer || strpos( $referer, admin_url() ) !== 0 ) {
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 * Utility to check if a string is JSON.
181 */
182 private function is_json( string $string ): bool {
183 json_decode( $string );
184 return json_last_error() === JSON_ERROR_NONE;
185 }
186
187 /**
188 * Extracts data from an array using the given keys.
189 *
190 * @param array $sanitized The array from which to extract data.
191 * @param array $keys The keys to use for extraction.
192 *
193 * @return array The extracted data.
194 */
195 public function extract_data( array $sanitized, array $keys ): array {
196 return array_intersect_key( $sanitized, array_flip( $keys ) );
197 }
198
199 /**
200 * Register AJAX actions.
201 *
202 * @param string $action The action to register.
203 * @return string The action name.
204 */
205 private function action( $action ): string {
206 return sprintf( 'wp_ajax_%s-%s', Template::NAME, $action );
207 }
208
209 /**
210 * Check if the current action requires Zoho API calls.
211 * Override this method in classes that need specific rate limiting rules.
212 *
213 * @return bool
214 */
215 protected function requires_zoho_api_call(): bool {
216 // Get the current action being processed.
217 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Action name used for rate limiting check.
218 $current_action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
219
220 // Actions that are internal AJAX calls (no Zoho API interaction).
221 $internal_ajax_actions = array(
222 'get_zoho_categories', // Fetches cached categories data.
223 'get_zoho_taxes', // Fetches cached tax data.
224 'get_zoho_locations', // Fetches cached location data.
225 'get_zoho_prices', // Fetches cached price data.
226 'get_wc_taxes', // WordPress internal data.
227 'save_zoho_', // All save operations are internal settings.
228 'get_zoho_connect', // Connection settings retrieval.
229 'reset_zoho_', // Reset operations are internal.
230 'is_connected', // Connection check uses cached data.
231 );
232
233 // Check if current action is internal (should NOT be rate limited).
234 foreach ( $internal_ajax_actions as $internal_action ) {
235 if ( strpos( $current_action, $internal_action ) !== false ) {
236 return false;
237 }
238 }
239
240 // Actions that make actual Zoho API calls (external requests - SHOULD be rate limited).
241 $zoho_api_actions = array(
242 'zoho_ajax_call_item', // Product sync to Zoho.
243 'zoho_ajax_call_variable_item_from_zoho', // Fetch variable products from Zoho.
244 'zoho_ajax_call_item_from_zoho', // Fetch simple products from Zoho.
245 'zoho_ajax_call_composite_item_from_zoho', // Fetch composite products from Zoho.
246 'zoho_ajax_call_composite_item', // Sync composite products to Zoho.
247 'zoho_ajax_call_parent_categories', // Sync parent categories (makes Zoho API calls).
248 'zoho_ajax_call_subcategories', // Sync subcategories (makes Zoho API calls).
249 'zoho_ajax_call_subcategories_start', // Start async subcategory sync.
250 'zoho_ajax_call_subcategories_batch', // Process subcategory sync batch.
251 'zoho_ajax_call_subcategories_status', // Get subcategory sync status.
252 'zoho_ajax_call_remove_duplicates', // Remove duplicates (makes Zoho API calls).
253 'import_zoho_contacts', // Contact sync from Zoho.
254 'convert_guest', // Convert guest customers (Zoho API calls).
255 );
256
257 // Check if current action makes Zoho API calls.
258 foreach ( $zoho_api_actions as $api_action ) {
259 if ( strpos( $current_action, $api_action ) !== false ) {
260 return true;
261 }
262 }
263
264 // Default to no rate limiting for unknown actions.
265 return false;
266 }
267
268 /**
269 * Check Zoho API rate limit (based on Zoho's own rate limiting).
270 *
271 * @return bool
272 */
273 private function check_zoho_rate_limit(): bool {
274 // Check if Zoho has indicated rate limit exceeded.
275 $zoho_rate_limit_exceeded = get_option( 'cmbird_zoho_rate_limit_exceeded', false );
276
277 if ( $zoho_rate_limit_exceeded ) {
278 // Check if enough time has passed since the rate limit was hit.
279 $rate_limit_time = get_option( 'cmbird_zoho_rate_limit_time', 0 );
280 $current_time = time();
281
282 // Zoho typically resets limits every minute, so wait 65 seconds to be safe.
283 if ( ( $current_time - $rate_limit_time ) < 65 ) {
284 return false;
285 } else {
286 // Enough time has passed, reset the rate limit flag.
287 update_option( 'cmbird_zoho_rate_limit_exceeded', false );
288 delete_option( 'cmbird_zoho_rate_limit_time' );
289 }
290 }
291
292 return true;
293 }
294
295 /**
296 * Deep sanitization of data arrays.
297 *
298 * @param mixed $data Data to sanitize.
299 * @return mixed Sanitized data.
300 */
301 private function sanitize_deep( $data ) {
302 if ( is_array( $data ) ) {
303 return array_map( array( $this, 'sanitize_deep' ), $data );
304 }
305
306 if ( is_string( $data ) ) {
307 return sanitize_text_field( $data );
308 }
309
310 return $data;
311 }
312
313 /**
314 * Log security events for audit trail.
315 */
316 private function log_security_event(): void {
317 $log_data = array(
318 'timestamp' => current_time( 'mysql' ),
319 'user_id' => get_current_user_id(),
320 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
321 'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown',
322 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
323 'action' => isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : 'unknown',
324 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
325 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown',
326 );
327
328 // Store in transient for security monitoring.
329 $security_log = get_transient( 'commercebird_security_log' ) ?: array();
330 $security_log[] = $log_data;
331
332 // Keep only last 100 entries.
333 if ( count( $security_log ) > 100 ) {
334 $security_log = array_slice( $security_log, -100 );
335 }
336
337 set_transient( 'commercebird_security_log', $security_log, DAY_IN_SECONDS );
338 }
339 }
340