Jake Vanderwerf
2025-11-23 d7dbe7fee362d587dfc334135d9581b6216a4295
inc/rest/RestRouteManager.php
@@ -6,6 +6,7 @@
use JVBase\managers\OperationQueue;
use JVBase\managers\CacheManager;
use JVBase\managers\NotificationManager;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_Error;
use Exception;
@@ -36,7 +37,7 @@
    protected NotificationManager $notifications;
    protected string $cache_name ='';
    protected int $cache_ttl = 3600; //1 hour default
   protected array $response_headers = [];
    // Error code constants for consistency
    const ERROR_MISSING_PARAMS = 'missing_parameters';
@@ -114,6 +115,19 @@
        return true;
    }
   public function checkRateLimit(WP_REST_Request $request):bool|WP_Error
   {
      if (!$this->rate_limiter->checkLimit($request)) {
         error_log('Rate Limit Reached');
         return new WP_Error(
            'rate_limit',
            'Too many attempts. Please wait a moment before trying again.',
            ['status' => 429]
         );
      }
      return true;
   }
   protected function checkContent(string $content, bool $bool = false):string|bool
   {
      $result = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??'';
@@ -520,6 +534,392 @@
      return $this->checkHeaders($request, $types, ['user_id' => $user_id]);
   }
   /**
    * Helper to return error response
    */
   protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
   {
      $data = [
         'message' => $message,
         'code' => $code
      ];
      if ($field) {
         $data['field'] = $field;
      }
      return new WP_REST_Response($data, $status);
   }
   /**
    * Helper to return success response
    */
   protected function success(array $data, int $status = 200): WP_REST_Response
   {
      return new WP_REST_Response($data, $status);
   }
   /************************************************************
      SESSION FINGERPRINT
    ************************************************************/
   /**
    * Store session fingerprint for hijacking detection
    */
   protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
   {
      if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
         return;
      }
      $fingerprint = $this->generateSessionFingerprint($request);
      update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
      update_user_meta($user_id, BASE . 'session_timestamp', time());
   }
   /**
    * Generate session fingerprint for hijacking detection
    *
    * @param WP_REST_Request $request The REST request
    * @return string Hashed fingerprint
    */
   protected function generateSessionFingerprint(WP_REST_Request $request): string
   {
      return hash('sha256', implode('|', [
         $request->get_header('User-Agent') ?? '',
         // Use IP class instead of full IP to allow for mobile network changes
         $this->getIPClass(
            $request->get_header('X-Forwarded-For')
               ?: $request->get_header('X-Real-IP')
               ?: $_SERVER['REMOTE_ADDR'] ?? ''
         )
      ]));
   }
   /**
    * Get IP class (first 3 octets) for session validation
    * This allows for minor IP changes (common with mobile networks)
    *
    * @param string $ip IP address
    * @return string First 3 octets
    */
   protected function getIPClass(string $ip): string
   {
      $parts = explode('.', $ip);
      return implode('.', array_slice($parts, 0, 3));
   }
   /**
    * Validate session fingerprint against stored value
    *
    * @param int $user_id User ID to validate
    * @param WP_REST_Request $request Current request
    * @return bool True if valid, false if potential hijacking
    */
   protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
   {
      // Only enforce if enabled in config
      if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
         return true;
      }
      $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
      $current = $this->generateSessionFingerprint($request);
      if (empty($stored)) {
         // First request - store fingerprint
         update_user_meta($user_id, BASE . 'session_fingerprint', $current);
         update_user_meta($user_id, BASE . 'session_timestamp', time());
         return true;
      }
      // Compare using timing-safe comparison
      return hash_equals($stored, $current);
   }
   /**
    * Clear session fingerprint (call on logout)
    *
    * @param int $user_id User ID
    * @return void
    */
   protected function clearSessionFingerprint(int $user_id): void
   {
      delete_user_meta($user_id, BASE . 'session_fingerprint');
      delete_user_meta($user_id, BASE . 'session_timestamp');
   }
   /******************************************************************
    *  CSRF PROTECTION
   ******************************************************************/
   /**
    * Generate CSRF token for a user
    *
    * @param int $user_id User ID
    * @return string CSRF token
    */
   protected function generateCSRFToken(int $user_id): string
   {
      $token = wp_generate_password(32, false);
      set_transient(BASE . 'csrf_' . $user_id, $token, HOUR_IN_SECONDS);
      return $token;
   }
   /**
    * Validate CSRF token from request header
    *
    * @param WP_REST_Request $request The REST request
    * @return bool True if valid or not required
    */
   protected function validateCSRFToken(WP_REST_Request $request): bool
   {
      // Only for authenticated requests
      if (!is_user_logged_in()) {
         return true;
      }
      // Only for state-changing operations
      if (in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) {
         return true;
      }
      $user_id = get_current_user_id();
      $token = $request->get_header('X-CSRF-Token');
      $stored = get_transient(BASE . 'csrf_' . $user_id);
      if (empty($stored) || empty($token)) {
         return false;
      }
      return hash_equals($stored, $token);
   }
   /**
    * Get current CSRF token for user (for frontend to use)
    *
    * @param int $user_id User ID
    * @return string|null CSRF token or null
    */
   protected function getCSRFToken(int $user_id): ?string
   {
      $token = get_transient(BASE . 'csrf_' . $user_id);
      if (!$token) {
         $token = $this->generateCSRFToken($user_id);
      }
      return $token;
   }
   /***********************************************************
    * REQUEST SIGNATURE VERIFICATION
   ***********************************************************/
   /**
    * Verify request signature for sensitive operations
    * Adds an additional layer of security for critical endpoints
    *
    * @param WP_REST_Request $request The REST request
    * @param array $sensitive_params Params to include in signature
    * @return bool True if valid signature
    */
   protected function verifyRequestSignature(WP_REST_Request $request, array $sensitive_params): bool
   {
      $signature = $request->get_header('X-Request-Signature');
      if (empty($signature)) {
         return false;
      }
      // Get user's signing key (rotated on each login)
      $user_id = get_current_user_id();
      if (!$user_id) {
         return false;
      }
      $signing_key = get_user_meta($user_id, BASE . 'signing_key', true);
      if (empty($signing_key)) {
         // Generate new key if missing
         $signing_key = wp_generate_password(64, false);
         update_user_meta($user_id, BASE . 'signing_key', $signing_key);
      }
      // Build signature from sensitive params
      ksort($sensitive_params);
      $message = json_encode($sensitive_params);
      $expected = hash_hmac('sha256', $message, $signing_key);
      return hash_equals($expected, $signature);
   }
   /**
    * Rotate signing key (call on login)
    *
    * @param int $user_id User ID
    * @return string New signing key
    */
   protected function rotateSigningKey(int $user_id): string
   {
      $new_key = wp_generate_password(64, false);
      update_user_meta($user_id, BASE . 'signing_key', $new_key);
      return $new_key;
   }
   /**********************************************************
    * AUDIT LOGGING
   **********************************************************/
   /**
    * Log security-relevant events
    *
    * @param string $event Event name
    * @param array $data Additional event data
    * @return void
    */
   protected function auditLog(string $event, array $data = []): void
   {
      // Add standard context
      $context = array_merge($data, [
         'timestamp' => current_time('mysql'),
         'user_id' => get_current_user_id() ?: 0,
         'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
         'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
      ]);
      // Use existing error handler
      try {
         JVB()->error()->log(
            'security_audit',
            $event,
            $context,
            'info'
         );
      } catch (\Exception $e) {
         // Fallback to error_log if handler fails
         error_log("Security Audit: {$event} - " . json_encode($context));
      }
   }
   /***************************************************************
    * SANITIZATION HELPERS
   ***************************************************************/
   /**
    * Sanitize array of IDs
    *
    * @param array $ids Array of IDs
    * @return array Sanitized array of integers
    */
   protected function sanitizeIDs(array $ids): array
   {
      return array_map('absint', array_filter($ids, function($id) {
         return is_numeric($id) && $id > 0;
      }));
   }
   /***************************************************************
    * RESPONSE HELPERS
   ***************************************************************/
   /**
    * Return validation error response
    *
    * @param array $errors Validation errors (field => message)
    * @return WP_REST_Response 422 response with errors
    */
   protected function validationError(array $errors): WP_REST_Response
   {
      return new WP_REST_Response([
         'success' => false,
         'message' => 'Validation failed',
         'errors' => $errors
      ], 422);
   }
   /**
    * Return unauthorized error response
    *
    * @param string $message Error message
    * @return WP_REST_Response 401 response
    */
   protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response
   {
      return new WP_REST_Response([
         'success' => false,
         'message' => $message
      ], 401);
   }
   /**
    * Return forbidden error response
    *
    * @param string $message Error message
    * @return WP_REST_Response 403 response
    */
   protected function forbidden(string $message = 'Forbidden'): WP_REST_Response
   {
      return new WP_REST_Response([
         'success' => false,
         'message' => $message
      ], 403);
   }
   /**
    * Return not found error response
    *
    * @param string $message Error message
    * @return WP_REST_Response 404 response
    */
   protected function notFound(string $message = 'Not found'): WP_REST_Response
   {
      return new WP_REST_Response([
         'success' => false,
         'message' => $message
      ], 404);
   }
   /*****************************************************************
    * CONCURRENT CHECKS
   *****************************************************************/
   /**
    * Prevent concurrent requests for the same operation
    * Useful for preventing double-submissions
    *
    * @param string $operation_key Unique key for this operation
    * @param int $lock_duration Lock duration in seconds
    * @return bool True if lock acquired, false if already locked
    */
   protected function acquireOperationLock(string $operation_key, int $lock_duration = 5): bool
   {
      $lock_key = 'op_lock_' . md5($operation_key);
      // Try to acquire lock
      $locked = get_transient($lock_key);
      if ($locked) {
         return false; // Already locked
      }
      // Set lock
      set_transient($lock_key, true, $lock_duration);
      return true;
   }
   /**
    * Release operation lock
    *
    * @param string $operation_key Unique key for this operation
    * @return void
    */
   protected function releaseOperationLock(string $operation_key): void
   {
      $lock_key = 'op_lock_' . md5($operation_key);
      delete_transient($lock_key);
   }
   protected function verifyTurnstile(string $token): bool
   {
      if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
         return true;
      }
      if (empty($token)) {
         return false;
      }
      return JVB()->connect('cloudflare')->verifyTurnstile($token);
   }
}
//
//Simple example:
@@ -605,3 +1005,37 @@
// $response = new WP_REST_Response($data);
// return $this->addCacheHeaders($response);
//}
/**
 * Use operation lock in your methods like this:
 *
 * public function updateContent(WP_REST_Request $request): WP_REST_Response
 * {
 *     $user_id = get_current_user_id();
 *     $content_id = $request->get_param('content_id');
 *
 *     // Prevent concurrent updates
 *     $lock_key = "update_{$user_id}_{$content_id}";
 *     if (!$this->acquireOperationLock($lock_key)) {
 *         return $this->error(
 *             'An update is already in progress. Please wait.',
 *             'concurrent_operation',
 *             409
 *         );
 *     }
 *
 *     try {
 *         // Do your operation
 *         $result = $this->doUpdate($content_id);
 *
 *         $this->releaseOperationLock($lock_key);
 *         return $this->success($result);
 *
 *     } catch (\Exception $e) {
 *         $this->releaseOperationLock($lock_key);
 *         return $this->error($e->getMessage(), 'operation_failed');
 *     }
 * }
 */