set_safe_mode(true) refuses anything but http and https, refuses hosts that resolve to loopback, private, link-local or reserved addresses, pins curl to the addresses that were checked (no DNS rebinding), and follows redirects one hop at a time so each is checked, dropping credentials when a redirect changes origin. Hosts or ranges can be allowed explicitly for development servers. Off by default. Adds a PHPUnit suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
| @ -1,3 +1,4 @@ | |||||
| vendor/ | vendor/ | ||||
| composer.lock | composer.lock | ||||
| .phpunit.result.cache | |||||
| @ -0,0 +1,8 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <phpunit bootstrap="vendor/autoload.php" colors="true"> | |||||
| <testsuites> | |||||
| <testsuite name="p3k-http"> | |||||
| <directory>tests</directory> | |||||
| </testsuite> | |||||
| </testsuites> | |||||
| </phpunit> | |||||
| @ -0,0 +1,144 @@ | |||||
| <?php | |||||
| namespace p3k\HTTP; | |||||
| // Decides whether a URL may be fetched when safe mode is on: http or https | |||||
| // only, and a host whose every address is on the public internet (unless the | |||||
| // host or the address is allowed explicitly). The addresses that were checked | |||||
| // are returned so a transport can connect to exactly those, which defeats | |||||
| // DNS rebinding between the check and the connection. | |||||
| class Guard { | |||||
| // Hosts written as anything other than a plain dotted quad, such as | |||||
| // 2130706433, 0x7f.1 or 0177.0.0.1, are ones curl and the resolver would | |||||
| // read as an IP address. Refuse them rather than guess which one. | |||||
| const NUMERIC_HOST = '/^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+))*\.?$/i'; | |||||
| private $_allow_hosts = []; | |||||
| private $_allow_cidrs = []; | |||||
| private $_resolver; | |||||
| /** | |||||
| * @param array $allow Hostnames, IP addresses or CIDR ranges that may be | |||||
| * reached even though they are not public. | |||||
| * @param callable|null $resolver fn(string $host): string[] of addresses; | |||||
| * defaults to the system resolver. Tests pass their own. | |||||
| */ | |||||
| public function __construct(array $allow=[], $resolver=null) { | |||||
| foreach($allow as $entry) { | |||||
| $entry = strtolower(trim($entry)); | |||||
| if($entry === '') | |||||
| continue; | |||||
| if(strpos($entry, '/') !== false || filter_var(trim($entry, '[]'), FILTER_VALIDATE_IP)) | |||||
| $this->_allow_cidrs[] = strpos($entry, '/') !== false ? $entry : trim($entry, '[]') . (strpos($entry, ':') !== false ? '/128' : '/32'); | |||||
| else | |||||
| $this->_allow_hosts[] = rtrim($entry, '.'); | |||||
| } | |||||
| $this->_resolver = $resolver ?: [self::class, 'resolve']; | |||||
| } | |||||
| /** | |||||
| * @return array Either ['host' => ..., 'port' => ..., 'addresses' => [...]] | |||||
| * or ['error' => 'blocked_url'|'dns_error', 'error_description' => ...] | |||||
| */ | |||||
| public function check($url) { | |||||
| $parts = parse_url($url); | |||||
| if($parts === false || !isset($parts['scheme']) || !isset($parts['host']) || $parts['host'] === '') | |||||
| return self::_error('blocked_url', 'The URL is not a valid absolute URL'); | |||||
| $scheme = strtolower($parts['scheme']); | |||||
| if($scheme !== 'http' && $scheme !== 'https') | |||||
| return self::_error('blocked_url', 'Only http and https URLs can be fetched'); | |||||
| $host = strtolower($parts['host']); | |||||
| $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'https' ? 443 : 80); | |||||
| if($host[0] === '[') { | |||||
| $literal = substr($host, 1, -1); | |||||
| if(!filter_var($literal, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) | |||||
| return self::_error('blocked_url', 'The URL has an invalid IPv6 address'); | |||||
| $addresses = [$literal]; | |||||
| } elseif(filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { | |||||
| $addresses = [$host]; | |||||
| } elseif(preg_match(self::NUMERIC_HOST, $host)) { | |||||
| return self::_error('blocked_url', 'The URL\'s host is an IP address in an unusual form'); | |||||
| } else { | |||||
| $addresses = call_user_func($this->_resolver, rtrim($host, '.')); | |||||
| if(!$addresses) | |||||
| return self::_error('dns_error', 'Could not resolve ' . $host); | |||||
| } | |||||
| $hostAllowed = in_array(rtrim($host, '.'), $this->_allow_hosts, true); | |||||
| foreach($addresses as $address) { | |||||
| if(!$hostAllowed && !$this->_address_allowed($address)) | |||||
| return self::_error('blocked_url', $host . ' resolves to ' . $address . ', which is not a public address'); | |||||
| } | |||||
| return ['host' => $host, 'port' => $port, 'addresses' => array_values($addresses)]; | |||||
| } | |||||
| /** Every IPv4 and IPv6 address the system resolver knows for a host. */ | |||||
| public static function resolve($host) { | |||||
| $addresses = gethostbynamel($host) ?: []; | |||||
| $records = @dns_get_record($host, DNS_AAAA); | |||||
| foreach($records ?: [] as $record) { | |||||
| if(isset($record['ipv6'])) | |||||
| $addresses[] = $record['ipv6']; | |||||
| } | |||||
| return array_values(array_unique($addresses)); | |||||
| } | |||||
| /** Whether an address is on the public internet, looking inside IPv6 forms that carry an IPv4 address. */ | |||||
| public static function is_public($address) { | |||||
| if(!filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) | |||||
| return false; | |||||
| $embedded = self::_embedded_ipv4($address); | |||||
| if($embedded !== null) | |||||
| return self::is_public($embedded); | |||||
| return true; | |||||
| } | |||||
| public static function in_cidr($address, $cidr) { | |||||
| list($subnet, $bits) = array_pad(explode('/', $cidr, 2), 2, null); | |||||
| $a = @inet_pton($address); | |||||
| $s = @inet_pton($subnet); | |||||
| if($a === false || $s === false || strlen($a) !== strlen($s)) | |||||
| return false; | |||||
| $bits = $bits === null ? strlen($a) * 8 : (int)$bits; | |||||
| $bytes = intdiv($bits, 8); | |||||
| if(substr($a, 0, $bytes) !== substr($s, 0, $bytes)) | |||||
| return false; | |||||
| $rest = $bits % 8; | |||||
| if($rest === 0) | |||||
| return true; | |||||
| $mask = chr((0xff << (8 - $rest)) & 0xff); | |||||
| return ($a[$bytes] & $mask) === ($s[$bytes] & $mask); | |||||
| } | |||||
| private function _address_allowed($address) { | |||||
| foreach($this->_allow_cidrs as $cidr) { | |||||
| if(self::in_cidr($address, $cidr)) | |||||
| return true; | |||||
| } | |||||
| return self::is_public($address); | |||||
| } | |||||
| // NAT64 (64:ff9b::/96, 64:ff9b:1::/48) and 6to4 (2002::/16) addresses | |||||
| // reach an IPv4 address, which must be public too. | |||||
| private static function _embedded_ipv4($address) { | |||||
| $bin = @inet_pton($address); | |||||
| if($bin === false || strlen($bin) !== 16) | |||||
| return null; | |||||
| if(self::in_cidr($address, '64:ff9b::/96') || self::in_cidr($address, '64:ff9b:1::/48')) | |||||
| return inet_ntop(substr($bin, 12, 4)); | |||||
| if(self::in_cidr($address, '2002::/16')) | |||||
| return inet_ntop(substr($bin, 2, 4)); | |||||
| return null; | |||||
| } | |||||
| private static function _error($code, $description) { | |||||
| return ['error' => $code, 'error_description' => $description]; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,17 @@ | |||||
| <?php | |||||
| namespace p3k\HTTP; | |||||
| // A transport that can be held to what safe mode checked: only http and | |||||
| // https, no redirects of its own, and connections only to the addresses that | |||||
| // were vetted. HTTP calls pin_addresses() before each request in safe mode | |||||
| // and pin_addresses(null) after it. | |||||
| interface Pinnable { | |||||
| /** | |||||
| * @param array|null $resolve "host:port:address" entries, as for | |||||
| * CURLOPT_RESOLVE; null lifts the restriction. | |||||
| */ | |||||
| public function pin_addresses($resolve); | |||||
| } | |||||
| @ -0,0 +1,64 @@ | |||||
| <?php | |||||
| namespace p3k\HTTP\Tests; | |||||
| use p3k\HTTP; | |||||
| use p3k\HTTP\Curl; | |||||
| use PHPUnit\Framework\TestCase; | |||||
| // Real requests through curl to PHP's built-in server on 127.0.0.1. | |||||
| class CurlPinningTest extends TestCase { | |||||
| private static $server; | |||||
| private static $port; | |||||
| public static function setUpBeforeClass(): void { | |||||
| self::$port = 20000 + random_int(0, 9999); | |||||
| self::$server = proc_open( | |||||
| [PHP_BINARY, '-S', '127.0.0.1:' . self::$port, '-t', __DIR__ . '/server'], | |||||
| [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], | |||||
| $pipes | |||||
| ); | |||||
| for($i = 0; $i < 50; $i++) { | |||||
| $socket = @fsockopen('127.0.0.1', self::$port); | |||||
| if($socket) { fclose($socket); return; } | |||||
| usleep(100000); | |||||
| } | |||||
| self::fail('The test server did not start'); | |||||
| } | |||||
| public static function tearDownAfterClass(): void { | |||||
| proc_terminate(self::$server); | |||||
| } | |||||
| private function http() { | |||||
| $http = new HTTP('test'); | |||||
| // "pinned.example" exists only in this resolver; curl can reach it only | |||||
| // through the pinned address. | |||||
| $http->set_safe_mode(true, ['127.0.0.1'], function($host) { return $host === 'pinned.example' ? ['127.0.0.1'] : []; }); | |||||
| return $http; | |||||
| } | |||||
| public function testConnectsToThePinnedAddress() { | |||||
| $response = $this->http()->get('http://pinned.example:' . self::$port . '/'); | |||||
| $this->assertSame(200, $response['code']); | |||||
| $this->assertSame('host=pinned.example:' . self::$port, $response['body']); | |||||
| } | |||||
| public function testRedirectsAreCheckedHopByHop() { | |||||
| $port = self::$port; | |||||
| $response = $this->http()->get("http://pinned.example:$port/?to=" . rawurlencode("http://127.0.0.1:$port/")); | |||||
| $this->assertSame(200, $response['code']); | |||||
| $this->assertSame("http://127.0.0.1:$port/", $response['url']); | |||||
| $response = $this->http()->get("http://pinned.example:$port/?to=" . rawurlencode("http://127.0.0.2:$port/")); | |||||
| $this->assertSame('blocked_url', $response['error']); | |||||
| } | |||||
| public function testPinnedCurlRefusesOtherProtocols() { | |||||
| $curl = new Curl(); | |||||
| $curl->pin_addresses([]); | |||||
| $response = $curl->get('dict://127.0.0.1:' . self::$port . '/info'); | |||||
| $this->assertSame(0, $response['code']); | |||||
| $this->assertNotSame('', $response['error_description']); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,91 @@ | |||||
| <?php | |||||
| namespace p3k\HTTP\Tests; | |||||
| use p3k\HTTP\Guard; | |||||
| use PHPUnit\Framework\TestCase; | |||||
| class GuardTest extends TestCase { | |||||
| private static function guard(array $dns = [], array $allow = []) { | |||||
| return new Guard($allow, function($host) use($dns) { return $dns[$host] ?? []; }); | |||||
| } | |||||
| public static function publicAddresses() { | |||||
| return [['8.8.8.8'], ['93.184.216.34'], ['2606:4700::1'], ['2001:4860:4860::8888']]; | |||||
| } | |||||
| public static function nonPublicAddresses() { | |||||
| return [ | |||||
| ['127.0.0.1'], ['127.8.9.10'], ['10.11.11.80'], ['172.16.0.1'], ['192.168.1.1'], ['169.254.169.254'], | |||||
| ['100.64.0.1'], ['0.0.0.0'], ['::'], ['::1'], ['fe80::1'], ['fc00::1'], ['fd12:3456::1'], | |||||
| ['::ffff:127.0.0.1'], ['::ffff:10.0.0.1'], ['64:ff9b::7f00:1'], ['64:ff9b::a00:1'], ['2002:7f00:1::1'], ['2002:a9fe:a9fe::'], | |||||
| ]; | |||||
| } | |||||
| #[\PHPUnit\Framework\Attributes\DataProvider('publicAddresses')] | |||||
| public function testPublicAddresses($address) { | |||||
| $this->assertTrue(Guard::is_public($address)); | |||||
| } | |||||
| #[\PHPUnit\Framework\Attributes\DataProvider('nonPublicAddresses')] | |||||
| public function testNonPublicAddresses($address) { | |||||
| $this->assertFalse(Guard::is_public($address)); | |||||
| } | |||||
| public static function blockedUrls() { | |||||
| return [ | |||||
| ['gopher://127.0.0.1:6379/_SET%20x%201'], ['dict://127.0.0.1:6379/info'], ['file:///etc/passwd'], | |||||
| ['ftp://example.com/'], ['ldap://example.com/'], ['javascript:alert(1)'], ['/relative'], ['https://'], | |||||
| ['http://127.0.0.1/'], ['http://localhost.example/'], ['http://[::1]/'], ['http://[::ffff:127.0.0.1]/'], | |||||
| ['http://2130706433/'], ['http://0x7f000001/'], ['http://0177.0.0.1/'], ['http://127.1/'], ['http://0/'], | |||||
| ['http://169.254.169.254/latest/meta-data/'], ['http://mixed.example/'], | |||||
| ]; | |||||
| } | |||||
| #[\PHPUnit\Framework\Attributes\DataProvider('blockedUrls')] | |||||
| public function testBlockedUrls($url) { | |||||
| $result = self::guard(['localhost.example' => ['127.0.0.1'], 'mixed.example' => ['93.184.216.34', '10.0.0.1']])->check($url); | |||||
| $this->assertSame('blocked_url', $result['error'] ?? null, $url); | |||||
| } | |||||
| public function testPublicHostIsAllowedWithItsAddresses() { | |||||
| $result = self::guard(['example.com' => ['93.184.216.34', '2606:2800:220:1::']])->check('https://Example.com/path'); | |||||
| $this->assertSame(['host' => 'example.com', 'port' => 443, 'addresses' => ['93.184.216.34', '2606:2800:220:1::']], $result); | |||||
| } | |||||
| public function testExplicitPort() { | |||||
| $result = self::guard(['example.com' => ['93.184.216.34']])->check('http://example.com:8080/'); | |||||
| $this->assertSame(8080, $result['port']); | |||||
| } | |||||
| public function testUnresolvableHost() { | |||||
| $this->assertSame('dns_error', self::guard()->check('https://nowhere.example/')['error']); | |||||
| } | |||||
| public function testAllowedHost() { | |||||
| $guard = self::guard(['dev.example' => ['10.11.11.80']], ['dev.example']); | |||||
| $this->assertSame(['10.11.11.80'], $guard->check('https://dev.example/')['addresses']); | |||||
| $this->assertArrayHasKey('error', $guard->check('https://other.example/')); | |||||
| } | |||||
| public function testAllowedRange() { | |||||
| $guard = self::guard(['dev.example' => ['10.11.11.80'], 'db.example' => ['10.11.12.5']], ['10.11.11.0/24']); | |||||
| $this->assertArrayNotHasKey('error', $guard->check('https://dev.example/')); | |||||
| $this->assertArrayHasKey('error', $guard->check('https://db.example/')); | |||||
| } | |||||
| public function testAllowedSingleAddress() { | |||||
| $guard = self::guard([], ['127.0.0.1', '::1']); | |||||
| $this->assertArrayNotHasKey('error', $guard->check('http://127.0.0.1:8000/')); | |||||
| $this->assertArrayNotHasKey('error', $guard->check('http://[::1]:8000/')); | |||||
| $this->assertArrayHasKey('error', $guard->check('http://127.0.0.2/')); | |||||
| } | |||||
| public function testInCidr() { | |||||
| $this->assertTrue(Guard::in_cidr('10.11.11.80', '10.11.11.0/24')); | |||||
| $this->assertFalse(Guard::in_cidr('10.11.12.80', '10.11.11.0/24')); | |||||
| $this->assertTrue(Guard::in_cidr('10.11.11.80', '10.8.0.0/13')); | |||||
| $this->assertTrue(Guard::in_cidr('fd00::1', 'fc00::/7')); | |||||
| $this->assertFalse(Guard::in_cidr('10.0.0.1', 'fc00::/7')); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,43 @@ | |||||
| <?php | |||||
| namespace p3k\HTTP\Tests; | |||||
| use p3k\HTTP\Pinnable; | |||||
| use p3k\HTTP\Transport; | |||||
| // Answers from a table of canned responses and records every request it | |||||
| // was asked to make, and what it was pinned to at the time. | |||||
| class RecordingTransport implements Transport, Pinnable { | |||||
| public $requests = []; | |||||
| public $max_redirects = null; | |||||
| private $pinned = null; | |||||
| private $responses; | |||||
| /** @param array $responses url => [code, headers string, body] */ | |||||
| public function __construct(array $responses) { | |||||
| $this->responses = $responses; | |||||
| } | |||||
| public function pin_addresses($resolve) { $this->pinned = $resolve; } | |||||
| public function set_timeout($timeout) {} | |||||
| public function set_max_redirects($max) { $this->max_redirects = $max; } | |||||
| public function get($url, $headers=[]) { return $this->answer('GET', $url, false, $headers); } | |||||
| public function post($url, $body, $headers=[]) { return $this->answer('POST', $url, $body, $headers); } | |||||
| public function put($url, $body, $headers=[]) { return $this->answer('PUT', $url, $body, $headers); } | |||||
| public function head($url, $headers=[]) { return $this->answer('HEAD', $url, false, $headers); } | |||||
| private function answer($method, $url, $body, $headers) { | |||||
| $this->requests[] = ['method' => $method, 'url' => $url, 'body' => $body, 'headers' => $headers, 'pinned' => $this->pinned]; | |||||
| list($code, $header, $responseBody) = $this->responses[$url] ?? [404, '', 'not found']; | |||||
| return [ | |||||
| 'code' => $code, | |||||
| 'header' => "HTTP/1.1 $code X\r\n" . $header, | |||||
| 'body' => $responseBody, | |||||
| 'error' => '', | |||||
| 'error_description' => '', | |||||
| 'url' => $url, | |||||
| 'debug' => '', | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,99 @@ | |||||
| <?php | |||||
| namespace p3k\HTTP\Tests; | |||||
| use p3k\HTTP; | |||||
| use PHPUnit\Framework\TestCase; | |||||
| class SafeModeTest extends TestCase { | |||||
| const DNS = [ | |||||
| 'a.example' => ['93.184.216.34'], | |||||
| 'b.example' => ['93.184.216.35', '2606:2800:220:1::'], | |||||
| 'internal.example' => ['10.0.0.5'], | |||||
| ]; | |||||
| private function http(array $responses, &$transport) { | |||||
| $transport = new RecordingTransport($responses); | |||||
| $http = new HTTP('test', $transport); | |||||
| $http->set_safe_mode(true, [], function($host) { return self::DNS[$host] ?? []; }); | |||||
| return $http; | |||||
| } | |||||
| public function testBlockedUrlMakesNoRequest() { | |||||
| $http = $this->http([], $transport); | |||||
| foreach(['gopher://127.0.0.1:6379/_FLUSHALL', 'file:///etc/passwd', 'http://127.0.0.1/', 'https://internal.example/'] as $url) { | |||||
| $response = $http->post($url, 'x'); | |||||
| $this->assertSame('blocked_url', $response['error'], $url); | |||||
| $this->assertSame(0, $response['code']); | |||||
| } | |||||
| $this->assertSame([], $transport->requests); | |||||
| } | |||||
| public function testRequestIsPinnedToCheckedAddresses() { | |||||
| $http = $this->http(['https://b.example/' => [200, '', 'ok']], $transport); | |||||
| $response = $http->get('https://b.example/'); | |||||
| $this->assertSame(200, $response['code']); | |||||
| $this->assertSame(['b.example:443:93.184.216.35', 'b.example:443:[2606:2800:220:1::]'], $transport->requests[0]['pinned']); | |||||
| $this->assertSame(0, $transport->max_redirects); | |||||
| } | |||||
| public function testRedirectToPrivateAddressIsRefused() { | |||||
| $http = $this->http([ | |||||
| 'https://a.example/' => [302, "Location: http://169.254.169.254/latest/meta-data/\r\n", ''], | |||||
| ], $transport); | |||||
| $response = $http->get('https://a.example/'); | |||||
| $this->assertSame('blocked_url', $response['error']); | |||||
| $this->assertCount(1, $transport->requests); | |||||
| } | |||||
| public function testRedirectToOtherSchemeIsRefused() { | |||||
| $http = $this->http([ | |||||
| 'https://a.example/token' => [307, "Location: gopher://a.example:6379/_x\r\n", ''], | |||||
| ], $transport); | |||||
| $this->assertSame('blocked_url', $http->post('https://a.example/token', 'code=1')['error']); | |||||
| $this->assertCount(1, $transport->requests); | |||||
| } | |||||
| public function testRedirectsAreFollowedKeepingMethodAndBody() { | |||||
| $http = $this->http([ | |||||
| 'https://a.example/one' => [301, "Location: /two\r\n", ''], | |||||
| 'https://a.example/two' => [200, "Content-Type: text/plain\r\n", 'done'], | |||||
| ], $transport); | |||||
| $response = $http->post('https://a.example/one', 'body', ['Authorization: Bearer secret']); | |||||
| $this->assertSame(200, $response['code']); | |||||
| $this->assertSame('done', $response['body']); | |||||
| $this->assertSame('https://a.example/two', $response['url']); | |||||
| $this->assertSame('POST', $transport->requests[1]['method']); | |||||
| $this->assertSame('body', $transport->requests[1]['body']); | |||||
| $this->assertContains('Authorization: Bearer secret', $transport->requests[1]['headers']); | |||||
| } | |||||
| public function testCredentialsAreDroppedWhenARedirectLeavesTheOrigin() { | |||||
| $http = $this->http([ | |||||
| 'https://a.example/' => [302, "Location: https://b.example/\r\n", ''], | |||||
| 'https://b.example/' => [200, '', 'ok'], | |||||
| ], $transport); | |||||
| $http->get('https://a.example/', ['Authorization: Bearer secret', 'Cookie: a=b', 'Accept: text/html']); | |||||
| $this->assertSame(['Accept: text/html', 'User-Agent: test'], $transport->requests[1]['headers']); | |||||
| } | |||||
| public function testTooManyRedirects() { | |||||
| $http = $this->http([ | |||||
| 'https://a.example/loop' => [302, "Location: /loop\r\n", ''], | |||||
| ], $transport); | |||||
| $http->set_max_redirects(3); | |||||
| $response = $http->get('https://a.example/loop'); | |||||
| $this->assertSame('too_many_redirects', $response['error']); | |||||
| $this->assertCount(4, $transport->requests); | |||||
| } | |||||
| public function testOffByDefault() { | |||||
| $transport = new RecordingTransport([]); | |||||
| $http = new HTTP('test', $transport); | |||||
| $this->assertFalse($http->safe_mode()); | |||||
| $http->get('http://127.0.0.1/'); | |||||
| $this->assertCount(1, $transport->requests); | |||||
| $this->assertNull($transport->requests[0]['pinned']); | |||||
| $this->assertSame(8, $transport->max_redirects); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,7 @@ | |||||
| <?php | |||||
| // For CurlPinningTest: echoes the Host header, or redirects when asked. | |||||
| if(($_GET['to'] ?? '') !== '') { | |||||
| header('Location: ' . $_GET['to'], true, 302); | |||||
| exit; | |||||
| } | |||||
| echo 'host=' . ($_SERVER['HTTP_HOST'] ?? ''); | |||||