mirror of
https://github.com/linuxserver/Heimdall.git
synced 2026-08-07 07:16:13 +00:00
Harden against host header injection and open redirect
Heimdall trusted the incoming X-Forwarded-Host header for URL generation, so a spoofed value poisoned the page base href, asset() URLs and redirect targets - loading assets from and redirecting to an attacker-controlled host (CVE-2025-50578). - TrustProxies no longer trusts X-Forwarded-Host; a forged value can no longer influence getHost(), url(), asset() or redirects. X-Forwarded-For/Port/Proto handling is unchanged. - Trusted proxies are now configurable via the TRUSTED_PROXIES env var (comma-separated CIDRs/IPs, "*" to trust all), defaulting to the previous private ranges. - Added an opt-in TRUSTED_HOSTS allow-list: when set, only the listed hosts are served and any other Host header is rejected. Unset keeps the historic behaviour of serving arbitrary hosts, so existing installs are unaffected. Resolves #1451
This commit is contained in:
@@ -4,6 +4,16 @@ APP_KEY=
|
|||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
# Security: Host Header Injection / Open Redirect hardening (CVE-2025-50578).
|
||||||
|
# TRUSTED_PROXIES: comma-separated CIDRs/IPs of reverse proxies allowed to set
|
||||||
|
# X-Forwarded-* headers. Defaults to the private ranges below when unset. Use
|
||||||
|
# "*" to trust all proxies (only behind a trusted network boundary).
|
||||||
|
#TRUSTED_PROXIES=192.168.0.0/16,172.16.0.0/12,10.0.0.0/8,127.0.0.1
|
||||||
|
# TRUSTED_HOSTS: comma-separated hostnames Heimdall is allowed to serve. Unset
|
||||||
|
# means no restriction (default, backward compatible). Set this to your own
|
||||||
|
# domain to fully prevent host-header injection / open redirects.
|
||||||
|
#TRUSTED_HOSTS=heimdall.example.com
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||||
|
|
||||||
|
class TrustHosts extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the host patterns that should be trusted.
|
||||||
|
*
|
||||||
|
* The allow-list is read from the TRUSTED_HOSTS env var (comma-separated
|
||||||
|
* hostnames). When it is unset/empty an empty array is returned so that NO
|
||||||
|
* host restriction is applied, preserving Heimdall's historic behaviour of
|
||||||
|
* running on arbitrary hosts. When set, only the listed hosts (and their
|
||||||
|
* subdomains) are accepted; any other Host header is rejected by Symfony
|
||||||
|
* with a SuspiciousOperationException (HTTP 400).
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function hosts()
|
||||||
|
{
|
||||||
|
$trustedHosts = env('TRUSTED_HOSTS');
|
||||||
|
|
||||||
|
if ($trustedHosts === null || trim((string) $trustedHosts) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hosts = [];
|
||||||
|
|
||||||
|
foreach (explode(',', (string) $trustedHosts) as $host) {
|
||||||
|
$host = trim($host);
|
||||||
|
|
||||||
|
if ($host !== '') {
|
||||||
|
$hosts[] = '^(.+\.)?'.preg_quote($host).'$';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hosts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,52 @@ use Illuminate\Http\Request;
|
|||||||
class TrustProxies extends Middleware
|
class TrustProxies extends Middleware
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The trusted proxies for this application.
|
* The default trusted proxies used when the TRUSTED_PROXIES env var is unset.
|
||||||
*
|
*
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
protected $proxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'];
|
protected $defaultProxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trusted proxies for this application.
|
||||||
|
*
|
||||||
|
* @var array<int, string>|string|null
|
||||||
|
*/
|
||||||
|
protected $proxies;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current proxy header mappings.
|
* The current proxy header mappings.
|
||||||
*
|
*
|
||||||
* @var array
|
* Note: Request::HEADER_X_FORWARDED_HOST is intentionally NOT trusted to
|
||||||
|
* prevent Host header injection / open redirects (CVE-2025-50578). A spoofed
|
||||||
|
* X-Forwarded-Host header must never influence getHost()/url()/asset().
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
*/
|
*/
|
||||||
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
|
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new middleware instance.
|
||||||
|
*
|
||||||
|
* The set of trusted proxies is read from the TRUSTED_PROXIES env var
|
||||||
|
* (comma-separated CIDRs/IPs). When unset it falls back to the historic
|
||||||
|
* default list. The special value "*" trusts all proxies.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$trustedProxies = env('TRUSTED_PROXIES');
|
||||||
|
|
||||||
|
if ($trustedProxies === null || trim((string) $trustedProxies) === '') {
|
||||||
|
$this->proxies = $this->defaultProxies;
|
||||||
|
} elseif (trim((string) $trustedProxies) === '*') {
|
||||||
|
$this->proxies = '*';
|
||||||
|
} else {
|
||||||
|
$this->proxies = array_values(array_filter(
|
||||||
|
array_map('trim', explode(',', (string) $trustedProxies)),
|
||||||
|
fn ($proxy) => $proxy !== ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
|
|
||||||
$middleware->replace(\Illuminate\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class);
|
$middleware->replace(\Illuminate\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class);
|
||||||
|
|
||||||
|
$middleware->trustHosts();
|
||||||
|
$middleware->replace(\Illuminate\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustHosts::class);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'allowed' => \App\Http\Middleware\CheckAllowed::class,
|
'allowed' => \App\Http\Middleware\CheckAllowed::class,
|
||||||
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
|
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\TrustHosts;
|
||||||
|
use Illuminate\Contracts\Http\Kernel;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TrustHostsTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Remove any TRUSTED_HOSTS override and reset Symfony's static trusted host
|
||||||
|
* state so tests do not leak into one another.
|
||||||
|
*/
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_HOSTS');
|
||||||
|
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
|
||||||
|
|
||||||
|
Request::setTrustedHosts([]);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setTrustedHostsEnv(string $value): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_HOSTS='.$value);
|
||||||
|
$_ENV['TRUSTED_HOSTS'] = $value;
|
||||||
|
$_SERVER['TRUSTED_HOSTS'] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeMiddleware(): TrustHosts
|
||||||
|
{
|
||||||
|
return $this->app->make(TrustHosts::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hosts_is_empty_when_env_unset(): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_HOSTS');
|
||||||
|
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
|
||||||
|
|
||||||
|
$this->assertSame([], $this->makeMiddleware()->hosts());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_arbitrary_host_is_accepted_when_env_unset(): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_HOSTS');
|
||||||
|
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
|
||||||
|
|
||||||
|
// No trusted host patterns configured -> getHost() must not throw.
|
||||||
|
Request::setTrustedHosts(array_filter($this->makeMiddleware()->hosts()));
|
||||||
|
|
||||||
|
$request = Request::create('http://anything.example/', 'GET');
|
||||||
|
|
||||||
|
$this->assertSame('anything.example', $request->getHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hosts_contains_pattern_matching_configured_host(): void
|
||||||
|
{
|
||||||
|
$this->setTrustedHostsEnv('example.com');
|
||||||
|
|
||||||
|
$hosts = $this->makeMiddleware()->hosts();
|
||||||
|
|
||||||
|
$this->assertNotEmpty($hosts);
|
||||||
|
$this->assertCount(1, $hosts);
|
||||||
|
// Symfony wraps each pattern as {pattern}i before matching.
|
||||||
|
$this->assertSame(1, preg_match('{'.$hosts[0].'}i', 'example.com'));
|
||||||
|
$this->assertSame(0, preg_match('{'.$hosts[0].'}i', 'evil.com'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_configured_host_is_accepted_and_others_rejected(): void
|
||||||
|
{
|
||||||
|
$this->setTrustedHostsEnv('example.com');
|
||||||
|
|
||||||
|
Request::setTrustedHosts($this->makeMiddleware()->hosts());
|
||||||
|
|
||||||
|
$accepted = Request::create('http://example.com/', 'GET');
|
||||||
|
$this->assertSame('example.com', $accepted->getHost());
|
||||||
|
|
||||||
|
$this->expectException(SuspiciousOperationException::class);
|
||||||
|
|
||||||
|
Request::create('http://evil.com/', 'GET')->getHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_multiple_hosts_can_be_configured(): void
|
||||||
|
{
|
||||||
|
$this->setTrustedHostsEnv('example.com, dash.example.org');
|
||||||
|
|
||||||
|
$hosts = $this->makeMiddleware()->hosts();
|
||||||
|
|
||||||
|
$this->assertCount(2, $hosts);
|
||||||
|
|
||||||
|
Request::setTrustedHosts($hosts);
|
||||||
|
|
||||||
|
$this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost());
|
||||||
|
$this->assertSame('dash.example.org', Request::create('http://dash.example.org/', 'GET')->getHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_custom_trust_hosts_middleware_is_registered_globally(): void
|
||||||
|
{
|
||||||
|
$globalMiddleware = $this->app->make(Kernel::class)->getGlobalMiddleware();
|
||||||
|
|
||||||
|
$this->assertContains(TrustHosts::class, $globalMiddleware);
|
||||||
|
$this->assertNotContains(\Illuminate\Http\Middleware\TrustHosts::class, $globalMiddleware);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\TrustProxies;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TrustProxiesTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Remove any TRUSTED_PROXIES override and reset Symfony's static trusted
|
||||||
|
* proxy/host state so tests do not leak into one another.
|
||||||
|
*/
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_PROXIES');
|
||||||
|
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
|
||||||
|
|
||||||
|
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
|
||||||
|
Request::setTrustedHosts([]);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setTrustedProxiesEnv(string $value): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_PROXIES='.$value);
|
||||||
|
$_ENV['TRUSTED_PROXIES'] = $value;
|
||||||
|
$_SERVER['TRUSTED_PROXIES'] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readProtected(object $object, string $property): mixed
|
||||||
|
{
|
||||||
|
return (fn () => $this->{$property})->call($object);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_x_forwarded_host_header_is_ignored(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('http://localhost/', 'GET');
|
||||||
|
$request->server->set('REMOTE_ADDR', '10.0.0.1');
|
||||||
|
$request->headers->set('X-Forwarded-Host', 'evil.com');
|
||||||
|
$request->server->set('HTTP_X_FORWARDED_HOST', 'evil.com');
|
||||||
|
|
||||||
|
(new TrustProxies())->handle($request, fn ($req) => $req);
|
||||||
|
|
||||||
|
$this->assertSame('localhost', $request->getHost());
|
||||||
|
$this->assertNotSame('evil.com', $request->getHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_headers_bitmask_excludes_forwarded_host(): void
|
||||||
|
{
|
||||||
|
$headers = $this->readProtected(new TrustProxies(), 'headers');
|
||||||
|
|
||||||
|
$this->assertSame(0, $headers & Request::HEADER_X_FORWARDED_HOST);
|
||||||
|
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_FOR);
|
||||||
|
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PORT);
|
||||||
|
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PROTO);
|
||||||
|
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_AWS_ELB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_default_trusted_proxies_when_env_unset(): void
|
||||||
|
{
|
||||||
|
putenv('TRUSTED_PROXIES');
|
||||||
|
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
|
||||||
|
|
||||||
|
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'],
|
||||||
|
$proxies
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_trusted_proxies_can_be_configured_via_env(): void
|
||||||
|
{
|
||||||
|
$this->setTrustedProxiesEnv('203.0.113.5, 198.51.100.0/24');
|
||||||
|
|
||||||
|
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
|
||||||
|
|
||||||
|
$this->assertSame(['203.0.113.5', '198.51.100.0/24'], $proxies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_trusted_proxies_supports_wildcard(): void
|
||||||
|
{
|
||||||
|
$this->setTrustedProxiesEnv('*');
|
||||||
|
|
||||||
|
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
|
||||||
|
|
||||||
|
$this->assertSame('*', $proxies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wildcard_proxy_trusts_calling_ip_for_forwarded_headers(): void
|
||||||
|
{
|
||||||
|
$this->setTrustedProxiesEnv('*');
|
||||||
|
|
||||||
|
$request = Request::create('http://localhost/', 'GET');
|
||||||
|
$request->server->set('REMOTE_ADDR', '203.0.113.9');
|
||||||
|
$request->headers->set('X-Forwarded-Proto', 'https');
|
||||||
|
$request->server->set('HTTP_X_FORWARDED_PROTO', 'https');
|
||||||
|
|
||||||
|
(new TrustProxies())->handle($request, fn ($req) => $req);
|
||||||
|
|
||||||
|
// Proto is honored (proxy trusted) but host is still not taken from headers.
|
||||||
|
$this->assertTrue($request->isSecure());
|
||||||
|
$this->assertSame('localhost', $request->getHost());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user