You've already forked wc-licensed-product
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6bfa219a | |||
| 302f2e76ca | |||
| 5938aaed1b | |||
| 630a5859d3 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.1] - 2026-01-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed API Verification Secret not displayed in PHP fallback template on customer account licenses page
|
||||
- Response signing now includes `/update-check` endpoint (was missing from signed routes)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.2
|
||||
- Updated `symfony/http-client` dependency to v7.4.5
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added customer secret display to `displayLicensesFallback()` method in `AccountController`
|
||||
- Added `/update-check` route to `ResponseSigner::shouldSign()` method for consistent signature headers
|
||||
- Verified server implementation aligns with updated client library documentation
|
||||
|
||||
## [0.7.0] - 2026-01-28
|
||||
|
||||
### Security
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -32,14 +32,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
|
||||
|
||||
### Version 0.7.0
|
||||
### Version 0.7.2
|
||||
|
||||
This is a security version. It includes a full security audit and a remote check of a live version of this plugin on <https://shop.magdev.cc>. The shop is the property of the plugin developer, all actions are permitted.
|
||||
|
||||
- Check the sourcecode for best practises of all involved components, including checks for SQLi, XSRF, XSS and similar techniques
|
||||
- Check the remote version for the OWASP Top 10
|
||||
- Check the whole licensing workflow
|
||||
- Minimize the thread vectors
|
||||
No pending features.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -60,6 +55,13 @@ This is a security version. It includes a full security audit and a remote check
|
||||
- Nonce verification on form submissions
|
||||
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
|
||||
- Direct file access prevention via `ABSPATH` check
|
||||
- XSS-safe DOM construction in JavaScript (no `innerHTML` with user data)
|
||||
- Rate limiting on API endpoints (configurable via `WC_LICENSE_RATE_LIMIT`)
|
||||
- Rate limiting on frontend operations (transfers: 5/hour, downloads: 30/hour)
|
||||
- CSV import limits (2MB max, 1000 rows max, 5-minute cooldown)
|
||||
- IP detection with proxy support via `IpDetectionTrait` (supports `WC_LICENSE_TRUSTED_PROXIES`)
|
||||
- SQL injection prevention using `$wpdb->prepare()` throughout
|
||||
- Secure download URLs with hash verification using `hash_equals()`
|
||||
|
||||
### Translation Ready
|
||||
|
||||
@@ -1843,3 +1845,30 @@ Security-focused release with comprehensive audit and hardening. Performed OWASP
|
||||
- Created release package: `releases/wc-licensed-product-0.7.0.zip` (883 KB)
|
||||
- SHA256: `12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18`
|
||||
- Tagged as `v0.7.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-28 - Version 0.7.1 - Bug Fixes & Client Compatibility
|
||||
|
||||
**Overview:**
|
||||
|
||||
Bug fix release ensuring compatibility with updated `magdev/wc-licensed-product-client` v0.2.2 and fixing API Verification Secret display.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- **CRITICAL:** Fixed API Verification Secret not displaying on customer account licenses page when using PHP fallback (Twig unavailable)
|
||||
- Fixed `/update-check` endpoint responses not being signed (missing from `ResponseSigner::shouldSign()`)
|
||||
|
||||
**Dependency Updates:**
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` from `760e1e7` to `56abe8a` (v0.2.2)
|
||||
- Updated `symfony/http-client` from v7.4.4 to v7.4.5
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Frontend/AccountController.php` - Added customer secret display to PHP fallback method `displayLicensesFallback()`
|
||||
- `src/Api/ResponseSigner.php` - Added `/update-check` to `shouldSign()` method
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- PHP fallback template now includes the collapsible API Verification Secret section matching the Twig template
|
||||
- All four API endpoints (`/validate`, `/status`, `/activate`, `/update-check`) now include signature headers when `WC_LICENSE_SERVER_SECRET` is configured
|
||||
- Client library v0.2.2 verified compatible with server implementation
|
||||
|
||||
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
|
||||
|
||||
|
||||
16
composer.lock
generated
16
composer.lock
generated
@@ -12,7 +12,7 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
||||
"reference": "760e1e752a0c088fa634cf7ff678e0735ed525a4"
|
||||
"reference": "56abe8a97c72419c07a6daf263ba6f4a9b5fe4b1"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
@@ -52,7 +52,7 @@
|
||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
||||
},
|
||||
"time": "2026-01-27T19:52:12+00:00"
|
||||
"time": "2026-01-28T10:56:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
@@ -380,16 +380,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v7.4.4",
|
||||
"version": "v7.4.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c"
|
||||
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
|
||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -457,7 +457,7 @@
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.4"
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -477,7 +477,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-23T16:34:22+00:00"
|
||||
"time": "2026-01-27T16:16:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,8 @@ final class ResponseSigner
|
||||
|
||||
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/activate')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/update-check');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -428,6 +428,26 @@ final class AccountController
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if (ResponseSigner::isSigningEnabled() && !empty($license['customer_secret'])): ?>
|
||||
<div class="license-row-secret">
|
||||
<button type="button" class="secret-toggle" aria-expanded="false">
|
||||
<span class="dashicons dashicons-lock"></span>
|
||||
<?php esc_html_e('API Verification Secret', 'wc-licensed-product'); ?>
|
||||
<span class="dashicons dashicons-arrow-down-alt2 toggle-arrow"></span>
|
||||
</button>
|
||||
<div class="secret-content" style="display: none;">
|
||||
<p class="secret-description">
|
||||
<?php esc_html_e('Use this secret to verify signed API responses. Keep it secure.', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
<div class="secret-value-wrapper">
|
||||
<code class="secret-value"><?php echo esc_html($license['customer_secret']); ?></code>
|
||||
<button type="button" class="copy-secret-btn" data-secret="<?php echo esc_attr($license['customer_secret']); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>">
|
||||
<span class="dashicons dashicons-clipboard"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -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.7.0
|
||||
* Version: 0.7.1
|
||||
* 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.7.0');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.7.1');
|
||||
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__));
|
||||
|
||||
Reference in New Issue
Block a user