added additional plugins
This commit is contained in:
parent
c85895d306
commit
00e60ec1b7
132 changed files with 27514 additions and 0 deletions
|
|
@ -0,0 +1,560 @@
|
|||
<?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))
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue