From ac2d7696f949e77fe8b286cf3b4f22b5654cdf65 Mon Sep 17 00:00:00 2001 From: PurHur Date: Tue, 19 May 2026 11:14:27 +0000 Subject: [PATCH] Web: HTTPS, REQUEST_SCHEME, SERVER_PORT in $_SERVER (#235) Populate scheme and port from Host / X-Forwarded-Proto in VM and AOT refresh; avoid baking runtime $_SERVER keys at AOT compile time. Co-authored-by: Cursor --- lib/AOT/runtime/superglobals_refresh.c | 116 ++++++++++++++++++++ lib/JIT/SuperglobalInit.php | 11 ++ lib/Web/Superglobals.php | 109 ++++++++++++++++++ test/aot/RuntimeSuperglobalRefreshTest.php | 51 +++++++++ test/real/cases/web_server_https.phpt | 19 ++++ test/unit/Web/SuperglobalsUrlSchemeTest.php | 95 ++++++++++++++++ 6 files changed, 401 insertions(+) create mode 100644 test/real/cases/web_server_https.phpt create mode 100644 test/unit/Web/SuperglobalsUrlSchemeTest.php diff --git a/lib/AOT/runtime/superglobals_refresh.c b/lib/AOT/runtime/superglobals_refresh.c index fc460425..9e76a447 100644 --- a/lib/AOT/runtime/superglobals_refresh.c +++ b/lib/AOT/runtime/superglobals_refresh.c @@ -305,6 +305,121 @@ static void apply_cgi_headers_from_environ(__hashtable__ *server) } } +static int sg_is_https_request(void) +{ + const char *https = getenv("HTTPS"); + + if (NULL != https && '\0' != https[0] && 0 != strcmp(https, "0") + && 0 != strcasecmp(https, "off")) { + return 1; + } + { + const char *proto = getenv("HTTP_X_FORWARDED_PROTO"); + + if (NULL != proto && 0 == strcasecmp(proto, "https")) { + return 1; + } + } + + return 0; +} + +static int sg_parse_host_port(const char *host, char *name_out, size_t name_len, int *port_out) +{ + const char *colon; + + name_out[0] = '\0'; + *port_out = 0; + if ('\0' == host[0]) { + return 0; + } + if ('[' == host[0]) { + const char *close = strchr(host, ']'); + + if (NULL != close) { + size_t name_part = (size_t) (close - host - 1); + + if (name_part >= name_len) { + name_part = name_len - 1; + } + memcpy(name_out, host + 1, name_part); + name_out[name_part] = '\0'; + if (']' == close[0] && ':' == close[1]) { + *port_out = atoi(close + 2); + } + + return 1; + } + } + colon = strrchr(host, ':'); + if (NULL != colon && NULL == strchr(colon + 1, ':')) { + int port = atoi(colon + 1); + + if (port > 0) { + size_t name_part = (size_t) (colon - host); + + if (name_part >= name_len) { + name_part = name_len - 1; + } + memcpy(name_out, host, name_part); + name_out[name_part] = '\0'; + *port_out = port; + + return 1; + } + } + strncpy(name_out, host, name_len - 1); + name_out[name_len - 1] = '\0'; + + return 1; +} + +static int sg_resolve_server_port(int https, int port_from_host) +{ + const char *from_env = getenv("SERVER_PORT"); + + if (NULL != from_env && '\0' != from_env[0]) { + int port = atoi(from_env); + + if (port > 0) { + return port; + } + } + if (port_from_host > 0) { + return port_from_host; + } + + return https ? 443 : 80; +} + +static void apply_scheme_and_port(__hashtable__ *server) +{ + const char *host = env_or_empty("HTTP_HOST"); + int https = sg_is_https_request(); + const char *scheme = https ? "https" : "http"; + char server_name[256]; + int port_from_host = 0; + int port; + char port_buf[16]; + + if ('\0' != host[0]) { + set_string_key(server, "HTTP_HOST", host); + sg_parse_host_port(host, server_name, sizeof(server_name), &port_from_host); + if ('\0' != server_name[0]) { + set_string_key(server, "SERVER_NAME", server_name); + } + } + + set_string_key(server, "REQUEST_SCHEME", scheme); + if (https) { + set_string_key(server, "HTTPS", "on"); + } + + port = sg_resolve_server_port(https, port_from_host); + snprintf(port_buf, sizeof(port_buf), "%d", port); + set_string_key(server, "SERVER_PORT", port_buf); +} + static void derive_path_info(const char *script_name, const char *request_uri, char *out, size_t out_len) { char path_buf[1024]; @@ -392,6 +507,7 @@ void __superglobals__refresh(void) } apply_cgi_headers_from_environ(sg_SERVER); + apply_scheme_and_port(sg_SERVER); if (NULL == sg_COOKIE) { sg_COOKIE = __hashtable__alloc(); diff --git a/lib/JIT/SuperglobalInit.php b/lib/JIT/SuperglobalInit.php index e529f1f6..55b79d49 100644 --- a/lib/JIT/SuperglobalInit.php +++ b/lib/JIT/SuperglobalInit.php @@ -16,6 +16,14 @@ final class SuperglobalInit /** @var array */ public static array $globals = []; + /** $_SERVER keys repopulated by __superglobals__refresh (issue #201, #235). */ + private const RUNTIME_SERVER_KEYS = [ + 'REQUEST_SCHEME', + 'HTTPS', + 'SERVER_PORT', + 'SERVER_NAME', + ]; + public static function declareRefresh(Context $context): void { $signature = $context->context->functionType($context->context->voidType(), false); @@ -148,6 +156,9 @@ public static function compileTimeReadString( string $superglobalName, string $key ): ?\PHPLLVM\Value { + if ('_SERVER' === $superglobalName && in_array($key, self::RUNTIME_SERVER_KEYS, true)) { + return null; + } if (!self::compileTimeOffsetIsSet($context, $superglobalName, $key)) { return null; } diff --git a/lib/Web/Superglobals.php b/lib/Web/Superglobals.php index 528fd5bf..d6691962 100644 --- a/lib/Web/Superglobals.php +++ b/lib/Web/Superglobals.php @@ -175,6 +175,115 @@ private static function populateServer( self::setStringEntry($server, $key, $value); } } + + self::applySchemeAndPort($server); + } + + /** + * Derive REQUEST_SCHEME, HTTPS, SERVER_PORT, and SERVER_NAME (issue #235). + */ + public static function applySchemeAndPort(HashTable $server): void + { + $host = self::readStringEntry($server, 'HTTP_HOST'); + if ('' === $host) { + $fromEnv = getenv('HTTP_HOST'); + $host = false === $fromEnv ? '' : $fromEnv; + if ('' !== $host) { + self::setStringEntry($server, 'HTTP_HOST', $host); + } + } + + $https = self::detectHttps($server); + $scheme = $https ? 'https' : 'http'; + self::setStringEntry($server, 'REQUEST_SCHEME', $scheme); + if ($https) { + self::setStringEntry($server, 'HTTPS', 'on'); + } + + [$serverName, $portFromHost] = self::parseHostAndPort($host); + $port = self::resolveServerPort($https, $portFromHost); + self::setStringEntry($server, 'SERVER_PORT', (string) $port); + + if ('' !== $serverName) { + self::setStringEntry($server, 'SERVER_NAME', $serverName); + } elseif ('' !== $host) { + self::setStringEntry($server, 'SERVER_NAME', $host); + } + } + + /** + * @return array{0: string, 1: ?int} server name and optional port from Host header + */ + public static function parseHostAndPort(string $host): array + { + if ('' === $host) { + return ['', null]; + } + if ('[' === $host[0]) { + $close = strpos($host, ']'); + if (false !== $close) { + $name = substr($host, 1, $close - 1); + if (isset($host[$close + 1]) && ':' === $host[$close + 1]) { + $port = (int) substr($host, $close + 2); + + return [$name, $port > 0 ? $port : null]; + } + + return [$name, null]; + } + } + $colon = strrpos($host, ':'); + if (false !== $colon && false === strpos($host, ':', $colon + 1)) { + $port = (int) substr($host, $colon + 1); + if ($port > 0) { + return [substr($host, 0, $colon), $port]; + } + } + + return [$host, null]; + } + + public static function detectHttps(HashTable $server): bool + { + $https = getenv('HTTPS'); + if (false !== $https && '' !== $https && '0' !== $https && 'off' !== strtolower($https)) { + return true; + } + + $proto = self::readStringEntry($server, 'HTTP_X_FORWARDED_PROTO'); + if ('' === $proto) { + $fromEnv = getenv('HTTP_X_FORWARDED_PROTO'); + $proto = false === $fromEnv ? '' : $fromEnv; + } + + return 'https' === strtolower($proto); + } + + private static function resolveServerPort(bool $https, ?int $portFromHost): int + { + $fromEnv = getenv('SERVER_PORT'); + if (false !== $fromEnv && '' !== $fromEnv && ctype_digit($fromEnv)) { + return (int) $fromEnv; + } + if (null !== $portFromHost && $portFromHost > 0) { + return $portFromHost; + } + + return $https ? 443 : 80; + } + + private static function readStringEntry(HashTable $ht, string $key): string + { + $var = $ht->find($key); + if (null === $var) { + return ''; + } + $resolved = $var->resolveIndirect(); + if (Variable::TYPE_STRING !== $resolved->type) { + return ''; + } + + return $resolved->toString(); } /** diff --git a/test/aot/RuntimeSuperglobalRefreshTest.php b/test/aot/RuntimeSuperglobalRefreshTest.php index d7047a3a..32c7e6ec 100644 --- a/test/aot/RuntimeSuperglobalRefreshTest.php +++ b/test/aot/RuntimeSuperglobalRefreshTest.php @@ -78,6 +78,57 @@ public function testTwoRequestsDifferentQueryString(): void @unlink($outfile); } + public function testHttpsSchemeFromCgiEnvironment(): void + { + $source = <<<'PHP' +assertNotFalse($outfile); + unlink($outfile); + + $repoRoot = dirname(__DIR__, 2); + $env = $this->llvmProcessEnv($repoRoot); + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $compile = proc_open( + array_merge( + self::llvmEnvPrefix(), + self::phpCommand(), + [$this->compileBin, '-o', $outfile] + ), + $descriptorSpec, + $pipes, + $repoRoot, + $env + ); + fwrite($pipes[0], $source); + fclose($pipes[0]); + $compileErr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($compile); + $this->assertFileExists($outfile, trim($compileErr !== false ? $compileErr : '')); + + $runEnv = $env; + $runEnv['HTTP_HOST'] = 'example.test'; + $runEnv['HTTP_X_FORWARDED_PROTO'] = 'https'; + $runEnv['SCRIPT_NAME'] = '/index.php'; + $runEnv['REQUEST_URI'] = '/index.php'; + $output = $this->runBinary($outfile, $runEnv); + $this->assertStringContainsString('https://example.test', $output); + + @unlink($outfile); + } + public function testHttpHostFromCgiEnvironment(): void { $source = <<<'PHP' diff --git a/test/real/cases/web_server_https.phpt b/test/real/cases/web_server_https.phpt new file mode 100644 index 00000000..abb39096 --- /dev/null +++ b/test/real/cases/web_server_https.phpt @@ -0,0 +1,19 @@ +--TEST-- +Web: REQUEST_SCHEME and HTTP_HOST for absolute URLs (issue #235) +--ENV-- +REQUEST_METHOD=GET +SCRIPT_NAME=/index.php +REQUEST_URI=/index.php +HTTP_HOST=example.test +HTTP_X_FORWARDED_PROTO=https +--FILE-- +runtime = new Runtime(); + } + + protected function tearDown(): void + { + foreach (['HTTPS', 'HTTP_HOST', 'HTTP_X_FORWARDED_PROTO', 'SERVER_PORT'] as $key) { + putenv($key); + unset($_SERVER[$key]); + } + } + + public function testHttpsFromForwardedProto(): void + { + putenv('HTTP_HOST=example.test'); + putenv('HTTP_X_FORWARDED_PROTO=https'); + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + + Superglobals::populateFromEnvironment($this->runtime->vmContext, '', ''); + + $server = $this->runtime->vmContext->getSuperglobal('_SERVER')->toArray(); + $this->assertSame('https', $this->readServer($server, 'REQUEST_SCHEME')); + $this->assertSame('on', $this->readServer($server, 'HTTPS')); + $this->assertSame('443', $this->readServer($server, 'SERVER_PORT')); + $this->assertSame('example.test', $this->readServer($server, 'SERVER_NAME')); + } + + public function testHttpDefaultPort(): void + { + putenv('HTTP_HOST=example.test'); + $_SERVER['HTTP_HOST'] = 'example.test'; + + Superglobals::populateFromEnvironment($this->runtime->vmContext, '', ''); + + $server = $this->runtime->vmContext->getSuperglobal('_SERVER')->toArray(); + $this->assertSame('http', $this->readServer($server, 'REQUEST_SCHEME')); + $this->assertSame('', $this->readServer($server, 'HTTPS')); + $this->assertSame('80', $this->readServer($server, 'SERVER_PORT')); + } + + public function testParseHostAndPort(): void + { + $this->assertSame(['example.test', 8080], Superglobals::parseHostAndPort('example.test:8080')); + $this->assertSame(['example.test', null], Superglobals::parseHostAndPort('example.test')); + $this->assertSame(['::1', 443], Superglobals::parseHostAndPort('[::1]:443')); + } + + public function testAbsoluteUrlParts(): void + { + putenv('HTTP_HOST=example.test'); + putenv('HTTP_X_FORWARDED_PROTO=https'); + $_SERVER['HTTP_HOST'] = 'example.test'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + + Superglobals::populateFromEnvironment($this->runtime->vmContext, '', ''); + + $server = $this->runtime->vmContext->getSuperglobal('_SERVER')->toArray(); + $url = $this->readServer($server, 'REQUEST_SCHEME') + .'://' + .$this->readServer($server, 'HTTP_HOST'); + $this->assertSame('https://example.test', $url); + } + + private function readServer(\PHPCompiler\VM\HashTable $server, string $key): string + { + $var = $server->find($key); + if (null === $var) { + return ''; + } + $resolved = $var->resolveIndirect(); + if (\PHPCompiler\VM\Variable::TYPE_STRING !== $resolved->type) { + return ''; + } + + return $resolved->toString(); + } +}