Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions src/think/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class Request implements ArrayAccess
*/
protected $proxyServerIp = [];

/**
* 可信任的Host白名单(为空则不校验,支持 *.example.com 通配).
*
* @var array
*/
protected $trustedHosts = [];

/**
* 前端代理服务器真实IP头
* @var array
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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];
}

/**
Expand Down
111 changes: 111 additions & 0 deletions tests/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Mockery as m;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use think\App;
use think\Config;
use think\Container;
Expand Down Expand Up @@ -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());
}
}