560 lines
17 KiB
PHP
560 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Ticket Tailor Webhook Handler
|
|
*
|
|
* Handles real-time updates via webhooks from Ticket Tailor
|
|
* FIXED VERSION: Added rate limiting (100 requests/hour)
|
|
*/
|
|
|
|
// Exit if accessed directly
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class Ticket_Tailor_Webhook_Handler {
|
|
|
|
/**
|
|
* Event Manager
|
|
*/
|
|
private $events;
|
|
|
|
/**
|
|
* Order Manager
|
|
*/
|
|
private $orders;
|
|
|
|
/**
|
|
* Webhook secret
|
|
*/
|
|
private $webhook_secret;
|
|
|
|
/**
|
|
* Rate limiting settings
|
|
*/
|
|
private $rate_limit = 100; // Max requests per hour
|
|
private $rate_limit_window = 3600; // 1 hour in seconds
|
|
|
|
/**
|
|
* Allowed webhook IP ranges - SECURITY ENHANCEMENT
|
|
* Note: Update this array with actual Ticket Tailor IP ranges
|
|
*/
|
|
private $default_webhook_ips = array(
|
|
// These are placeholder IPs - replace with actual Ticket Tailor IPs
|
|
// Format: 'x.x.x.x' or 'x.x.x.x/24' for CIDR ranges
|
|
'0.0.0.0/0', // Allow all by default - should be configured by admin
|
|
);
|
|
|
|
/**
|
|
* Security logger
|
|
*/
|
|
private $security_logger;
|
|
|
|
/**
|
|
* Constructor - SECURITY ENHANCEMENT: Added security logger
|
|
*/
|
|
public function __construct($events, $orders) {
|
|
$this->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))
|
|
));
|
|
}
|
|
}
|