logger = $logger ?? new NullLogger(); $this->validateBaseUrl($baseUrl, $allowInsecureHttp); } public function validate(string $licenseKey, string $domain): LicenseInfo { $cacheKey = $this->buildCacheKey('validate', $licenseKey, $domain); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); if ($item->isHit()) { $this->logger->debug('License validation cache hit', [ 'domain' => $domain, ]); return $item->get(); } } $this->logger->info('Validating license', [ 'domain' => $domain, ]); $response = $this->request('validate', [ 'license_key' => $licenseKey, 'domain' => $domain, ]); $result = LicenseInfo::fromArray($response['license']); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); $item->set($result); $item->expiresAfter($this->cacheTtl); $this->cache->save($item); } $this->logger->info('License validated successfully', [ 'domain' => $domain, 'product_id' => $result->productId, ]); return $result; } public function status(string $licenseKey): LicenseStatus { $cacheKey = $this->buildCacheKey('status', $licenseKey); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); if ($item->isHit()) { $this->logger->debug('License status cache hit'); return $item->get(); } } $this->logger->info('Checking license status'); $response = $this->request('status', [ 'license_key' => $licenseKey, ]); $result = LicenseStatus::fromArray($response); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); $item->set($result); $item->expiresAfter($this->cacheTtl); $this->cache->save($item); } $this->logger->info('License status retrieved', [ 'status' => $result->status->value, 'valid' => $result->valid, ]); return $result; } public function activate(string $licenseKey, string $domain): ActivationResult { $this->logger->info('Activating license', [ 'domain' => $domain, ]); $response = $this->request('activate', [ 'license_key' => $licenseKey, 'domain' => $domain, ]); $result = ActivationResult::fromArray($response); // Invalidate related cache entries after activation if ($this->cache !== null) { $this->cache->deleteItem($this->buildCacheKey('validate', $licenseKey, $domain)); $this->cache->deleteItem($this->buildCacheKey('status', $licenseKey)); } $this->logger->info('License activation completed', [ 'domain' => $domain, 'success' => $result->success, ]); return $result; } /** * @throws LicenseException */ private function request(string $endpoint, array $payload): array { $url = rtrim($this->baseUrl, '/') . self::API_PATH . '/' . $endpoint; try { $response = $this->httpClient->request('POST', $url, [ 'json' => $payload, 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], ]); $statusCode = $response->getStatusCode(); $data = $response->toArray(false); if ($statusCode >= 400) { $this->logger->warning('License API error response', [ 'endpoint' => $endpoint, 'status_code' => $statusCode, 'error' => $data['error'] ?? 'unknown', ]); throw LicenseException::fromApiResponse($data, $statusCode); } return $data; } catch (LicenseException $e) { throw $e; } catch (\Throwable $e) { // Log full error for debugging but sanitize for user-facing message $this->logger->error('License API request failed', [ 'endpoint' => $endpoint, 'exception_class' => $e::class, 'error_code' => $e->getCode(), ]); throw new LicenseException( 'Failed to communicate with license server', null, 0, $e ); } } private function buildCacheKey(string $operation, string $licenseKey, ?string $domain = null): string { $key = 'wc_license_' . $operation . '_' . hash('sha256', $licenseKey); if ($domain !== null) { $key .= '_' . hash('sha256', $domain); } return $key; } /** * Validate the base URL to prevent SSRF attacks. * * @throws \InvalidArgumentException If the URL is invalid or potentially dangerous */ private function validateBaseUrl(string $url, bool $allowInsecureHttp): void { if ($url === '') { throw new \InvalidArgumentException('Base URL cannot be empty'); } $parsed = parse_url($url); if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { throw new \InvalidArgumentException('Invalid base URL format'); } $scheme = strtolower($parsed['scheme']); $host = strtolower($parsed['host']); // Require HTTPS unless explicitly allowed for localhost if ($scheme !== 'https') { if ($scheme !== 'http') { throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme'); } $isLocalhost = $host === 'localhost' || $host === '127.0.0.1'; if (!$allowInsecureHttp && !$isLocalhost) { throw new \InvalidArgumentException( 'Base URL must use HTTPS for non-localhost hosts. ' . 'Set allowInsecureHttp=true to allow HTTP (not recommended for production).' ); } } // Resolve hostname and check for private IPs $ip = gethostbyname($host); if ($ip !== $host && $this->isPrivateIp($ip)) { throw new \InvalidArgumentException( 'Base URL resolves to a private IP address, which is not allowed' ); } } /** * Check if an IP address is in a private range. */ private function isPrivateIp(string $ip): bool { $ipLong = ip2long($ip); if ($ipLong === false) { return false; } foreach (self::PRIVATE_IP_RANGES as $range) { [$subnet, $bits] = explode('/', $range); $subnetLong = ip2long($subnet); $mask = -1 << (32 - (int) $bits); if (($ipLong & $mask) === ($subnetLong & $mask)) { return true; } } return false; } }