From b50969f7014d95f6b566c9fcb6503d891edb34df Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 28 Jan 2026 11:27:08 +0100 Subject: [PATCH] Release v0.7.0 - Security Hardening Security Fixes: - Fixed XSS vulnerability in checkout blocks DOM injection (replaced innerHTML with safe DOM methods) - Unified IP detection for rate limiting across all API endpoints (new IpDetectionTrait) - Added rate limiting to license transfers (5/hour) and downloads (30/hour) (new RateLimitTrait) - Added file size limit (2MB), row limit (1000), and rate limiting to CSV import - Added JSON decode error handling in StoreApiExtension - Added license ID validation in frontend.js to prevent selector injection New Files: - src/Api/IpDetectionTrait.php - Shared IP detection with proxy support - src/Common/RateLimitTrait.php - Reusable rate limiting for frontend operations Breaking Changes: - None Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 25 +++++ assets/js/checkout-blocks.js | 138 +++++++++++++---------- assets/js/frontend.js | 22 +++- src/Admin/AdminController.php | 80 +++++++++++++ src/Api/IpDetectionTrait.php | 168 ++++++++++++++++++++++++++++ src/Api/RestApiController.php | 149 +----------------------- src/Api/UpdateController.php | 3 +- src/Checkout/StoreApiExtension.php | 7 +- src/Common/RateLimitTrait.php | 91 +++++++++++++++ src/Frontend/AccountController.php | 12 ++ src/Frontend/DownloadController.php | 12 ++ wc-licensed-product.php | 4 +- 12 files changed, 500 insertions(+), 211 deletions(-) create mode 100644 src/Api/IpDetectionTrait.php create mode 100644 src/Common/RateLimitTrait.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e287fd..c562725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-01-28 + +### Security + +- Fixed XSS vulnerability in checkout blocks DOM fallback injection +- Unified IP detection for rate limiting across all REST API endpoints +- Added rate limiting to license transfers (5 per hour) and downloads (30 per hour) +- Added file size (2MB), row count (1000), and rate limiting to CSV import +- Added JSON decode error handling in Store API extension +- Added jQuery selector sanitization for license ID validation + +### Added + +- New `IpDetectionTrait` for shared IP detection logic with proxy support +- New `RateLimitTrait` for reusable frontend rate limiting +- New `src/Common/` directory for shared traits + +### Changed + +- RestApiController now uses IpDetectionTrait instead of inline methods +- UpdateController now uses IpDetectionTrait for consistent rate limiting behind proxies +- AccountController now uses RateLimitTrait for transfer rate limiting +- DownloadController now uses RateLimitTrait for download rate limiting +- Checkout blocks fallback uses safe DOM construction instead of innerHTML + ## [0.6.1] - 2026-01-27 ### Added diff --git a/assets/js/checkout-blocks.js b/assets/js/checkout-blocks.js index 9ba63eb..2917b1f 100644 --- a/assets/js/checkout-blocks.js +++ b/assets/js/checkout-blocks.js @@ -367,64 +367,90 @@ container.className = 'wc-block-components-licensed-product-wrapper'; container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;'; - if (settings.isMultiDomainEnabled && settings.licensedProducts) { - container.innerHTML = ` -

${settings.sectionTitle || 'License Domains'}

-

- ${settings.fieldDescription || 'Enter a unique domain for each license.'} -

