PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.7.0
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.7.0
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 7 months ago LogWriter.php 7 months ago OptionStatus.php 10 months ago Singleton.php 1 year ago index.php 1 year ago
AjaxRequest.php
301 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 * Array to store registered AJAX requests.
15 *
16 * @var array
17 */
18 private array $request = array();
19
20 /**
21 * Array to store registered AJAX response.
22 *
23 * @var array
24 */
25 private array $response = array( 'message' => 'Saved' );
26
27 /**
28 * Array to store registered AJAX posted data.
29 *
30 * @var array
31 */
32 private array $data = array();
33
34 /**
35 * Array to store registered AJAX errors.
36 *
37 * @var array
38 */
39 private array $errors = array();
40
41 /**
42 * Load all registered AJAX actions
43 *
44 * Loops through the self::ACTIONS array and registers each action
45 * with WordPress using the add_action hook.
46 */
47 private function load_actions() {
48 foreach ( self::ACTIONS as $action => $handler ) {
49 add_action(
50 $this->action( $action ),
51 array( $this, $handler ),
52 );
53 }
54 }
55
56 /**
57 * Serve data to AJAX request.
58 */
59 private function serve(): void {
60 if ( count( $this->errors ) > 0 ) {
61 wp_send_json_error( $this->errors );
62 }
63
64 wp_send_json_success( $this->response );
65 }
66
67 /**
68 * Verify AJAX request with enhanced security.
69 *
70 * @param array $keys The keys to verify (optional).
71 */
72 private function verify( array $keys = array() ): void {
73 // Early exit: these Ajax handlers should never be called by logged-out users.
74 // WordPress wp_ajax_ actions are only for logged-in users, so this shouldn't happen.
75 if ( ! is_user_logged_in() ) {
76 // If we reach here, something is wrong with the action registration.
77 // Just exit silently to prevent frontend errors.
78 wp_die( '', '', array( 'response' => 200 ) );
79 }
80
81 // Check if the request is coming from the admin area (for logged-in users).
82 $referer = wp_get_referer();
83 if ( ! $referer || strpos( $referer, admin_url() ) !== 0 ) {
84 $this->errors = array( 'message' => 'Invalid request origin' );
85 $this->serve();
86 return;
87 }
88
89 // Enhanced CSRF protection with proper nonce verification.
90 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
91 $security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
92 $nonce_action = Template::NAME;
93
94 if ( ! wp_verify_nonce( $security_token, $nonce_action ) ) {
95 $this->errors = array( 'message' => 'Security check failed' );
96 $this->serve();
97 return;
98 }
99
100 // Rate limiting check - only for actions that make Zoho API calls.
101 if ( $this->requires_zoho_api_call() && ! $this->check_zoho_rate_limit() ) {
102 $this->errors = array( 'message' => 'Zoho API rate limit exceeded. Please wait and try again.' );
103 $this->serve();
104 return;
105 }
106
107 // Capability check - ensure user has proper permissions (same as menu page).
108 if ( ! current_user_can( 'manage_woocommerce' ) ) {
109 $this->errors = array( 'message' => 'Insufficient permissions' );
110 $this->serve();
111 return;
112 }
113
114 // Initialize response and errors.
115 $this->response = array( 'success' => true );
116 $this->errors = array();
117 $this->request = array_map( 'sanitize_text_field', wp_unslash( $_REQUEST ) );
118
119 // Attempt to retrieve JSON if POST is empty.
120 if ( empty( $_POST ) ) {
121 $contents = trim( file_get_contents( 'php://input' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended.
122
123 if ( $this->is_json( $contents ) ) {
124 $json_data = json_decode( $contents, true );
125
126 if ( ! empty( $json_data ) ) {
127 // Deep sanitization of JSON data.
128 $this->data = empty( $keys ) ? $this->sanitize_deep( $json_data ) : $this->extract_data( $this->sanitize_deep( $json_data ), $keys );
129 }
130 }
131 } else {
132 // Sanitize POST data.
133 $this->data = empty( $keys ) ? $this->sanitize_deep( $_POST ) : $this->extract_data( $this->sanitize_deep( $_POST ), $keys );
134 }
135
136 // Log security events for audit.
137 $this->log_security_event();
138 }
139
140 /**
141 * Utility to check if a string is JSON.
142 */
143 private function is_json( string $string ): bool {
144 json_decode( $string );
145 return json_last_error() === JSON_ERROR_NONE;
146 }
147
148 /**
149 * Extracts data from an array using the given keys.
150 *
151 * @param array $sanitized The array from which to extract data.
152 * @param array $keys The keys to use for extraction.
153 *
154 * @return array The extracted data.
155 */
156 public function extract_data( array $sanitized, array $keys ): array {
157 return array_intersect_key( $sanitized, array_flip( $keys ) );
158 }
159
160 /**
161 * Register AJAX actions.
162 *
163 * @param string $action The action to register.
164 * @return string The action name.
165 */
166 private function action( $action ): string {
167 return sprintf( 'wp_ajax_%s-%s', Template::NAME, $action );
168 }
169
170 /**
171 * Check if the current action requires Zoho API calls.
172 * Override this method in classes that need specific rate limiting rules.
173 *
174 * @return bool
175 */
176 protected function requires_zoho_api_call(): bool {
177 // Get the current action being processed.
178 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Action name used for rate limiting check.
179 $current_action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
180
181 // Actions that are internal AJAX calls (no Zoho API interaction).
182 $internal_ajax_actions = array(
183 'get_zoho_categories', // Fetches cached categories data.
184 'get_zoho_taxes', // Fetches cached tax data.
185 'get_zoho_locations', // Fetches cached location data.
186 'get_zoho_prices', // Fetches cached price data.
187 'get_wc_taxes', // WordPress internal data.
188 'save_zoho_', // All save operations are internal settings.
189 'get_zoho_connect', // Connection settings retrieval.
190 'reset_zoho_', // Reset operations are internal.
191 'is_connected', // Connection check uses cached data.
192 );
193
194 // Check if current action is internal (should NOT be rate limited).
195 foreach ( $internal_ajax_actions as $internal_action ) {
196 if ( strpos( $current_action, $internal_action ) !== false ) {
197 return false;
198 }
199 }
200
201 // Actions that make actual Zoho API calls (external requests - SHOULD be rate limited).
202 $zoho_api_actions = array(
203 'zoho_ajax_call_item', // Product sync to Zoho.
204 'zoho_ajax_call_variable_item_from_zoho', // Fetch variable products from Zoho.
205 'zoho_ajax_call_item_from_zoho', // Fetch simple products from Zoho.
206 'zoho_ajax_call_composite_item_from_zoho', // Fetch composite products from Zoho.
207 'zoho_ajax_call_composite_item', // Sync composite products to Zoho.
208 'zoho_ajax_call_parent_categories', // Sync parent categories (makes Zoho API calls).
209 'zoho_ajax_call_subcategories', // Sync subcategories (makes Zoho API calls).
210 'zoho_ajax_call_subcategories_start', // Start async subcategory sync.
211 'zoho_ajax_call_subcategories_batch', // Process subcategory sync batch.
212 'zoho_ajax_call_subcategories_status', // Get subcategory sync status.
213 'zoho_ajax_call_remove_duplicates', // Remove duplicates (makes Zoho API calls).
214 'import_zoho_contacts', // Contact sync from Zoho.
215 'convert_guest', // Convert guest customers (Zoho API calls).
216 );
217
218 // Check if current action makes Zoho API calls.
219 foreach ( $zoho_api_actions as $api_action ) {
220 if ( strpos( $current_action, $api_action ) !== false ) {
221 return true;
222 }
223 }
224
225 // Default to no rate limiting for unknown actions.
226 return false;
227 }
228
229 /**
230 * Check Zoho API rate limit (based on Zoho's own rate limiting).
231 *
232 * @return bool
233 */
234 private function check_zoho_rate_limit(): bool {
235 // Check if Zoho has indicated rate limit exceeded.
236 $zoho_rate_limit_exceeded = get_option( 'cmbird_zoho_rate_limit_exceeded', false );
237
238 if ( $zoho_rate_limit_exceeded ) {
239 // Check if enough time has passed since the rate limit was hit.
240 $rate_limit_time = get_option( 'cmbird_zoho_rate_limit_time', 0 );
241 $current_time = time();
242
243 // Zoho typically resets limits every minute, so wait 65 seconds to be safe.
244 if ( ( $current_time - $rate_limit_time ) < 65 ) {
245 return false;
246 } else {
247 // Enough time has passed, reset the rate limit flag.
248 update_option( 'cmbird_zoho_rate_limit_exceeded', false );
249 delete_option( 'cmbird_zoho_rate_limit_time' );
250 }
251 }
252
253 return true;
254 }
255
256 /**
257 * Deep sanitization of data arrays.
258 *
259 * @param mixed $data Data to sanitize.
260 * @return mixed Sanitized data.
261 */
262 private function sanitize_deep( $data ) {
263 if ( is_array( $data ) ) {
264 return array_map( array( $this, 'sanitize_deep' ), $data );
265 }
266
267 if ( is_string( $data ) ) {
268 return sanitize_text_field( $data );
269 }
270
271 return $data;
272 }
273
274 /**
275 * Log security events for audit trail.
276 */
277 private function log_security_event(): void {
278 $log_data = array(
279 'timestamp' => current_time( 'mysql' ),
280 'user_id' => get_current_user_id(),
281 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
282 'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown',
283 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
284 'action' => isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : 'unknown',
285 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
286 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown',
287 );
288
289 // Store in transient for security monitoring.
290 $security_log = get_transient( 'commercebird_security_log' ) ?: array();
291 $security_log[] = $log_data;
292
293 // Keep only last 100 entries.
294 if ( count( $security_log ) > 100 ) {
295 $security_log = array_slice( $security_log, -100 );
296 }
297
298 set_transient( 'commercebird_security_log', $security_log, DAY_IN_SECONDS );
299 }
300 }
301