Files
wc-licensed-product/src/Api/IpDetectionTrait.php
magdev b50969f701 Release v0.7.0 - Security Hardening
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>
2026-01-28 11:27:08 +01:00

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);
}
}