Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ APP_KEY=
APP_DEBUG=false
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_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
Expand Down
60 changes: 60 additions & 0 deletions app/Http/Middleware/TrustHosts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?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;
}

/**
* Determine if the application should specify trusted hosts.
*
* The parent implementation skips enforcement whenever the app runs in the
* "local" environment (Heimdall's shipped default, see .env.example) or
* under the test runner, which would leave the TRUSTED_HOSTS allow-list
* silently unenforced for almost every real deployment. Instead we tie
* enforcement directly to configuration: apply the allow-list whenever one
* has actually been provided, in any environment. When TRUSTED_HOSTS is
* unset hosts() is empty and this returns false, preserving the historic
* no-restriction behaviour.
*
* @return bool
*/
protected function shouldSpecifyTrustedHosts()
{
return ! empty($this->hosts());
}
}
44 changes: 40 additions & 4 deletions app/Http/Middleware/TrustProxies.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,52 @@
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
*/
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.
*
* @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 !== ''
));
}
}
}
3 changes: 3 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@

$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([
'allowed' => \App\Http\Middleware\CheckAllowed::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
Expand Down
146 changes: 146 additions & 0 deletions tests/Feature/TrustHostsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?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);
}

public function test_handle_enforces_trusted_hosts_even_in_local_environment(): void
{
// The app runs as APP_ENV=local under the test runner; the parent
// middleware would skip enforcement entirely. Confirm handle() still
// applies the allow-list once TRUSTED_HOSTS is configured.
$this->setTrustedHostsEnv('example.com');

$request = Request::create('http://example.com/', 'GET');

$reachedNext = false;
$this->makeMiddleware()->handle($request, function ($req) use (&$reachedNext) {
$reachedNext = true;

return $req;
});

$this->assertTrue($reachedNext);

// The configured host is now accepted and any other Host is rejected.
$this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost());

$this->expectException(SuspiciousOperationException::class);
Request::create('http://evil.com/', 'GET')->getHost();
}

public function test_handle_does_not_restrict_hosts_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);

$request = Request::create('http://anything.example/', 'GET');

$this->makeMiddleware()->handle($request, fn ($req) => $req);

// No allow-list configured -> arbitrary hosts still accepted.
$this->assertSame('anything.example', Request::create('http://anything.example/', 'GET')->getHost());
}
}
Loading
Loading