You've already forked wc-licensed-product
Update documentation for v0.7.0 security features
README.md: - Added frontend rate limiting info (transfers: 5/hour, downloads: 30/hour) - Added CSV import limits section (2MB, 1000 rows, 5-min cooldown) - Added XSS-safe DOM construction to security section - Added rate limiting and import limits to security best practices docs/server-implementation.md: - Updated PHP requirement to 8.3+ - Fixed key derivation to use RFC 5869 hash_hkdf() (v0.5.5 fix) - Added recursive key sorting for signature generation - Updated signature algorithm documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
10
README.md
10
README.md
@@ -21,6 +21,7 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
- **Version Binding**: Optional binding to major software versions
|
||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||
- **Rate Limiting**: API endpoints protected with configurable rate limiting (default: 30 requests/minute)
|
||||
- **Frontend Rate Limiting**: Transfer requests (5/hour) and downloads (30/hour) protected against abuse
|
||||
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
|
||||
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
|
||||
- **Self-Licensing**: The plugin can validate its own license (for commercial distribution)
|
||||
@@ -132,17 +133,26 @@ When a customer purchases a licensed product, they must enter the domain where t
|
||||
3. Upload a CSV file (supports exported format or simplified format)
|
||||
4. Choose options: skip header row, update existing licenses
|
||||
|
||||
**Import Limits (Security):**
|
||||
|
||||
- Maximum file size: 2MB
|
||||
- Maximum rows per import: 1000
|
||||
- Cooldown between imports: 5 minutes
|
||||
|
||||
## Security
|
||||
|
||||
The plugin implements several security best practices:
|
||||
|
||||
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
||||
- **Output Escaping**: All output is escaped to prevent XSS attacks
|
||||
- **XSS-Safe DOM Construction**: JavaScript uses `createElement()` and `textContent` instead of `innerHTML`
|
||||
- **CSRF Protection**: Nonce verification on all forms and AJAX requests
|
||||
- **SQL Injection Prevention**: All database queries use prepared statements
|
||||
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
||||
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
||||
- **Response Signing**: Optional HMAC-SHA256 signatures for API tamper protection
|
||||
- **Rate Limiting**: API and frontend operations protected against abuse
|
||||
- **Import Limits**: CSV imports limited by file size, row count, and cooldown period
|
||||
|
||||
### Trusted Proxy Configuration
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ This prevents attackers from:
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 7.4+ (8.0+ recommended)
|
||||
- PHP 8.3+
|
||||
- A server secret stored securely (not in version control)
|
||||
|
||||
## Server Configuration
|
||||
@@ -51,25 +51,33 @@ php -r "echo bin2hex(random_bytes(32));"
|
||||
|
||||
### Key Derivation
|
||||
|
||||
Each license key gets a unique signing key derived from the server secret:
|
||||
Each license key gets a unique signing key derived from the server secret using RFC 5869 HKDF:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Derive a unique signing key for a license.
|
||||
*
|
||||
* @param string $licenseKey The license key
|
||||
* @param string $serverSecret The server's master secret
|
||||
* @return string The derived key (hex encoded)
|
||||
* Uses PHP's native hash_hkdf() function per RFC 5869.
|
||||
*
|
||||
* @param string $licenseKey The license key (used as "info" context)
|
||||
* @param string $serverSecret The server's master secret (used as IKM)
|
||||
* @return string The derived key (hex encoded, 64 characters)
|
||||
*/
|
||||
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||
{
|
||||
// HKDF-like key derivation
|
||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
||||
// HKDF key derivation per RFC 5869
|
||||
// IKM: server_secret, Length: 32 bytes, Info: license_key
|
||||
return bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey));
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** This uses PHP's native `hash_hkdf()` function (available since PHP 7.1.2). The parameters are:
|
||||
|
||||
- **Algorithm:** sha256
|
||||
- **IKM (Input Keying Material):** server_secret
|
||||
- **Length:** 32 bytes (256 bits)
|
||||
- **Info:** license_key (context-specific information)
|
||||
|
||||
### Response Signing
|
||||
|
||||
Sign every API response before sending:
|
||||
@@ -88,8 +96,8 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
||||
$timestamp = time();
|
||||
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($responseData);
|
||||
// Recursively sort keys for consistent ordering (important for nested arrays!)
|
||||
$responseData = recursive_key_sort($responseData);
|
||||
|
||||
// Build signature payload
|
||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
@@ -103,6 +111,20 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
||||
'X-License-Timestamp' => (string) $timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys alphabetically.
|
||||
*/
|
||||
function recursive_key_sort(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = recursive_key_sort($value);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
### WordPress REST API Integration
|
||||
@@ -214,7 +236,7 @@ class ResponseSigner
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
ksort($data);
|
||||
$data = $this->recursiveKeySort($data);
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
$data,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
@@ -226,11 +248,21 @@ class ResponseSigner
|
||||
];
|
||||
}
|
||||
|
||||
private function recursiveKeySort(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->recursiveKeySort($value);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function deriveKey(string $licenseKey): string
|
||||
{
|
||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||
// HKDF key derivation per RFC 5869
|
||||
return bin2hex(hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,8 +294,8 @@ signature = HMAC-SHA256(
|
||||
|
||||
Where:
|
||||
|
||||
- `derive_signing_key` uses HKDF-like derivation (see above)
|
||||
- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode
|
||||
- `derive_signing_key` uses RFC 5869 HKDF: `hash_hkdf('sha256', server_secret, 32, license_key)`
|
||||
- `canonical_json` recursively sorts keys alphabetically, no escaping of slashes/unicode
|
||||
- Result is hex-encoded (64 characters)
|
||||
|
||||
## Testing
|
||||
|
||||
Reference in New Issue
Block a user