Files
wc-licensed-product-client/src/SecureLicenseClient.php
magdev fa748d61d3 Fix security vulnerabilities identified in audit
- Add JSON encoding error handling in ResponseSignature to prevent silent failures
- Sanitize exception messages to prevent information disclosure
- Fix header normalization to treat empty values as null
- Add SSRF protection with URL validation and private IP blocking
- Replace custom key derivation with RFC 5869 compliant hash_hkdf()
- Add input validation in DTO fromArray() methods
- Add DateTime exception handling in DTOs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:31:13 +01:00

387 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
use Magdev\WcLicensedProductClient\Security\IntegrityException;
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
use Magdev\WcLicensedProductClient\Security\SignatureException;
use Magdev\WcLicensedProductClient\Security\StringEncoder;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Secure license client with response signature verification.
*
* This client extends the basic LicenseClient with security features:
* - Response signature verification (HMAC)
* - Code integrity checking
* - Obfuscated API paths (optional)
*
* IMPORTANT: This client requires the server to sign responses.
* See docs/server-implementation.md for server setup instructions.
*/
final class SecureLicenseClient implements LicenseClientInterface
{
private const CACHE_TTL = 300;
/** @var string[] Private IPv4 ranges (CIDR notation) */
private const PRIVATE_IP_RANGES = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
'169.254.0.0/16',
'0.0.0.0/8',
];
private readonly LoggerInterface $logger;
private readonly StringEncoder $encoder;
private readonly string $apiPath;
// Encoded API path segments (decoded at runtime)
private const ENCODED_API_PATH = 'MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE='; // /wp-json/wc-licensed-product/v1
private const ENCODED_VALIDATE = 'Egs4Oz4HMgE='; // validate
private const ENCODED_STATUS = 'NwgqKAcZ'; // status
private const ENCODED_ACTIVATE = 'Jggxfg4MEws='; // activate
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $baseUrl,
private readonly string $serverSecret,
?LoggerInterface $logger = null,
private readonly ?CacheItemPoolInterface $cache = null,
private readonly int $cacheTtl = self::CACHE_TTL,
private readonly bool $verifyIntegrity = false,
?StringEncoder $encoder = null,
bool $allowInsecureHttp = false,
) {
$this->logger = $logger ?? new NullLogger();
$this->encoder = $encoder ?? new StringEncoder();
$this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH);
$this->validateBaseUrl($baseUrl, $allowInsecureHttp);
if ($this->verifyIntegrity) {
$this->checkIntegrity();
}
}
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]);
$endpoint = $this->encoder->decode(self::ENCODED_VALIDATE);
$response = $this->secureRequest($endpoint, [
'license_key' => $licenseKey,
'domain' => $domain,
], $licenseKey);
$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');
$endpoint = $this->encoder->decode(self::ENCODED_STATUS);
$response = $this->secureRequest($endpoint, [
'license_key' => $licenseKey,
], $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]);
$endpoint = $this->encoder->decode(self::ENCODED_ACTIVATE);
$response = $this->secureRequest($endpoint, [
'license_key' => $licenseKey,
'domain' => $domain,
], $licenseKey);
$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
* @throws SignatureException
*/
private function secureRequest(string $endpoint, array $payload, string $licenseKey): array
{
$url = rtrim($this->baseUrl, '/') . $this->apiPath . '/' . $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);
$headers = $this->normalizeHeaders($response->getHeaders(false));
// Verify response signature
$this->verifySignature($data, $headers, $licenseKey);
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 | SignatureException $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 verifySignature(array $data, array $headers, string $licenseKey): void
{
$signature = ResponseSignature::extractSignature($headers);
$timestamp = ResponseSignature::extractTimestamp($headers);
if ($signature === null || $timestamp === null) {
$this->logger->warning('Response missing signature headers');
throw new SignatureException('Response is not signed by the server');
}
$verifier = ResponseSignature::fromLicenseKey($licenseKey, $this->serverSecret);
if (!$verifier->verify($data, $signature, $timestamp)) {
$this->logger->warning('Response signature verification failed');
throw new SignatureException();
}
$this->logger->debug('Response signature verified');
}
private function normalizeHeaders(array $headers): array
{
$normalized = [];
foreach ($headers as $name => $values) {
// HTTP client returns arrays of values; take the first one
// Empty arrays or empty strings should be treated as missing (null)
if (is_array($values)) {
$value = $values[0] ?? null;
$normalized[$name] = ($value !== '' && $value !== null) ? $value : null;
} else {
$normalized[$name] = ($values !== '' && $values !== null) ? $values : null;
}
}
return $normalized;
}
private function checkIntegrity(): void
{
$criticalFiles = [
'src/SecureLicenseClient.php',
'src/Security/ResponseSignature.php',
'src/Security/SignatureException.php',
];
// In production, these hashes would be embedded
// This is a placeholder showing the mechanism
$hashes = $this->getExpectedHashes();
if (empty($hashes)) {
$this->logger->debug('Integrity check skipped: no hashes configured');
return;
}
$basePath = dirname(__DIR__);
$checker = new IntegrityChecker($hashes, $basePath);
try {
$checker->verify();
$this->logger->debug('Integrity check passed');
} catch (IntegrityException $e) {
$this->logger->critical('Integrity check failed', [
'failures' => $e->failures,
]);
throw $e;
}
}
/**
* Get expected file hashes for integrity checking.
*
* In a production obfuscated build, these would be hardcoded.
* Override this method or inject hashes via configuration.
*
* @return array<string, string>
*/
protected function getExpectedHashes(): array
{
// Placeholder: in production, return actual hashes
return [];
}
private function buildCacheKey(string $operation, string $licenseKey, ?string $domain = null): string
{
$key = 'wc_license_secure_' . $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;
}
}