You've already forked wc-licensed-product
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6bfa219a | |||
| 302f2e76ca | |||
| 5938aaed1b | |||
| 630a5859d3 | |||
| 36e1fdc20a | |||
| cbece2f279 | |||
| b50969f701 | |||
| d0af939f5e | |||
| c1a337aabe |
43
CHANGELOG.md
43
CHANGELOG.md
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|||||||
94
CLAUDE.md
94
CLAUDE.md
@@ -32,9 +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.
|
**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
|
||||||
|
|
||||||
No changes planned at the moment
|
No pending features.
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
|
|
||||||
@@ -55,6 +55,13 @@ No changes planned at the moment
|
|||||||
- Nonce verification on form submissions
|
- Nonce verification on form submissions
|
||||||
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
|
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
|
||||||
- Direct file access prevention via `ABSPATH` check
|
- 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
|
### Translation Ready
|
||||||
|
|
||||||
@@ -1782,3 +1789,86 @@ Bug fix and improvement release addressing admin license testing, auto-update se
|
|||||||
**Dependency Updates:**
|
**Dependency Updates:**
|
||||||
|
|
||||||
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1
|
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1
|
||||||
|
|
||||||
|
### 2026-01-28 - Version 0.7.0 - Security Hardening
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
|
||||||
|
Security-focused release with comprehensive audit and hardening. Performed OWASP Top 10 testing against live shop (shop.magdev.cc) and fixed identified vulnerabilities.
|
||||||
|
|
||||||
|
**Security Audit Results:**
|
||||||
|
|
||||||
|
- SQL injection: Protected (prepared statements throughout)
|
||||||
|
- CSRF: Protected (nonce verification on all forms/AJAX)
|
||||||
|
- Rate limiting: Working (429 responses after ~28 requests)
|
||||||
|
- Access control: Working (403 for unauthorized access)
|
||||||
|
- HTTPS: Enforced with proper redirect
|
||||||
|
- Missing security headers noted: X-Frame-Options, HSTS, CSP (server-level configuration)
|
||||||
|
|
||||||
|
**Critical Fixes:**
|
||||||
|
|
||||||
|
- **XSS in checkout-blocks.js**: Replaced `innerHTML` template literals with safe DOM construction using `document.createElement()` and `textContent`
|
||||||
|
- **IP Detection**: UpdateController was using raw `$_SERVER['REMOTE_ADDR']` without proxy support - now uses shared `IpDetectionTrait`
|
||||||
|
|
||||||
|
**New Files:**
|
||||||
|
|
||||||
|
- `src/Api/IpDetectionTrait.php` - Shared IP detection with proxy header support (Cloudflare, X-Forwarded-For, X-Real-IP)
|
||||||
|
- `src/Common/RateLimitTrait.php` - Reusable rate limiting for frontend operations
|
||||||
|
|
||||||
|
**Security Enhancements:**
|
||||||
|
|
||||||
|
- Added rate limiting to license transfers (5/hour per user)
|
||||||
|
- Added rate limiting to file downloads (30/hour per user)
|
||||||
|
- Added CSV import limits: 2MB max file size, 1000 max rows, 5-minute cooldown
|
||||||
|
- Added JSON error handling in StoreApiExtension
|
||||||
|
- Added license ID validation in frontend.js to prevent selector injection
|
||||||
|
|
||||||
|
**Modified Files:**
|
||||||
|
|
||||||
|
- `assets/js/checkout-blocks.js` - XSS-safe DOM construction
|
||||||
|
- `assets/js/frontend.js` - Added `sanitizeForSelector()` helper
|
||||||
|
- `src/Api/RestApiController.php` - Use IpDetectionTrait, remove duplicate methods
|
||||||
|
- `src/Api/UpdateController.php` - Use IpDetectionTrait for rate limiting
|
||||||
|
- `src/Admin/AdminController.php` - CSV import security limits
|
||||||
|
- `src/Frontend/AccountController.php` - Transfer rate limiting
|
||||||
|
- `src/Frontend/DownloadController.php` - Download rate limiting
|
||||||
|
- `src/Checkout/StoreApiExtension.php` - JSON error handling
|
||||||
|
|
||||||
|
**Technical Notes:**
|
||||||
|
|
||||||
|
- IpDetectionTrait supports `WC_LICENSE_TRUSTED_PROXIES` constant for proxy configuration
|
||||||
|
- RateLimitTrait uses WordPress transients with user ID-based keys
|
||||||
|
- CSV import constants: `MAX_IMPORT_FILE_SIZE = 2097152`, `MAX_IMPORT_ROWS = 1000`, `IMPORT_RATE_LIMIT_WINDOW = 300`
|
||||||
|
|
||||||
|
**Release v0.7.0:**
|
||||||
|
|
||||||
|
- 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
|
- **Version Binding**: Optional binding to major software versions
|
||||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||||
- **Rate Limiting**: API endpoints protected with configurable rate limiting (default: 30 requests/minute)
|
- **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
|
- **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+)
|
- **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)
|
- **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)
|
3. Upload a CSV file (supports exported format or simplified format)
|
||||||
4. Choose options: skip header row, update existing licenses
|
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
|
## Security
|
||||||
|
|
||||||
The plugin implements several security best practices:
|
The plugin implements several security best practices:
|
||||||
|
|
||||||
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
||||||
- **Output Escaping**: All output is escaped to prevent XSS attacks
|
- **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
|
- **CSRF Protection**: Nonce verification on all forms and AJAX requests
|
||||||
- **SQL Injection Prevention**: All database queries use prepared statements
|
- **SQL Injection Prevention**: All database queries use prepared statements
|
||||||
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
||||||
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
||||||
- **Response Signing**: Optional HMAC-SHA256 signatures for API tamper protection
|
- **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
|
### Trusted Proxy Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -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;';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||||
container.innerHTML = `
|
// Build header safely using DOM methods
|
||||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
|
var header = createEl('h4', settings.sectionTitle || 'License Domains', 'margin: 0 0 8px 0;');
|
||||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
container.appendChild(header);
|
||||||
${settings.fieldDescription || 'Enter a unique domain for each license.'}
|
|
||||||
</p>
|
var desc = createEl('p', settings.fieldDescription || 'Enter a unique domain for each license.',
|
||||||
${settings.licensedProducts.map(product => {
|
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||||
const productKey = product.variation_id && product.variation_id > 0
|
container.appendChild(desc);
|
||||||
? `${product.product_id}_${product.variation_id}`
|
|
||||||
: product.product_id;
|
// Build product sections
|
||||||
const durationLabel = product.duration_label || '';
|
settings.licensedProducts.forEach(function(product) {
|
||||||
const displayName = durationLabel
|
var productKey = product.variation_id && product.variation_id > 0
|
||||||
? `${product.name} (${durationLabel})`
|
? product.product_id + '_' + product.variation_id
|
||||||
|
: String(product.product_id);
|
||||||
|
var durationLabel = product.duration_label || '';
|
||||||
|
var displayName = durationLabel
|
||||||
|
? product.name + ' (' + durationLabel + ')'
|
||||||
: product.name;
|
: product.name;
|
||||||
|
|
||||||
return `
|
var productDiv = createEl('div', null, 'margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;');
|
||||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
|
||||||
<strong style="display: block; margin-bottom: 8px;">
|
var nameEl = createEl('strong', displayName + (product.quantity > 1 ? ' ×' + product.quantity : ''),
|
||||||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
'display: block; margin-bottom: 8px;');
|
||||||
</strong>
|
productDiv.appendChild(nameEl);
|
||||||
${Array.from({ length: product.quantity }, (_, i) => `
|
|
||||||
<div style="margin-bottom: 8px;">
|
// Create input fields for each quantity
|
||||||
<label style="display: block; margin-bottom: 4px;">
|
for (var i = 0; i < product.quantity; i++) {
|
||||||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
|
||||||
</label>
|
|
||||||
<input type="text"
|
var label = createEl('label', (settings.licenseLabel || 'License %d:').replace('%d', i + 1),
|
||||||
name="licensed_domains[${productKey}][${i}]"
|
'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');
|
||||||
${product.variation_id && product.variation_id > 0 ? `
|
input.type = 'text';
|
||||||
<input type="hidden"
|
input.name = 'licensed_domains[' + productKey + '][' + i + ']';
|
||||||
name="licensed_variation_ids[${productKey}]"
|
input.placeholder = settings.fieldPlaceholder || 'example.com';
|
||||||
value="${product.variation_id}"
|
input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;';
|
||||||
/>
|
fieldDiv.appendChild(input);
|
||||||
` : ''}
|
|
||||||
</div>
|
// Hidden variation ID if applicable
|
||||||
`).join('')}
|
if (product.variation_id && product.variation_id > 0) {
|
||||||
</div>
|
var hiddenInput = document.createElement('input');
|
||||||
`}).join('')}
|
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
if (safeLicenseId) {
|
||||||
|
var $domainDisplay = $('.license-domain-display[data-license-id="' + safeLicenseId + '"]');
|
||||||
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
||||||
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', 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() {
|
||||||
|
|||||||
16
composer.lock
generated
16
composer.lock
generated
@@ -12,7 +12,7 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
||||||
"reference": "760e1e752a0c088fa634cf7ff678e0735ed525a4"
|
"reference": "56abe8a97c72419c07a6daf263ba6f4a9b5fe4b1"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
"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",
|
"name": "psr/cache",
|
||||||
@@ -380,16 +380,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client",
|
"name": "symfony/http-client",
|
||||||
"version": "v7.4.4",
|
"version": "v7.4.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-client.git",
|
"url": "https://github.com/symfony/http-client.git",
|
||||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c"
|
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
|
"url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c",
|
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
"http"
|
"http"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-client/tree/v7.4.4"
|
"source": "https://github.com/symfony/http-client/tree/v7.4.5"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-01-23T16:34:22+00:00"
|
"time": "2026-01-27T16:16:02+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client-contracts",
|
"name": "symfony/http-client-contracts",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ This prevents attackers from:
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- PHP 7.4+ (8.0+ recommended)
|
- PHP 8.3+
|
||||||
- A server secret stored securely (not in version control)
|
- A server secret stored securely (not in version control)
|
||||||
|
|
||||||
## Server Configuration
|
## Server Configuration
|
||||||
@@ -51,25 +51,33 @@ php -r "echo bin2hex(random_bytes(32));"
|
|||||||
|
|
||||||
### Key Derivation
|
### 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
|
```php
|
||||||
/**
|
/**
|
||||||
* Derive a unique signing key for a license.
|
* Derive a unique signing key for a license.
|
||||||
*
|
*
|
||||||
* @param string $licenseKey The license key
|
* Uses PHP's native hash_hkdf() function per RFC 5869.
|
||||||
* @param string $serverSecret The server's master secret
|
*
|
||||||
* @return string The derived key (hex encoded)
|
* @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
|
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||||
{
|
{
|
||||||
// HKDF-like key derivation
|
// HKDF key derivation per RFC 5869
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
// IKM: server_secret, Length: 32 bytes, Info: license_key
|
||||||
|
return bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey));
|
||||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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
|
### Response Signing
|
||||||
|
|
||||||
Sign every API response before sending:
|
Sign every API response before sending:
|
||||||
@@ -88,8 +96,8 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||||
|
|
||||||
// Sort keys for consistent ordering
|
// Recursively sort keys for consistent ordering (important for nested arrays!)
|
||||||
ksort($responseData);
|
$responseData = recursive_key_sort($responseData);
|
||||||
|
|
||||||
// Build signature payload
|
// Build signature payload
|
||||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$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,
|
'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
|
### WordPress REST API Integration
|
||||||
@@ -214,7 +236,7 @@ class ResponseSigner
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = $this->deriveKey($licenseKey);
|
$signingKey = $this->deriveKey($licenseKey);
|
||||||
|
|
||||||
ksort($data);
|
$data = $this->recursiveKeySort($data);
|
||||||
$payload = $timestamp . ':' . json_encode(
|
$payload = $timestamp . ':' . json_encode(
|
||||||
$data,
|
$data,
|
||||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
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
|
private function deriveKey(string $licenseKey): string
|
||||||
{
|
{
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
// HKDF key derivation per RFC 5869
|
||||||
|
return bin2hex(hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey));
|
||||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +294,8 @@ signature = HMAC-SHA256(
|
|||||||
|
|
||||||
Where:
|
Where:
|
||||||
|
|
||||||
- `derive_signing_key` uses HKDF-like derivation (see above)
|
- `derive_signing_key` uses RFC 5869 HKDF: `hash_hkdf('sha256', server_secret, 32, license_key)`
|
||||||
- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode
|
- `canonical_json` recursively sorts keys alphabetically, no escaping of slashes/unicode
|
||||||
- Result is hex-encoded (64 characters)
|
- Result is hex-encoded (64 characters)
|
||||||
|
|
||||||
## Testing
|
## 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
BIN
releases/wc-licensed-product-0.7.0.zip
Normal file
BIN
releases/wc-licensed-product-0.7.0.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.7.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.7.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
|||||||
|
12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18 wc-licensed-product-0.7.0.zip
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
168
src/Api/IpDetectionTrait.php
Normal file
168
src/Api/IpDetectionTrait.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,8 @@ final class ResponseSigner
|
|||||||
|
|
||||||
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
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/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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/Common/RateLimitTrait.php
Normal file
91
src/Common/RateLimitTrait.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -425,6 +428,26 @@ final class AccountController
|
|||||||
?>
|
?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,6 +598,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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.1
|
||||||
* 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.1');
|
||||||
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__));
|
||||||
|
|||||||
Reference in New Issue
Block a user