You've already forked wc-licensed-product
- Add recursive key sorting for response signing compatibility - Fix IP header spoofing in rate limiting with trusted proxy support - Add CSRF protection to CSV export with nonce verification - Explicit Twig autoescape for XSS prevention - Escape status values in CSS classes - Update README with security documentation and trusted proxy config - Update translations for v0.3.6 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
426 lines
13 KiB
PHP
426 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* REST API Controller
|
|
*
|
|
* @package Jeremias\WcLicensedProduct\Api
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Jeremias\WcLicensedProduct\Api;
|
|
|
|
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
|
use WP_REST_Request;
|
|
use WP_REST_Response;
|
|
use WP_REST_Server;
|
|
|
|
/**
|
|
* Handles REST API endpoints for license validation
|
|
*/
|
|
final class RestApiController
|
|
{
|
|
private const NAMESPACE = 'wc-licensed-product/v1';
|
|
|
|
/**
|
|
* Rate limit: requests per minute per IP
|
|
*/
|
|
private const RATE_LIMIT_REQUESTS = 30;
|
|
|
|
/**
|
|
* Rate limit window in seconds
|
|
*/
|
|
private const RATE_LIMIT_WINDOW = 60;
|
|
|
|
private LicenseManager $licenseManager;
|
|
|
|
public function __construct(LicenseManager $licenseManager)
|
|
{
|
|
$this->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);
|
|
|
|
$data = get_transient($transientKey);
|
|
|
|
if ($data === false) {
|
|
// First request, start counting
|
|
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
|
|
return null;
|
|
}
|
|
|
|
$count = (int) ($data['count'] ?? 0);
|
|
$start = (int) ($data['start'] ?? time());
|
|
|
|
// Check if window has expired
|
|
if (time() - $start >= self::RATE_LIMIT_WINDOW) {
|
|
// Reset counter
|
|
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
|
|
return null;
|
|
}
|
|
|
|
// Check if limit exceeded
|
|
if ($count >= self::RATE_LIMIT_REQUESTS) {
|
|
$retryAfter = self::RATE_LIMIT_WINDOW - (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], self::RATE_LIMIT_WINDOW);
|
|
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 {
|
|
return !empty($value) && strlen($value) <= 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',
|
|
],
|
|
],
|
|
]);
|
|
|
|
// 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',
|
|
],
|
|
'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 = $result['valid'] ? 200 : 403;
|
|
|
|
return new WP_REST_Response($result, $statusCode);
|
|
}
|
|
|
|
/**
|
|
* 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'),
|
|
]);
|
|
}
|
|
}
|