You've already forked wc-licensed-product
Security Fixes: - Fixed XSS vulnerability in checkout blocks DOM injection (replaced innerHTML with safe DOM methods) - Unified IP detection for rate limiting across all API endpoints (new IpDetectionTrait) - Added rate limiting to license transfers (5/hour) and downloads (30/hour) (new RateLimitTrait) - Added file size limit (2MB), row limit (1000), and rate limiting to CSV import - Added JSON decode error handling in StoreApiExtension - Added license ID validation in frontend.js to prevent selector injection New Files: - src/Api/IpDetectionTrait.php - Shared IP detection with proxy support - src/Common/RateLimitTrait.php - Reusable rate limiting for frontend operations Breaking Changes: - None Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
169 lines
4.7 KiB
PHP
169 lines
4.7 KiB
PHP
<?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);
|
|
}
|
|
}
|