licenseManager = $licenseManager; $this->registerHooks(); } /** * Register WordPress hooks */ private function registerHooks(): void { add_action('rest_api_init', [$this, 'registerRoutes']); } /** * Check rate limit for current IP * * @return WP_REST_Response|null Returns error response if rate limited, null if OK */ private function checkRateLimit(): ?WP_REST_Response { $ip = $this->getClientIp(); $transientKey = 'wclp_rate_' . md5($ip); $rateLimit = $this->getRateLimit(); $rateWindow = $this->getRateWindow(); $data = get_transient($transientKey); if ($data === false) { // First request, start counting set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow); return null; } $count = (int) ($data['count'] ?? 0); $start = (int) ($data['start'] ?? time()); // Check if window has expired if (time() - $start >= $rateWindow) { // Reset counter set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow); return null; } // Check if limit exceeded if ($count >= $rateLimit) { $retryAfter = $rateWindow - (time() - $start); $response = new WP_REST_Response([ 'success' => false, 'error' => 'rate_limit_exceeded', 'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'), 'retry_after' => $retryAfter, ], 429); $response->header('Retry-After', (string) $retryAfter); return $response; } // Increment counter set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow); return null; } /** * Get client IP address * * Security note: Only trust proxy headers when explicitly configured. * Set WC_LICENSE_TRUSTED_PROXIES constant or configure trusted_proxies * in wp-config.php to enable proxy header support. * * @return string Client IP address */ private function getClientIp(): string { // Get the direct connection IP first $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; // Only check proxy headers if we're behind a trusted proxy if ($this->isTrustedProxy($remoteAddr)) { // Check headers in order of trust preference $headers = [ 'HTTP_CF_CONNECTING_IP', // Cloudflare 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', ]; foreach ($headers as $header) { if (!empty($_SERVER[$header])) { $ips = explode(',', $_SERVER[$header]); $ip = trim($ips[0]); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return $ip; } } } } // Validate and return direct connection IP if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) { return $remoteAddr; } return '0.0.0.0'; } /** * Check if the given IP is a trusted proxy * * @param string $ip The IP address to check * @return bool Whether the IP is a trusted proxy */ private function isTrustedProxy(string $ip): bool { // Check if trusted proxies are configured if (!defined('WC_LICENSE_TRUSTED_PROXIES')) { return false; } $trustedProxies = WC_LICENSE_TRUSTED_PROXIES; // Handle string constant (comma-separated list) if (is_string($trustedProxies)) { $trustedProxies = array_map('trim', explode(',', $trustedProxies)); } if (!is_array($trustedProxies)) { return false; } // Check for special keywords if (in_array('CLOUDFLARE', $trustedProxies, true)) { // Cloudflare IP ranges (simplified - in production, fetch from Cloudflare API) if ($this->isCloudflareIp($ip)) { return true; } } // Check direct IP match or CIDR notation foreach ($trustedProxies as $proxy) { if ($proxy === $ip) { return true; } // Support CIDR notation if (str_contains($proxy, '/') && $this->ipMatchesCidr($ip, $proxy)) { return true; } } return false; } /** * Check if IP is in Cloudflare range * * @param string $ip The IP to check * @return bool Whether IP belongs to Cloudflare */ private function isCloudflareIp(string $ip): bool { // Cloudflare IPv4 ranges (as of 2024) $cloudflareRanges = [ '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', ]; foreach ($cloudflareRanges as $range) { if ($this->ipMatchesCidr($ip, $range)) { return true; } } return false; } /** * Check if an IP matches a CIDR range * * @param string $ip The IP to check * @param string $cidr The CIDR range (e.g., "192.168.1.0/24") * @return bool Whether the IP matches the CIDR range */ private function ipMatchesCidr(string $ip, string $cidr): bool { [$subnet, $bits] = explode('/', $cidr); if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || !filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return false; } $ipLong = ip2long($ip); $subnetLong = ip2long($subnet); $mask = -1 << (32 - (int) $bits); return ($ipLong & $mask) === ($subnetLong & $mask); } /** * Register REST API routes */ public function registerRoutes(): void { // Validate license endpoint (public) register_rest_route(self::NAMESPACE, '/validate', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'validateLicense'], 'permission_callback' => '__return_true', 'args' => [ 'license_key' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ($value): bool { $len = strlen($value); return !empty($value) && $len >= 8 && $len <= 64; }, ], 'domain' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ($value): bool { return !empty($value) && strlen($value) <= 255; }, ], ], ]); // Check license status endpoint (public) register_rest_route(self::NAMESPACE, '/status', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'checkStatus'], 'permission_callback' => '__return_true', 'args' => [ 'license_key' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ($value): bool { $len = strlen($value); return !empty($value) && $len >= 8 && $len <= 64; }, ], ], ]); // Activate license on domain endpoint (public) register_rest_route(self::NAMESPACE, '/activate', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'activateLicense'], 'permission_callback' => '__return_true', 'args' => [ 'license_key' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ($value): bool { $len = strlen($value); return !empty($value) && $len >= 8 && $len <= 64; }, ], 'domain' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ], ]); } /** * Validate license endpoint */ public function validateLicense(WP_REST_Request $request): WP_REST_Response { $rateLimitResponse = $this->checkRateLimit(); if ($rateLimitResponse !== null) { return $rateLimitResponse; } $licenseKey = $request->get_param('license_key'); $domain = $request->get_param('domain'); $result = $this->licenseManager->validateLicense($licenseKey, $domain); $statusCode = $this->getStatusCodeForResult($result); return new WP_REST_Response($result, $statusCode); } /** * Get HTTP status code based on validation result * * @param array $result The validation result * @return int HTTP status code */ private function getStatusCodeForResult(array $result): int { if ($result['valid']) { return 200; } $error = $result['error'] ?? ''; return match ($error) { 'license_not_found' => 404, 'activation_failed' => 500, default => 403, }; } /** * Check license status endpoint */ public function checkStatus(WP_REST_Request $request): WP_REST_Response { $rateLimitResponse = $this->checkRateLimit(); if ($rateLimitResponse !== null) { return $rateLimitResponse; } $licenseKey = $request->get_param('license_key'); $license = $this->licenseManager->getLicenseByKey($licenseKey); if (!$license) { return new WP_REST_Response([ 'valid' => false, 'error' => 'license_not_found', 'message' => __('License key not found.', 'wc-licensed-product'), ], 404); } return new WP_REST_Response([ 'valid' => $license->isValid(), 'status' => $license->getStatus(), 'domain' => $license->getDomain(), 'expires_at' => $license->getExpiresAt()?->format('Y-m-d'), 'activations_count' => $license->getActivationsCount(), 'max_activations' => $license->getMaxActivations(), ]); } /** * Activate license on domain endpoint */ public function activateLicense(WP_REST_Request $request): WP_REST_Response { $rateLimitResponse = $this->checkRateLimit(); if ($rateLimitResponse !== null) { return $rateLimitResponse; } $licenseKey = $request->get_param('license_key'); $domain = $request->get_param('domain'); $license = $this->licenseManager->getLicenseByKey($licenseKey); if (!$license) { return new WP_REST_Response([ 'success' => false, 'error' => 'license_not_found', 'message' => __('License key not found.', 'wc-licensed-product'), ], 404); } if (!$license->isValid()) { return new WP_REST_Response([ 'success' => false, 'error' => 'license_invalid', 'message' => __('This license is not valid.', 'wc-licensed-product'), ], 403); } $normalizedDomain = $this->licenseManager->normalizeDomain($domain); // Check if already activated on this domain if ($license->getDomain() === $normalizedDomain) { return new WP_REST_Response([ 'success' => true, 'message' => __('License is already activated for this domain.', 'wc-licensed-product'), ]); } // Check if can activate on another domain if (!$license->canActivate()) { return new WP_REST_Response([ 'success' => false, 'error' => 'max_activations_reached', 'message' => __('Maximum number of activations reached.', 'wc-licensed-product'), ], 403); } // Update domain (in this simple implementation, we replace the domain) $success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain); if (!$success) { return new WP_REST_Response([ 'success' => false, 'error' => 'activation_failed', 'message' => __('Failed to activate license.', 'wc-licensed-product'), ], 500); } return new WP_REST_Response([ 'success' => true, 'message' => __('License activated successfully.', 'wc-licensed-product'), ]); } }