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__));