mc-cms-namelessmc/core/classes/Misc/HttpUtils.php
tadhgboyle 07d1e05229
Create and implement Cache#fetch (#3662)
* Create and implement `Cache#fetch`

* convert more

* fix

* wip

* wip

* Apply fixes from StyleCI (#3663)

Co-authored-by: StyleCI Bot <bot@styleci.io>

* wip, more conversions

* clear widget cache on setting update

* fix

* convert

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
2025-05-20 18:37:24 -07:00

281 lines
10 KiB
PHP

<?php
use GeoIp2\Database\Reader;
use GeoIp2\Exception\GeoIp2Exception;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Helps with common HTTP related tasks.
*
* @package NamelessMC\Misc
* @author Derkades
* @version 2.0.0
* @license MIT
*/
class HttpUtils
{
private static Reader $_geoIpReader;
/**
* Get the client's true IP address, using proxy headers if necessary.
*
* @return ?string Client IP address, or null if there is no remote address, for example in CLI environment
*/
public static function getRemoteAddress(): ?string
{
if (!self::isTrustedProxy()) {
// Client is not a trusted proxy, we can only trust its actual remote address
return $_SERVER['REMOTE_ADDR'];
}
// Non-standard header sent by Cloudflare that only contains the origin address
// We can trust this to be the real IP address, no real-world setup would
// have an additional proxy in front of CloudFlare.
$cf_connecting_ip = self::getHeader('CF-Connecting-IP');
if ($cf_connecting_ip !== null) {
return $cf_connecting_ip;
}
// Now the more complicated (X-)Forwarded(-For) headers.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#parsing:
// > There may be multiple X-Forwarded-For headers present in a request (per RFC 2616). The IP addresses in
// > these headers must be treated as a single list, starting with the first IP address of the first header
// > and continuing to the last IP address of the last header.
// > It is insufficient to use only one of multiple X-Forwarded-For headers.
//
// Unfortunately, we cannot follow this advice since PHP only seems to return the last header. However, since
// supposedly the addresses should be read from right to left, only using the last header is not insecure, while
// the using the first header would be.
// In case of a weirdly behaving proxy that sends an additional Forwarded header instead of appending to an
// existing one, the worst that would happen is an IP ban affecting the proxy (every user). Under no
// circumstance would a user be able to spoof their address.
$x_forwarded_for = self::getHeader('X-Forwarded-For');
if ($x_forwarded_for !== null) {
$addresses = [];
foreach (explode(',', trim($x_forwarded_for)) as $part) {
$addresses[] = trim($part);
}
return self::firstNonProxyAddress($addresses);
}
$forwarded = self::getHeader('Forwarded');
if ($forwarded !== null) {
$addresses = [];
foreach (explode(',', trim($forwarded)) as $part1) {
// Extract the optional 'for=<address>' bit
foreach (explode(';', trim($part1)) as $part2) {
$part2 = explode('=', $part2);
if (count($part2) != 2) {
die('Invalid Forwarded header');
}
if ($part2[0] === 'for') {
$addresses[] = trim($part2[1]);
break;
}
}
}
if (count($addresses) > 0) {
return self::firstNonProxyAddress($addresses);
}
}
// Non-standard header that only contains the origin address. This header should be tried last, since it does
// not work in the case of multiple proxies where at least two of them set the X-Real-IP header.
$x_real_ip = self::getHeader('X-Real-IP');
if ($x_real_ip !== null) {
return $x_real_ip;
}
return $_SERVER['REMOTE_ADDR'];
}
/**
* Get the protocol used by client's HTTP request, using proxy headers if necessary.
*
* @return string 'http' if HTTP or 'https' if HTTPS. If the protocol is not known, for example when using the CLI, 'http' is always returned.
*/
public static function getProtocol(): string
{
$x_forwarded_proto = self::getHeader('X-Forwarded-Proto');
if ($x_forwarded_proto !== null) {
if ($x_forwarded_proto !== 'http' && $x_forwarded_proto !== 'https') {
die('Invalid X-Forwarded-Proto header, should be "http" or "https" but it is "' . Output::getClean($x_forwarded_proto) . '".');
}
return $x_forwarded_proto;
}
if (isset($_SERVER['HTTPS'])) {
return $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
}
return 'http';
}
/**
* Get port used by client's HTTP request, using proxy headers if necessary.
*
* @return ?int Port number, or null when using the CLI
*/
public static function getPort(): ?int
{
$x_forwarded_port = self::getHeader('X-Forwarded-Port');
if ($x_forwarded_port !== null) {
return (int) $x_forwarded_port;
}
// Some hosts don't set X-Forwarded-Port, but do set X-Forwarded-Proto.
// Assume the default port for https or http is used, in that case.
$x_forwarded_proto = self::getHeader('X-Forwarded-Proto');
if ($x_forwarded_proto !== null) {
if ($x_forwarded_proto === 'https') {
return 443;
} elseif ($x_forwarded_proto === 'http') {
return 80;
}
}
if (isset($_SERVER['SERVER_PORT'])) {
return (int) $_SERVER['SERVER_PORT'];
}
return null;
}
/**
* Determine whether the trusted proxies config option is set to a valid value or not.
*
* @return bool Whether the trusted proxies option is configured or not
*/
public static function isTrustedProxiesConfigured(): bool
{
$config_proxies = Config::get('core.trustedProxies');
$env_proxies = getenv('NAMELESS_TRUSTED_PROXIES');
return ($config_proxies !== false && is_array($config_proxies)) || $env_proxies !== false;
}
/**
* @return array List of trusted proxy networks according to config file and environment
*/
public static function getTrustedProxies(): array
{
$trusted_proxies = [];
// Add trusted proxies from config file
$config_proxies = Config::get('core.trustedProxies');
if ($config_proxies !== false && $config_proxies !== null) {
if (!is_array($config_proxies)) {
die('Trusted proxies should be an array');
}
$trusted_proxies = array_merge($trusted_proxies, $config_proxies);
}
// Add trusted proxies from environment variable (comma-separated string)
$env_proxies = getenv('NAMELESS_TRUSTED_PROXIES');
if ($env_proxies !== false && $env_proxies !== 'none') {
$env_proxies_array = explode(',', $env_proxies);
$trusted_proxies = array_merge($trusted_proxies, $env_proxies_array);
}
return $trusted_proxies;
}
/**
* Checks whether the client making the request is a trusted proxy.
*
* @return bool Whether the client is a trusted proxy or not.
*/
private static function isTrustedProxy(): bool
{
$trusted_proxies = self::getTrustedProxies();
foreach ($trusted_proxies as $trustedProxy) {
if (IpUtils::checkIp($_SERVER['REMOTE_ADDR'], $trustedProxy)) {
return true;
}
}
return false;
}
/**
* Extract trustworthy address from a list of addresses provided by the Forwarded or X-Forwarded-For header.
*
* @return string Address that may be used for security purposes
*/
private static function firstNonProxyAddress(array $addresses): string
{
if (count($addresses) === 0) {
throw new InvalidArgumentException('Addresses must not be empty');
}
$trusted_proxies = self::getTrustedProxies();
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#parsing
// > When choosing the first trustworthy X-Forwarded-For client IP address, additional configuration is required.
// > The IPs or IP ranges of the trusted reverse proxies are configured. The X-Forwarded-For IP list is searched
// > from the rightmost, skipping all addresses that are on the trusted proxy list. The first non-matching
// > address is the target address.
// > The first trustworthy X-Forwarded-For IP address may belong to an untrusted intermediate proxy rather than
// > the actual client computer, but it is the only IP suitable for security uses.
for ($i = count($addresses) - 1; $i >= 0; $i--) {
$address = $addresses[$i];
foreach ($trusted_proxies as $trusted_proxy) {
if (IpUtils::checkIp($address, $trusted_proxy)) {
// This address is trusted, move one left
continue 2;
}
}
// Address is not trusted, this is the client IP we should use
return $address;
}
// All addresses are in a trusted network, use leftmost address
return $addresses[0];
}
/**
* Get header value.
* @param string $header_name Header name
* @return ?string Header value, or null if header is not present in request
*/
public static function getHeader(string $header_name): ?string
{
$headers = getallheaders();
foreach ($headers as $key => $value) {
if (strcasecmp($key, $header_name) === 0) {
return $value;
}
}
return null;
}
public static function getIpCountry(?string $ip): string
{
if (!$ip || in_array($ip, ['localhost', '127.0.0.1', '::1'])) {
return 'Unknown';
}
$cache = new Cache(['name' => 'nameless', 'extension' => '.cache', 'path' => ROOT_PATH . '/cache/']);
$cache->setCache('ip_location');
return $cache->fetch($ip, function () use ($ip) {
$reader = self::$_geoIpReader ??= new Reader(ROOT_PATH . '/core/assets/GeoLite2-Country.mmdb');
try {
$record = $reader->country($ip);
} catch (GeoIp2Exception $e) {
return 'Unknown';
}
return $record->country->name ?? 'Unknown';
}, 3600);
}
}