- ${settings.licensedProducts.map(product => { - const productKey = product.variation_id && product.variation_id > 0 - ? `${product.product_id}_${product.variation_id}` - : product.product_id; - const durationLabel = product.duration_label || ''; - const displayName = durationLabel - ? `${product.name} (${durationLabel})` - : product.name; + // Helper function to create elements with text content (XSS-safe) + function createEl(tag, textContent, styles) { + var el = document.createElement(tag); + if (textContent) el.textContent = textContent; + if (styles) el.style.cssText = styles; + return el; + } - return ` -
- - ${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''} - - ${Array.from({ length: product.quantity }, (_, i) => ` -
- - - ${product.variation_id && product.variation_id > 0 ? ` - - ` : ''} -
- `).join('')} -
- `}).join('')} - `; + if (settings.isMultiDomainEnabled && settings.licensedProducts) { + // Build header safely using DOM methods + var header = createEl('h4', settings.sectionTitle || 'License Domains', 'margin: 0 0 8px 0;'); + container.appendChild(header); + + var desc = createEl('p', settings.fieldDescription || 'Enter a unique domain for each license.', + 'margin-bottom: 12px; color: #666; font-size: 0.9em;'); + container.appendChild(desc); + + // Build product sections + settings.licensedProducts.forEach(function(product) { + var productKey = product.variation_id && product.variation_id > 0 + ? product.product_id + '_' + product.variation_id + : String(product.product_id); + var durationLabel = product.duration_label || ''; + var displayName = durationLabel + ? product.name + ' (' + durationLabel + ')' + : product.name; + + var productDiv = createEl('div', null, 'margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;'); + + var nameEl = createEl('strong', displayName + (product.quantity > 1 ? ' ×' + product.quantity : ''), + 'display: block; margin-bottom: 8px;'); + productDiv.appendChild(nameEl); + + // Create input fields for each quantity + for (var i = 0; i < product.quantity; i++) { + var fieldDiv = createEl('div', null, 'margin-bottom: 8px;'); + + var label = createEl('label', (settings.licenseLabel || 'License %d:').replace('%d', i + 1), + 'display: block; margin-bottom: 4px;'); + fieldDiv.appendChild(label); + + var input = document.createElement('input'); + input.type = 'text'; + input.name = 'licensed_domains[' + productKey + '][' + i + ']'; + input.placeholder = settings.fieldPlaceholder || 'example.com'; + input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;'; + fieldDiv.appendChild(input); + + // Hidden variation ID if applicable + if (product.variation_id && product.variation_id > 0) { + var hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'licensed_variation_ids[' + productKey + ']'; + hiddenInput.value = String(product.variation_id); + fieldDiv.appendChild(hiddenInput); + } + + productDiv.appendChild(fieldDiv); + } + + container.appendChild(productDiv); + }); } else { - container.innerHTML = ` -

${settings.sectionTitle || 'License Domain'}

-

- ${settings.fieldDescription || 'Enter the domain where you will use the license.'} -

-
- - -
- `; + // Single domain mode - build safely using DOM methods + var header = createEl('h4', settings.sectionTitle || 'License Domain', 'margin: 0 0 8px 0;'); + container.appendChild(header); + + var desc = createEl('p', settings.fieldDescription || 'Enter the domain where you will use the license.', + 'margin-bottom: 12px; color: #666; font-size: 0.9em;'); + container.appendChild(desc); + + var fieldDiv = createEl('div', null, 'margin-bottom: 8px;'); + + var label = createEl('label', settings.singleDomainLabel || 'Domain', 'display: block; margin-bottom: 4px;'); + fieldDiv.appendChild(label); + + var input = document.createElement('input'); + input.type = 'text'; + input.name = 'licensed_product_domain'; + input.placeholder = settings.fieldPlaceholder || 'example.com'; + input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;'; + fieldDiv.appendChild(input); + + container.appendChild(fieldDiv); } if (contactInfo) { diff --git a/assets/js/frontend.js b/assets/js/frontend.js index 4d0f3bf..c2ded03 100644 --- a/assets/js/frontend.js +++ b/assets/js/frontend.js @@ -11,6 +11,14 @@ $modal: null, $form: null, + /** + * Sanitize a value for safe use in jQuery selectors + * License IDs should be numeric only + */ + sanitizeForSelector: function(value) { + return String(value).replace(/[^\d]/g, ''); + }, + init: function() { this.$modal = $('#wclp-transfer-modal'); this.$form = $('#wclp-transfer-form'); @@ -171,6 +179,11 @@ var licenseId = $btn.data('license-id'); var currentDomain = $btn.data('current-domain'); + // Validate license ID is numeric + if (!licenseId || !/^\d+$/.test(String(licenseId))) { + return; + } + $('#transfer-license-id').val(licenseId); $('#transfer-current-domain').text(currentDomain); $('#transfer-new-domain').val(''); @@ -235,9 +248,12 @@ .removeClass('error').addClass('success').show(); // Update the domain display in the license card - var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]'); - $domainDisplay.find('.domain-value').text(response.data.new_domain); - $domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain); + var safeLicenseId = self.sanitizeForSelector(licenseId); + if (safeLicenseId) { + var $domainDisplay = $('.license-domain-display[data-license-id="' + safeLicenseId + '"]'); + $domainDisplay.find('.domain-value').text(response.data.new_domain); + $domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain); + } // Close modal after a short delay setTimeout(function() { diff --git a/src/Admin/AdminController.php b/src/Admin/AdminController.php index 7bd6521..c918b4b 100644 --- a/src/Admin/AdminController.php +++ b/src/Admin/AdminController.php @@ -18,6 +18,21 @@ use Twig\Environment; */ final class AdminController { + /** + * Maximum CSV file size in bytes (2MB) + */ + private const MAX_IMPORT_FILE_SIZE = 2 * 1024 * 1024; + + /** + * Maximum rows to import per file + */ + private const MAX_IMPORT_ROWS = 1000; + + /** + * Minimum time between imports in seconds (5 minutes) + */ + private const IMPORT_RATE_LIMIT_WINDOW = 300; + private Environment $twig; private LicenseManager $licenseManager; @@ -653,6 +668,23 @@ final class AdminController exit; } + // Check file size limit + if ($file['size'] > self::MAX_IMPORT_FILE_SIZE) { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=size')); + exit; + } + + // Check rate limit for imports + $lastImport = get_transient('wclp_last_csv_import_' . get_current_user_id()); + if ($lastImport !== false && (time() - $lastImport) < self::IMPORT_RATE_LIMIT_WINDOW) { + $retryAfter = self::IMPORT_RATE_LIMIT_WINDOW - (time() - $lastImport); + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=rate_limit&retry_after=' . $retryAfter)); + exit; + } + + // Set rate limit marker + set_transient('wclp_last_csv_import_' . get_current_user_id(), time(), self::IMPORT_RATE_LIMIT_WINDOW); + // Read the CSV file $handle = fopen($file['tmp_name'], 'r'); if (!$handle) { @@ -679,6 +711,7 @@ final class AdminController $updated = 0; $skipped = 0; $errors = []; + $rowCount = 0; while (($row = fgetcsv($handle)) !== false) { // Skip empty rows @@ -686,6 +719,24 @@ final class AdminController continue; } + // Check row limit + $rowCount++; + if ($rowCount > self::MAX_IMPORT_ROWS) { + fclose($handle); + $this->addNotice( + sprintf( + /* translators: %1$d: max rows, %2$d: imported count, %3$d: updated count */ + __('Import stopped: Maximum of %1$d rows allowed. %2$d imported, %3$d updated.', 'wc-licensed-product'), + self::MAX_IMPORT_ROWS, + $imported, + $updated + ), + 'warning' + ); + wp_redirect(admin_url('admin.php?page=wc-licenses&import_success=partial')); + exit; + } + // Map CSV columns (expected format from export): // ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At // For import we need: License Key (or generate), Product ID, Customer ID, Domain, Status, Max Activations, Expires At @@ -1700,6 +1751,21 @@ final class AdminController case 'read': esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product'); break; + case 'size': + printf( + /* translators: %s: max file size */ + esc_html__('File too large. Maximum size is %s.', 'wc-licensed-product'), + esc_html(size_format(self::MAX_IMPORT_FILE_SIZE)) + ); + break; + case 'rate_limit': + $retryAfter = isset($_GET['retry_after']) ? absint($_GET['retry_after']) : self::IMPORT_RATE_LIMIT_WINDOW; + printf( + /* translators: %d: seconds to wait */ + esc_html__('Please wait %d seconds before importing again.', 'wc-licensed-product'), + $retryAfter + ); + break; default: esc_html_e('An error occurred during import.', 'wc-licensed-product'); } @@ -1708,6 +1774,20 @@ final class AdminController +
+

+ +

+
+

diff --git a/src/Api/IpDetectionTrait.php b/src/Api/IpDetectionTrait.php new file mode 100644 index 0000000..4709509 --- /dev/null +++ b/src/Api/IpDetectionTrait.php @@ -0,0 +1,168 @@ +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 + */ + protected 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)) { + 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 + */ + protected 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 + */ + protected 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); + } +} diff --git a/src/Api/RestApiController.php b/src/Api/RestApiController.php index 9b6c1ff..2c8132a 100644 --- a/src/Api/RestApiController.php +++ b/src/Api/RestApiController.php @@ -19,6 +19,7 @@ use WP_REST_Server; */ final class RestApiController { + use IpDetectionTrait; private const NAMESPACE = 'wc-licensed-product/v1'; /** @@ -115,154 +116,6 @@ final class RestApiController 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 */ diff --git a/src/Api/UpdateController.php b/src/Api/UpdateController.php index 0674edd..9404ea2 100644 --- a/src/Api/UpdateController.php +++ b/src/Api/UpdateController.php @@ -26,6 +26,7 @@ use WP_REST_Server; */ final class UpdateController { + use IpDetectionTrait; private const NAMESPACE = 'wc-licensed-product/v1'; /** @@ -83,7 +84,7 @@ final class UpdateController */ private function checkRateLimit(): ?WP_REST_Response { - $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + $ip = $this->getClientIp(); $transientKey = 'wclp_update_rate_' . md5($ip); $rateLimit = $this->getRateLimit(); $rateWindow = $this->getRateWindow(); diff --git a/src/Checkout/StoreApiExtension.php b/src/Checkout/StoreApiExtension.php index a43013e..70ad85c 100644 --- a/src/Checkout/StoreApiExtension.php +++ b/src/Checkout/StoreApiExtension.php @@ -200,6 +200,11 @@ final class StoreApiExtension { $requestData = json_decode(file_get_contents('php://input'), true); + // Handle JSON decode errors gracefully + if (json_last_error() !== JSON_ERROR_NONE) { + $requestData = null; + } + if (SettingsController::isMultiDomainEnabled()) { $this->processMultiDomainOrder($order, $requestData); } else { @@ -270,7 +275,7 @@ final class StoreApiExtension // Check for wclp_license_domains (from our hidden input - JSON string) if (empty($domainData) && isset($requestData['wclp_license_domains'])) { $parsed = json_decode($requestData['wclp_license_domains'], true); - if (is_array($parsed)) { + if (json_last_error() === JSON_ERROR_NONE && is_array($parsed)) { $domainData = $this->normalizeDomainsData($parsed); } } diff --git a/src/Common/RateLimitTrait.php b/src/Common/RateLimitTrait.php new file mode 100644 index 0000000..9d035b5 --- /dev/null +++ b/src/Common/RateLimitTrait.php @@ -0,0 +1,91 @@ + 0 + ? (string) $userId + : 'ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + + $transientKey = 'wclp_rate_' . $action . '_' . $key; + $data = get_transient($transientKey); + + if ($data === false) { + // First request, start counting + set_transient($transientKey, ['count' => 1, 'start' => time()], $window); + return true; + } + + $count = (int) ($data['count'] ?? 0); + $start = (int) ($data['start'] ?? time()); + + // Check if window has expired + if (time() - $start >= $window) { + // Reset counter + set_transient($transientKey, ['count' => 1, 'start' => time()], $window); + return true; + } + + // Check if limit exceeded + if ($count >= $limit) { + return false; + } + + // Increment counter + set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $window); + return true; + } + + /** + * Get remaining time until rate limit resets + * + * @param string $action Action identifier + * @param int $window Time window in seconds (must match the one used in checkUserRateLimit) + * @return int Seconds until rate limit resets, or 0 if not rate limited + */ + protected function getRateLimitRetryAfter(string $action, int $window): int + { + $userId = get_current_user_id(); + $key = $userId > 0 + ? (string) $userId + : 'ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + + $transientKey = 'wclp_rate_' . $action . '_' . $key; + $data = get_transient($transientKey); + + if ($data === false) { + return 0; + } + + $start = (int) ($data['start'] ?? time()); + + return max(0, $window - (time() - $start)); + } +} diff --git a/src/Frontend/AccountController.php b/src/Frontend/AccountController.php index 0d4aafe..cfa6e2d 100644 --- a/src/Frontend/AccountController.php +++ b/src/Frontend/AccountController.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace Jeremias\WcLicensedProduct\Frontend; use Jeremias\WcLicensedProduct\Api\ResponseSigner; +use Jeremias\WcLicensedProduct\Common\RateLimitTrait; use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\Product\VersionManager; use Twig\Environment; @@ -19,6 +20,8 @@ use Twig\Environment; */ final class AccountController { + use RateLimitTrait; + private Environment $twig; private LicenseManager $licenseManager; private VersionManager $versionManager; @@ -575,6 +578,15 @@ final class AccountController */ public function handleTransferRequest(): void { + // Rate limit: 5 transfer attempts per hour per user + if (!$this->checkUserRateLimit('transfer', 5, 3600)) { + $retryAfter = $this->getRateLimitRetryAfter('transfer', 3600); + wp_send_json_error([ + 'message' => __('Too many transfer attempts. Please try again later.', 'wc-licensed-product'), + 'retry_after' => $retryAfter, + ], 429); + } + // Verify nonce if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) { wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403); diff --git a/src/Frontend/DownloadController.php b/src/Frontend/DownloadController.php index d662504..a5df2c2 100644 --- a/src/Frontend/DownloadController.php +++ b/src/Frontend/DownloadController.php @@ -9,6 +9,7 @@ declare(strict_types=1); namespace Jeremias\WcLicensedProduct\Frontend; +use Jeremias\WcLicensedProduct\Common\RateLimitTrait; use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\Product\VersionManager; @@ -17,6 +18,8 @@ use Jeremias\WcLicensedProduct\Product\VersionManager; */ final class DownloadController { + use RateLimitTrait; + private LicenseManager $licenseManager; private VersionManager $versionManager; @@ -110,6 +113,15 @@ final class DownloadController exit; } + // Rate limit: 30 downloads per hour per user + if (!$this->checkUserRateLimit('download', 30, 3600)) { + wp_die( + __('Too many download attempts. Please try again later.', 'wc-licensed-product'), + __('Download Error', 'wc-licensed-product'), + ['response' => 429] + ); + } + // Get license $license = $this->licenseManager->getLicenseById($licenseId); if (!$license) { diff --git a/wc-licensed-product.php b/wc-licensed-product.php index 1cbb77d..8b437ad 100644 --- a/wc-licensed-product.php +++ b/wc-licensed-product.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce Licensed Product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Description: WooCommerce plugin to sell software products using license keys with domain-based validation. - * Version: 0.6.1 + * Version: 0.7.0 * Author: Marco Graetsch * Author URI: https://src.bundespruefstelle.ch/magdev * License: GPL-2.0-or-later @@ -28,7 +28,7 @@ if (!defined('ABSPATH')) { } // Plugin constants -define('WC_LICENSED_PRODUCT_VERSION', '0.6.1'); +define('WC_LICENSED_PRODUCT_VERSION', '0.7.0'); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));