From fc0d98590dd99c3a79c266ed870087190e131be1 Mon Sep 17 00:00:00 2001 From: teletom Date: Fri, 26 Jun 2026 15:26:47 +0800 Subject: [PATCH] fix: only trust X-Forwarded-Host/Proto/Port from trusted proxy, add trustedHosts allowlist --- src/think/Request.php | 91 ++++++++++++++++++++++++++++++++-- tests/RequestTest.php | 111 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/src/think/Request.php b/src/think/Request.php index 1a1e749bc0..8f0080f675 100644 --- a/src/think/Request.php +++ b/src/think/Request.php @@ -78,6 +78,13 @@ class Request implements ArrayAccess */ protected $proxyServerIp = []; + /** + * 可信任的Host白名单(为空则不校验,支持 *.example.com 通配). + * + * @var array + */ + protected $trustedHosts = []; + /** * 前端代理服务器真实IP头 * @var array @@ -1550,7 +1557,7 @@ public function isSsl(): bool return true; } elseif ('443' == $this->server('SERVER_PORT')) { return true; - } elseif ('https' == $this->server('HTTP_X_FORWARDED_PROTO')) { + } elseif ($this->isFromTrustedProxy() && 'https' == $this->server('HTTP_X_FORWARDED_PROTO')) { return true; } elseif ($this->httpsAgentName && $this->server($this->httpsAgentName)) { return true; @@ -1785,9 +1792,14 @@ public function host(bool $strict = false): string if ($this->host) { $host = $this->host; } else { - $host = strval($this->server('HTTP_X_FORWARDED_HOST') ?: $this->server('HTTP_HOST')); + // 仅当请求来自可信代理时才信任 X-Forwarded-Host + $forwardedHost = $this->isFromTrustedProxy() ? $this->server('HTTP_X_FORWARDED_HOST') : null; + $host = strval($forwardedHost ?: $this->server('HTTP_HOST')); } + // 可信Host白名单校验(trustedHosts 为空时行为不变) + $host = $this->validateHost($host); + return true === $strict && str_contains($host, ':') ? strstr($host, ':', true) : $host; } @@ -1798,7 +1810,80 @@ public function host(bool $strict = false): string */ public function port(): int { - return (int) ($this->server('HTTP_X_FORWARDED_PORT') ?: $this->server('SERVER_PORT', '')); + // 仅当请求来自可信代理时才信任 X-Forwarded-Port + $forwardedPort = $this->isFromTrustedProxy() ? $this->server('HTTP_X_FORWARDED_PORT') : null; + + return (int) ($forwardedPort ?: $this->server('SERVER_PORT', '')); + } + + /** + * 判断当前请求是否来自可信代理 + * 未配置 proxyServerIp 时返回 true(保持向后兼容); + * 配置后,仅当 REMOTE_ADDR 属于可信代理网段时才返回 true。 + */ + protected function isFromTrustedProxy(): bool + { + $proxyIp = $this->proxyServerIp; + + // 未配置可信代理:维持既有行为,信任转发头 + if (empty($proxyIp)) { + return true; + } + + $remoteAddr = $this->server('REMOTE_ADDR', ''); + + if (!$this->isValidIP($remoteAddr)) { + return false; + } + + $remoteBin = $this->ip2bin($remoteAddr); + + foreach ($proxyIp as $ip) { + $elements = explode('/', $ip); + $serverIP = $elements[0]; + $prefix = $elements[1] ?? 128; + $serverBin = $this->ip2bin($serverIP); + + if (strlen($remoteBin) !== strlen($serverBin)) { + continue; + } + + if (0 === strncmp($remoteBin, $serverBin, (int) $prefix)) { + return true; + } + } + + return false; + } + + /** + * 根据 trustedHosts 白名单校验主机名 + * trustedHosts 为空时直接返回(向后兼容); + * 配置后,不在白名单内的主机将回退到白名单首项,避免 Host 污染。 + * + * @param string $host 待校验的 host(可能含端口) + */ + protected function validateHost(string $host): string + { + if (empty($this->trustedHosts)) { + return $host; + } + + $hostName = str_contains($host, ':') ? strstr($host, ':', true) : $host; + + foreach ($this->trustedHosts as $pattern) { + if ($pattern === $hostName) { + return $host; + } + + // 支持 *.example.com 通配 + if (str_starts_with($pattern, '*.') && str_ends_with($hostName, substr($pattern, 1))) { + return $host; + } + } + + // 不可信主机:回退到白名单首项 + return $this->trustedHosts[0]; } /** diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 3193f19c63..9eccd1bc8c 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -4,6 +4,7 @@ use Mockery as m; use PHPUnit\Framework\TestCase; +use ReflectionProperty; use think\App; use think\Config; use think\Container; @@ -566,4 +567,114 @@ public function testExt() $request3->withServer(['PATH_INFO' => '/user/profile']); $this->assertEquals('', $request3->ext()); } + + /** + * 通过反射设置 Request 的 protected 属性(用于注入 proxyServerIp / trustedHosts). + * + * @param mixed $value + */ + protected function setProtected(Request $request, string $property, $value): void + { + $ref = new ReflectionProperty(Request::class, $property); + $ref->setAccessible(true); + $ref->setValue($request, $value); + } + + public function testHostTrustsXForwardedHostByDefault() + { + // 未配置 proxyServerIp:维持既有行为,信任 X-Forwarded-Host(向后兼容) + $request = new Request(); + $request->withServer([ + 'HTTP_HOST' => 'real.example.com', + 'HTTP_X_FORWARDED_HOST' => 'evil.attacker.com', + ]); + $this->assertEquals('evil.attacker.com', $request->host()); + } + + public function testHostIgnoresXForwardedHostFromUntrustedProxy() + { + // 配置 proxyServerIp 后,来自非可信 IP 的 X-Forwarded-Host 必须被忽略 + $request = new Request(); + $this->setProtected($request, 'proxyServerIp', ['10.0.0.1']); + $request->withServer([ + 'REMOTE_ADDR' => '8.8.8.8', + 'HTTP_HOST' => 'real.example.com', + 'HTTP_X_FORWARDED_HOST' => 'evil.attacker.com', + ]); + $this->assertEquals('real.example.com', $request->host()); + } + + public function testHostTrustsXForwardedHostFromTrustedProxy() + { + // 来自可信代理的 X-Forwarded-Host 应被采纳 + $request = new Request(); + $this->setProtected($request, 'proxyServerIp', ['10.0.0.1']); + $request->withServer([ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_HOST' => 'real.example.com', + 'HTTP_X_FORWARDED_HOST' => 'cdn.example.com', + ]); + $this->assertEquals('cdn.example.com', $request->host()); + } + + public function testHostValidatesAgainstTrustedHosts() + { + // trustedHosts 精确命中(端口保留) + $request = new Request(); + $this->setProtected($request, 'trustedHosts', ['example.com', '*.example.com']); + $request->withServer(['HTTP_HOST' => 'example.com:8080']); + $this->assertEquals('example.com:8080', $request->host()); + + // *.example.com 通配命中 + $request2 = new Request(); + $this->setProtected($request2, 'trustedHosts', ['example.com', '*.example.com']); + $request2->withServer(['HTTP_HOST' => 'api.example.com']); + $this->assertEquals('api.example.com', $request2->host()); + } + + public function testHostRejectsUntrustedHost() + { + // 不在 trustedHosts 内的 host 回退到白名单首项,伪造的 X-Forwarded-Host 同样被挡 + $request = new Request(); + $this->setProtected($request, 'trustedHosts', ['example.com', '*.example.com']); + $request->withServer([ + 'HTTP_HOST' => 'example.com', + 'HTTP_X_FORWARDED_HOST' => 'evil.attacker.com', + ]); + $this->assertEquals('example.com', $request->host()); + } + + public function testIsSslIgnoresXForwardedProtoFromUntrustedProxy() + { + $request = new Request(); + $this->setProtected($request, 'proxyServerIp', ['10.0.0.1']); + $request->withServer([ + 'REMOTE_ADDR' => '8.8.8.8', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ]); + $this->assertFalse($request->isSsl()); + } + + public function testIsSslTrustsXForwardedProtoFromTrustedProxy() + { + $request = new Request(); + $this->setProtected($request, 'proxyServerIp', ['10.0.0.1']); + $request->withServer([ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ]); + $this->assertTrue($request->isSsl()); + } + + public function testPortIgnoresXForwardedPortFromUntrustedProxy() + { + $request = new Request(); + $this->setProtected($request, 'proxyServerIp', ['10.0.0.1']); + $request->withServer([ + 'REMOTE_ADDR' => '8.8.8.8', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PORT' => '443', + ]); + $this->assertEquals(80, $request->port()); + } } \ No newline at end of file