You've already forked wc-licensed-product
Security improvements and API compatibility fixes (v0.3.6)
- Add recursive key sorting for response signing compatibility - Fix IP header spoofing in rate limiting with trusted proxy support - Add CSRF protection to CSV export with nonce verification - Explicit Twig autoescape for XSS prevention - Escape status values in CSS classes - Update README with security documentation and trusted proxy config - Update translations for v0.3.6 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -572,6 +572,11 @@ final class AdminController
|
||||
*/
|
||||
private function handleCsvExport(): void
|
||||
{
|
||||
// Verify nonce for CSRF protection
|
||||
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'export_licenses_csv')) {
|
||||
wp_die(__('Security check failed.', 'wc-licensed-product'));
|
||||
}
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product'));
|
||||
}
|
||||
@@ -954,7 +959,7 @@ final class AdminController
|
||||
<span class="dashicons dashicons-admin-network"></span>
|
||||
<?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="button">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="button">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
<?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
@@ -1048,6 +1053,12 @@ final class AdminController
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string {
|
||||
return wp_create_nonce('transfer_license');
|
||||
}));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('export_csv_url', function (): string {
|
||||
return wp_nonce_url(
|
||||
admin_url('admin.php?page=wc-licenses&action=export_csv'),
|
||||
'export_licenses_csv'
|
||||
);
|
||||
}));
|
||||
|
||||
try {
|
||||
echo $this->twig->render('admin/licenses.html.twig', [
|
||||
@@ -1187,7 +1198,7 @@ final class AdminController
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline"><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
|
||||
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="page-title-action">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="page-title-action">
|
||||
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||
<?php esc_html_e('Export CSV', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
|
||||
@@ -94,8 +94,8 @@ final class ResponseSigner
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($data);
|
||||
// Recursively sort keys for consistent ordering (required by client implementation)
|
||||
$data = $this->recursiveKeySort($data);
|
||||
|
||||
// Build signature payload
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
@@ -109,6 +109,33 @@ final class ResponseSigner
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys alphabetically
|
||||
*
|
||||
* @param mixed $data The data to sort
|
||||
* @return mixed The sorted data
|
||||
*/
|
||||
private function recursiveKeySort(mixed $data): mixed
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Check if array is associative (has string keys)
|
||||
$isAssociative = array_keys($data) !== range(0, count($data) - 1);
|
||||
|
||||
if ($isAssociative) {
|
||||
ksort($data);
|
||||
}
|
||||
|
||||
// Recursively sort nested arrays
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = $this->recursiveKeySort($value);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique signing key for a license
|
||||
*
|
||||
|
||||
@@ -95,29 +95,152 @@ final class RestApiController
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR',
|
||||
];
|
||||
// Get the direct connection IP first
|
||||
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
// 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
|
||||
*/
|
||||
|
||||
@@ -98,6 +98,7 @@ final class Plugin
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
|
||||
'auto_reload' => true, // Always check for template changes
|
||||
'autoescape' => 'html', // Explicitly enable HTML autoescape for XSS protection
|
||||
]);
|
||||
|
||||
// Add WordPress functions as Twig functions
|
||||
|
||||
Reference in New Issue
Block a user