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:
2026-01-23 21:18:32 +01:00
parent c7967f71ab
commit 35d802c2b8
11 changed files with 669 additions and 392 deletions

View File

@@ -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>

View File

@@ -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
*

View File

@@ -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
*/

View File

@@ -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