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 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 11:27:08 +01:00
parent d0af939f5e
commit b50969f701
12 changed files with 500 additions and 211 deletions

View File

@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.6.1] - 2026-01-27
### Added ### Added

View File

@@ -367,64 +367,90 @@
container.className = 'wc-block-components-licensed-product-wrapper'; container.className = 'wc-block-components-licensed-product-wrapper';
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;'; container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
if (settings.isMultiDomainEnabled && settings.licensedProducts) { // Helper function to create elements with text content (XSS-safe)
container.innerHTML = ` function createEl(tag, textContent, styles) {
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4> var el = document.createElement(tag);
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;"> if (textContent) el.textContent = textContent;
${settings.fieldDescription || 'Enter a unique domain for each license.'} if (styles) el.style.cssText = styles;
</p> return el;
${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;
return ` if (settings.isMultiDomainEnabled && settings.licensedProducts) {
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;"> // Build header safely using DOM methods
<strong style="display: block; margin-bottom: 8px;"> var header = createEl('h4', settings.sectionTitle || 'License Domains', 'margin: 0 0 8px 0;');
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''} container.appendChild(header);
</strong>
${Array.from({ length: product.quantity }, (_, i) => ` var desc = createEl('p', settings.fieldDescription || 'Enter a unique domain for each license.',
<div style="margin-bottom: 8px;"> 'margin-bottom: 12px; color: #666; font-size: 0.9em;');
<label style="display: block; margin-bottom: 4px;"> container.appendChild(desc);
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
</label> // Build product sections
<input type="text" settings.licensedProducts.forEach(function(product) {
name="licensed_domains[${productKey}][${i}]" var productKey = product.variation_id && product.variation_id > 0
placeholder="${settings.fieldPlaceholder || 'example.com'}" ? product.product_id + '_' + product.variation_id
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;" : String(product.product_id);
/> var durationLabel = product.duration_label || '';
${product.variation_id && product.variation_id > 0 ? ` var displayName = durationLabel
<input type="hidden" ? product.name + ' (' + durationLabel + ')'
name="licensed_variation_ids[${productKey}]" : product.name;
value="${product.variation_id}"
/> var productDiv = createEl('div', null, 'margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;');
` : ''}
</div> var nameEl = createEl('strong', displayName + (product.quantity > 1 ? ' ×' + product.quantity : ''),
`).join('')} 'display: block; margin-bottom: 8px;');
</div> productDiv.appendChild(nameEl);
`}).join('')}
`; // 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 { } else {
container.innerHTML = ` // Single domain mode - build safely using DOM methods
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4> var header = createEl('h4', settings.sectionTitle || 'License Domain', 'margin: 0 0 8px 0;');
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;"> container.appendChild(header);
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
</p> var desc = createEl('p', settings.fieldDescription || 'Enter the domain where you will use the license.',
<div style="margin-bottom: 8px;"> 'margin-bottom: 12px; color: #666; font-size: 0.9em;');
<label style="display: block; margin-bottom: 4px;"> container.appendChild(desc);
${settings.singleDomainLabel || 'Domain'}
</label> var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
<input type="text"
name="licensed_product_domain" var label = createEl('label', settings.singleDomainLabel || 'Domain', 'display: block; margin-bottom: 4px;');
placeholder="${settings.fieldPlaceholder || 'example.com'}" fieldDiv.appendChild(label);
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/> var input = document.createElement('input');
</div> 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) { if (contactInfo) {

View File

@@ -11,6 +11,14 @@
$modal: null, $modal: null,
$form: 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() { init: function() {
this.$modal = $('#wclp-transfer-modal'); this.$modal = $('#wclp-transfer-modal');
this.$form = $('#wclp-transfer-form'); this.$form = $('#wclp-transfer-form');
@@ -171,6 +179,11 @@
var licenseId = $btn.data('license-id'); var licenseId = $btn.data('license-id');
var currentDomain = $btn.data('current-domain'); var currentDomain = $btn.data('current-domain');
// Validate license ID is numeric
if (!licenseId || !/^\d+$/.test(String(licenseId))) {
return;
}
$('#transfer-license-id').val(licenseId); $('#transfer-license-id').val(licenseId);
$('#transfer-current-domain').text(currentDomain); $('#transfer-current-domain').text(currentDomain);
$('#transfer-new-domain').val(''); $('#transfer-new-domain').val('');
@@ -235,9 +248,12 @@
.removeClass('error').addClass('success').show(); .removeClass('error').addClass('success').show();
// Update the domain display in the license card // Update the domain display in the license card
var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]'); var safeLicenseId = self.sanitizeForSelector(licenseId);
$domainDisplay.find('.domain-value').text(response.data.new_domain); if (safeLicenseId) {
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain); 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 // Close modal after a short delay
setTimeout(function() { setTimeout(function() {

View File

@@ -18,6 +18,21 @@ use Twig\Environment;
*/ */
final class AdminController 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 Environment $twig;
private LicenseManager $licenseManager; private LicenseManager $licenseManager;
@@ -653,6 +668,23 @@ final class AdminController
exit; 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 // Read the CSV file
$handle = fopen($file['tmp_name'], 'r'); $handle = fopen($file['tmp_name'], 'r');
if (!$handle) { if (!$handle) {
@@ -679,6 +711,7 @@ final class AdminController
$updated = 0; $updated = 0;
$skipped = 0; $skipped = 0;
$errors = []; $errors = [];
$rowCount = 0;
while (($row = fgetcsv($handle)) !== false) { while (($row = fgetcsv($handle)) !== false) {
// Skip empty rows // Skip empty rows
@@ -686,6 +719,24 @@ final class AdminController
continue; 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): // 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 // 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 // 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': case 'read':
esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product'); esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product');
break; 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: default:
esc_html_e('An error occurred during import.', 'wc-licensed-product'); esc_html_e('An error occurred during import.', 'wc-licensed-product');
} }
@@ -1708,6 +1774,20 @@ final class AdminController
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="notice notice-info" style="max-width: 800px;">
<p>
<?php
printf(
/* translators: %1$s: max file size, %2$d: max rows, %3$d: rate limit minutes */
esc_html__('Import limits: Maximum file size %1$s, maximum %2$d rows per import. You can import again after %3$d minutes.', 'wc-licensed-product'),
esc_html(size_format(self::MAX_IMPORT_FILE_SIZE)),
self::MAX_IMPORT_ROWS,
(int) (self::IMPORT_RATE_LIMIT_WINDOW / 60)
);
?>
</p>
</div>
<div class="card" style="max-width: 800px; padding: 20px;"> <div class="card" style="max-width: 800px; padding: 20px;">
<h2><?php esc_html_e('Import Licenses from CSV', 'wc-licensed-product'); ?></h2> <h2><?php esc_html_e('Import Licenses from CSV', 'wc-licensed-product'); ?></h2>

View File

@@ -0,0 +1,168 @@
<?php
/**
* IP Detection Trait
*
* Provides shared IP detection logic for API controllers with proxy support.
*
* @package Jeremias\WcLicensedProduct\Api
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api;
/**
* Trait for detecting client IP addresses with proxy support
*
* Security note: Only trust proxy headers when explicitly configured.
* Set WC_LICENSE_TRUSTED_PROXIES constant in wp-config.php to enable proxy header support.
*
* Examples:
* define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
* define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
*/
trait IpDetectionTrait
{
/**
* Get client IP address with proxy support
*
* @return string Client IP address
*/
protected 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
*/
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);
}
}

View File

@@ -19,6 +19,7 @@ use WP_REST_Server;
*/ */
final class RestApiController final class RestApiController
{ {
use IpDetectionTrait;
private const NAMESPACE = 'wc-licensed-product/v1'; private const NAMESPACE = 'wc-licensed-product/v1';
/** /**
@@ -115,154 +116,6 @@ final class RestApiController
return null; 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 * Register REST API routes
*/ */

View File

@@ -26,6 +26,7 @@ use WP_REST_Server;
*/ */
final class UpdateController final class UpdateController
{ {
use IpDetectionTrait;
private const NAMESPACE = 'wc-licensed-product/v1'; private const NAMESPACE = 'wc-licensed-product/v1';
/** /**
@@ -83,7 +84,7 @@ final class UpdateController
*/ */
private function checkRateLimit(): ?WP_REST_Response private function checkRateLimit(): ?WP_REST_Response
{ {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $ip = $this->getClientIp();
$transientKey = 'wclp_update_rate_' . md5($ip); $transientKey = 'wclp_update_rate_' . md5($ip);
$rateLimit = $this->getRateLimit(); $rateLimit = $this->getRateLimit();
$rateWindow = $this->getRateWindow(); $rateWindow = $this->getRateWindow();

View File

@@ -200,6 +200,11 @@ final class StoreApiExtension
{ {
$requestData = json_decode(file_get_contents('php://input'), true); $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()) { if (SettingsController::isMultiDomainEnabled()) {
$this->processMultiDomainOrder($order, $requestData); $this->processMultiDomainOrder($order, $requestData);
} else { } else {
@@ -270,7 +275,7 @@ final class StoreApiExtension
// Check for wclp_license_domains (from our hidden input - JSON string) // Check for wclp_license_domains (from our hidden input - JSON string)
if (empty($domainData) && isset($requestData['wclp_license_domains'])) { if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
$parsed = json_decode($requestData['wclp_license_domains'], true); $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); $domainData = $this->normalizeDomainsData($parsed);
} }
} }

View File

@@ -0,0 +1,91 @@
<?php
/**
* Rate Limit Trait
*
* Provides rate limiting functionality for frontend operations.
*
* @package Jeremias\WcLicensedProduct\Common
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Common;
/**
* Trait for implementing rate limiting on user actions
*
* Uses WordPress transients for storage. Rate limits are per-user when logged in,
* or per-IP when not logged in.
*/
trait RateLimitTrait
{
/**
* Check rate limit for a user action
*
* @param string $action Action identifier (e.g., 'transfer', 'download')
* @param int $limit Maximum attempts per window
* @param int $window Time window in seconds
* @return bool True if within limit, false if exceeded
*/
protected function checkUserRateLimit(string $action, int $limit, int $window): bool
{
$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) {
// 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));
}
}

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend; namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\Api\ResponseSigner; use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment; use Twig\Environment;
@@ -19,6 +20,8 @@ use Twig\Environment;
*/ */
final class AccountController final class AccountController
{ {
use RateLimitTrait;
private Environment $twig; private Environment $twig;
private LicenseManager $licenseManager; private LicenseManager $licenseManager;
private VersionManager $versionManager; private VersionManager $versionManager;
@@ -575,6 +578,15 @@ final class AccountController
*/ */
public function handleTransferRequest(): void 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 // Verify nonce
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) { if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403); wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend; namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
@@ -17,6 +18,8 @@ use Jeremias\WcLicensedProduct\Product\VersionManager;
*/ */
final class DownloadController final class DownloadController
{ {
use RateLimitTrait;
private LicenseManager $licenseManager; private LicenseManager $licenseManager;
private VersionManager $versionManager; private VersionManager $versionManager;
@@ -110,6 +113,15 @@ final class DownloadController
exit; 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 // Get license
$license = $this->licenseManager->getLicenseById($licenseId); $license = $this->licenseManager->getLicenseById($licenseId);
if (!$license) { if (!$license) {

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-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. * 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: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // 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_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));