events = $events; $this->orders = $orders; $this->webhook_secret = get_option('ticket_tailor_webhook_secret', ''); $this->security_logger = new Ticket_Tailor_Security_Logger(); // Register webhook endpoint add_action('rest_api_init', array($this, 'register_webhook_endpoint')); } /** * Register REST API endpoint for webhooks */ public function register_webhook_endpoint() { register_rest_route('ticket-tailor/v1', '/webhook', array( 'methods' => 'POST', 'callback' => array($this, 'handle_webhook'), 'permission_callback' => '__return_true', // Public endpoint (verified via secret) )); } /** * Handle incoming webhook - SECURITY ENHANCEMENT: IP whitelisting & logging */ public function handle_webhook($request) { $ip_address = $this->get_client_ip(); // SECURITY ENHANCEMENT: Check IP whitelist first if (!$this->verify_webhook_ip($ip_address)) { $this->security_logger->log_failed_webhook($ip_address, 'IP not whitelisted'); return new WP_Error( 'ip_not_allowed', __('IP address not authorized for webhooks', 'ticket-tailor'), array('status' => 403) ); } // SECURITY FIX: Verify webhook secret is configured (mandatory) if (empty($this->webhook_secret)) { $this->security_logger->log_failed_webhook($ip_address, 'Webhook secret not configured'); return new WP_Error( 'webhook_not_configured', __('Webhook secret not configured. Please set up webhook authentication.', 'ticket-tailor'), array('status' => 503) ); } // Check rate limit if (!$this->check_rate_limit($ip_address)) { $this->security_logger->log_rate_limit_exceeded($ip_address, 'webhook'); return new WP_Error( 'rate_limit_exceeded', __('Rate limit exceeded. Please try again later.', 'ticket-tailor'), array('status' => 429) ); } // Get raw body with size limit (SECURITY FIX: Prevent DoS) $body = $request->get_body(); if (strlen($body) > 102400) { // 100KB limit return new WP_Error( 'payload_too_large', __('Webhook payload too large', 'ticket-tailor'), array('status' => 413) ); } $data = json_decode($body, true); // SECURITY FIX: Validate JSON if (json_last_error() !== JSON_ERROR_NONE) { return new WP_Error( 'invalid_json', __('Invalid JSON payload', 'ticket-tailor'), array('status' => 400) ); } // SECURITY FIX: Verify webhook signature (now mandatory) $signature = $request->get_header('X-TT-Signature'); if (!$this->verify_signature($body, $signature)) { $this->security_logger->log_failed_webhook($ip_address, 'Invalid signature'); return new WP_Error( 'invalid_signature', __('Invalid webhook signature', 'ticket-tailor'), array('status' => 401) ); } // ENTERPRISE: Idempotency check - prevent duplicate processing $webhook_id = $data['id'] ?? md5($body); if ($this->is_webhook_processed($webhook_id)) { // Already processed this webhook - return success to ack receipt return new WP_REST_Response(array( 'success' => true, 'message' => 'Webhook already processed (idempotent)', 'webhook_id' => $webhook_id, ), 200); } // Log webhook for debugging (optional) $this->log_webhook($data); // SECURITY ENHANCEMENT: Log successful webhook $event_type = $data['type'] ?? 'unknown'; $this->security_logger->log_successful_webhook($ip_address, $event_type); // ENTERPRISE: Mark webhook as processed $this->mark_webhook_processed($webhook_id, $event_type, $ip_address); // Process webhook based on type $event_type = $data['type'] ?? ''; switch ($event_type) { case 'order.created': case 'order.updated': $this->handle_order_webhook($data); break; case 'issued_ticket.created': case 'issued_ticket.updated': case 'issued_ticket.voided': $this->handle_ticket_webhook($data); break; case 'event.created': case 'event.updated': $this->handle_event_webhook($data); break; default: // Unknown webhook type break; } // Trigger custom action for developers do_action('ticket_tailor_webhook_received', $event_type, $data); return new WP_REST_Response(array( 'success' => true, 'message' => 'Webhook processed', ), 200); } /** * Check rate limit - SECURITY FIX: IP-based rate limiting with database storage */ private function check_rate_limit($ip_address) { global $wpdb; // Sanitize IP address $ip_address = filter_var($ip_address, FILTER_VALIDATE_IP); if (!$ip_address) { return false; // Invalid IP } // Create rate limit table if it doesn't exist $table_name = $wpdb->prefix . 'ticket_tailor_rate_limits'; $this->maybe_create_rate_limit_table($table_name); // Clean up old entries (older than rate limit window) $wpdb->query($wpdb->prepare( "DELETE FROM {$table_name} WHERE timestamp < %s", gmdate('Y-m-d H:i:s', time() - $this->rate_limit_window) )); // Count requests from this IP in the current window $request_count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table_name} WHERE ip_address = %s AND timestamp > %s", $ip_address, gmdate('Y-m-d H:i:s', time() - $this->rate_limit_window) )); if ($request_count >= $this->rate_limit) { return false; // Rate limit exceeded } // Record this request $wpdb->insert( $table_name, array( 'ip_address' => $ip_address, 'timestamp' => current_time('mysql', 1), ), array('%s', '%s') ); return true; } /** * Create rate limit table if it doesn't exist - SECURITY FIX */ private function maybe_create_rate_limit_table($table_name) { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE IF NOT EXISTS {$table_name} ( id bigint(20) NOT NULL AUTO_INCREMENT, ip_address varchar(45) NOT NULL, timestamp datetime NOT NULL, PRIMARY KEY (id), KEY ip_timestamp (ip_address, timestamp) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); } /** * Get client IP address safely - SECURITY FIX */ private function get_client_ip() { $ip_keys = array( 'HTTP_CF_CONNECTING_IP', // CloudFlare 'HTTP_X_FORWARDED_FOR', // Proxy 'HTTP_X_REAL_IP', // Nginx proxy 'REMOTE_ADDR', // Direct connection ); foreach ($ip_keys as $key) { if (!empty($_SERVER[$key])) { $ip = $_SERVER[$key]; // Handle comma-separated list (X-Forwarded-For) if (strpos($ip, ',') !== false) { $ip = trim(explode(',', $ip)[0]); } // Validate IP if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return $ip; } } } // Fallback to REMOTE_ADDR even if private return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0'; } /** * Get current rate limit status - NEW IN THIS VERSION */ public function get_rate_limit_status() { $transient_key = 'tt_webhook_rate_limit'; $requests = get_transient($transient_key); return array( 'requests_made' => $requests !== false ? $requests : 0, 'requests_remaining' => $this->rate_limit - ($requests !== false ? $requests : 0), 'limit' => $this->rate_limit, 'window' => $this->rate_limit_window, ); } /** * Verify webhook IP address - SECURITY ENHANCEMENT */ private function verify_webhook_ip($ip) { // Get configured IP whitelist (defaults to allow all if not configured) $allowed_ips = get_option('ticket_tailor_webhook_ips', $this->default_webhook_ips); // If whitelist is empty, allow all (for backward compatibility) if (empty($allowed_ips)) { return true; } foreach ($allowed_ips as $allowed_range) { if ($this->ip_in_range($ip, $allowed_range)) { return true; } } return false; } /** * Check if IP is in CIDR range - SECURITY ENHANCEMENT */ private function ip_in_range($ip, $range) { // Handle exact match if (strpos($range, '/') === false) { return $ip === $range; } // Handle CIDR notation list($subnet, $bits) = explode('/', $range); // Convert IPs to long integers $ip_long = ip2long($ip); $subnet_long = ip2long($subnet); if ($ip_long === false || $subnet_long === false) { return false; } // Create netmask $mask = -1 << (32 - (int)$bits); $subnet_long &= $mask; return ($ip_long & $mask) == $subnet_long; } /** * Verify webhook signature */ private function verify_signature($payload, $signature) { if (empty($signature) || empty($this->webhook_secret)) { return false; } $expected_signature = hash_hmac('sha256', $payload, $this->webhook_secret); return hash_equals($expected_signature, $signature); } /** * Handle order webhook */ private function handle_order_webhook($data) { if (empty($data['data']['id'])) { return; } $order_id = $data['data']['id']; // Force refresh this specific order $order = $this->orders->get_order($order_id, true); if (!is_wp_error($order)) { // Trigger action for custom handling do_action('ticket_tailor_order_webhook', $data['type'], $order); } } /** * Handle ticket webhook */ private function handle_ticket_webhook($data) { if (empty($data['data']['order_id'])) { return; } $order_id = $data['data']['order_id']; // Refresh the related order $order = $this->orders->get_order($order_id, true); if (!is_wp_error($order)) { // Trigger action for custom handling do_action('ticket_tailor_ticket_webhook', $data['type'], $data['data'], $order); } } /** * Handle event webhook */ private function handle_event_webhook($data) { if (empty($data['data']['id'])) { return; } $event_id = $data['data']['id']; // Force refresh this specific event $event = $this->events->get_event($event_id, true); if (!is_wp_error($event)) { // Trigger action for custom handling do_action('ticket_tailor_event_webhook', $data['type'], $event); } } /** * Log webhook for debugging */ private function log_webhook($data) { // Only log if debug mode is enabled if (!get_option('ticket_tailor_debug_mode', false)) { return; } $log_entry = array( 'timestamp' => current_time('mysql'), 'type' => $data['type'] ?? 'unknown', 'data' => $data, ); // Store in transient (keep last 50 webhooks) $logs = get_transient('ticket_tailor_webhook_logs') ?: array(); array_unshift($logs, $log_entry); $logs = array_slice($logs, 0, 50); set_transient('ticket_tailor_webhook_logs', $logs, DAY_IN_SECONDS); } /** * Get webhook URL */ public function get_webhook_url() { return rest_url('ticket-tailor/v1/webhook'); } /** * Generate webhook secret */ public function generate_webhook_secret() { $secret = wp_generate_password(64, false); update_option('ticket_tailor_webhook_secret', $secret); $this->webhook_secret = $secret; return $secret; } /** * Set webhook secret */ public function set_webhook_secret($secret) { $secret = sanitize_text_field($secret); update_option('ticket_tailor_webhook_secret', $secret); $this->webhook_secret = $secret; } /** * Clear webhook secret */ public function clear_webhook_secret() { delete_option('ticket_tailor_webhook_secret'); $this->webhook_secret = ''; } /** * Get webhook logs */ public function get_logs() { return get_transient('ticket_tailor_webhook_logs') ?: array(); } /** * Clear webhook logs */ public function clear_logs() { delete_transient('ticket_tailor_webhook_logs'); } /** * Test webhook */ public function test_webhook() { $test_data = array( 'type' => 'test.webhook', 'created_at' => current_time('mysql'), 'data' => array( 'message' => 'This is a test webhook', ), ); $this->log_webhook($test_data); do_action('ticket_tailor_webhook_received', 'test.webhook', $test_data); return true; } /** * Check if webhook has been processed - ENTERPRISE: Idempotency */ private function is_webhook_processed($webhook_id) { global $wpdb; $table = $wpdb->prefix . 'ticket_tailor_webhook_log'; $exists = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE webhook_id = %s", $webhook_id )); return (bool) $exists; } /** * Mark webhook as processed - ENTERPRISE: Idempotency */ private function mark_webhook_processed($webhook_id, $event_type, $ip_address) { global $wpdb; $table = $wpdb->prefix . 'ticket_tailor_webhook_log'; $wpdb->insert( $table, array( 'webhook_id' => $webhook_id, 'event_type' => $event_type, 'processed_at' => current_time('mysql', 1), 'ip_address' => $ip_address, ), array('%s', '%s', '%s', '%s') ); // ENTERPRISE: Cleanup old webhook logs (older than 30 days) $wpdb->query($wpdb->prepare( "DELETE FROM {$table} WHERE processed_at < %s", gmdate('Y-m-d H:i:s', time() - (30 * DAY_IN_SECONDS)) )); } }