Skip to content

Commit 2ad2a71

Browse files
committed
Tighten up the default configuration for Trusted Proxies. Added Test Suite.
1 parent 3790e8c commit 2ad2a71

4 files changed

Lines changed: 202 additions & 35 deletions

File tree

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ APP_TIMEZONE='UTC'
99
APP_LOCALE='en-US'
1010
MAX_RESULTS=500
1111

12+
# -------------------------------------------
13+
# TRUSTED PROXY SETTINGS
14+
# -------------------------------------------
15+
# (See config/trustedproxy.php for details)
16+
APP_TRUSTED_PROXIES=""
17+
APP_TRUSTED_HEADERS=""
18+
1219
# --------------------------------------------
1320
# REQUIRED: UPLOADED FILE STORAGE SETTINGS
1421
# --------------------------------------------
@@ -286,4 +293,4 @@ SCIM_STANDARDS_COMPLIANCE=false
286293
# --------------------------------------------
287294
# OPTIONAL: WEBHOOK SETTINGS
288295
# --------------------------------------------
289-
WEBHOOK_ALLOW_INTERNAL_TARGETS=false
296+
WEBHOOK_ALLOW_INTERNAL_TARGETS=false

app/Http/Middleware/TrustProxies.php

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,57 @@
22

33
namespace App\Http\Middleware;
44

5-
use Illuminate\Http\Middleware\TrustProxies as Middleware;
5+
use Illuminate\Http\Middleware\TrustProxies as TrustedProxyMiddleware;
66
use Illuminate\Http\Request;
77

8-
class TrustProxies extends Middleware
8+
class TrustProxies extends TrustedProxyMiddleware
99
{
1010
/**
11-
* The trusted proxies for this application.
11+
* The trusted proxy headers computed from config('trustedproxy.headers').
1212
*
13-
* @var array<int, string>|string|null
13+
* Overrides the parent's $headers property/headers() fallback so that an
14+
* empty or entirely invalid APP_TRUSTED_HEADERS results in trusting *no*
15+
* headers, rather than silently falling back to the framework's default
16+
* header set. See headers().
17+
*
18+
* @var int
1419
*/
15-
protected $proxies;
20+
protected int $headerBitmask = 0;
21+
22+
public function __construct()
23+
{
24+
$header_bitmask = 0;
25+
26+
foreach (explode(",", config('trustedproxy.headers')) as $header) {
27+
$header = trim($header);
28+
29+
if (!$header) {
30+
continue;
31+
}
32+
33+
try {
34+
$header_bitmask |= constant(Request::class . "::$header"); // once we get to PHP 8.3, this can become Request::{$header}
35+
} catch (\Throwable $e) {
36+
\Log::error("Error parsing APP_TRUSTED_HEADERS: " . $header . " is not a valid setting, ignoring");
37+
}
38+
}
39+
\Log::debug("Final header bitmask: $header_bitmask");
40+
41+
$this->headerBitmask = $header_bitmask;
42+
// note, we do *not* need to also set the Proxies themselves since the middleware does that itself.
43+
}
1644

1745
/**
18-
* The headers that should be used to detect proxies.
46+
* Get the trusted headers.
1947
*
20-
* @var int
48+
* Deliberately does not fall back to the parent's hardcoded default
49+
* header set: an empty/unconfigured APP_TRUSTED_HEADERS must mean *no*
50+
* headers are trusted, not "trust everything".
51+
*
52+
* @return int
2153
*/
22-
protected $headers =
23-
Request::HEADER_X_FORWARDED_FOR |
24-
Request::HEADER_X_FORWARDED_HOST |
25-
Request::HEADER_X_FORWARDED_PORT |
26-
Request::HEADER_X_FORWARDED_PROTO |
27-
Request::HEADER_X_FORWARDED_AWS_ELB;
54+
protected function headers()
55+
{
56+
return $this->headerBitmask;
57+
}
2858
}

config/trustedproxy.php

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,37 +31,48 @@
3131
* always gets the originating client IP, no matter
3232
* how many proxies that client's request has
3333
* subsequently passed through.
34+
*
35+
* If you're looking for a sensible default for this value,
36+
* in many cases that would be 'PRIVATE_SUBNETS' or 'private_ranges'
37+
* (They both do the same thing; they 'trust' any private non-routable
38+
* IP, which is usually how most load-balancers and proxies are configured).
39+
*
40+
* This is a comma-separated list of IP addresses, **or** '*', or '**', or
41+
* 'PRIVATE_SUBNETS' or 'private_ranges'
3442
*/
35-
'proxies' => env('APP_TRUSTED_PROXIES') !== null ?
36-
explode(',', env('APP_TRUSTED_PROXIES')) : '*',
43+
'proxies' => env('APP_TRUSTED_PROXIES') ?
44+
explode(',', env('APP_TRUSTED_PROXIES')) : '',
3745

3846
/*
39-
* To trust one or more specific proxies that connect
40-
* directly to your server, use an array of IP addresses:
47+
* If APP_TRUSTED_HEADERS is left unset, NO forwarded headers are
48+
* trusted, regardless of 'proxies' above. There is no fallback to a
49+
* default header set - trusting X-Forwarded-* is strictly opt-in, one
50+
* header at a time, via the setting below.
51+
*
52+
* This is a comma-delimited list.
4153
*/
42-
// 'proxies' => ['192.168.1.1'],
4354

44-
/*
45-
* Or, to trust all proxies that connect
46-
* directly to your server, use a "*"
47-
*/
48-
// 'proxies' => '*',
55+
'headers' => env('APP_TRUSTED_HEADERS', ''),
4956

5057
/*
51-
* Trusted forwarded-header list intentionally not configured here.
58+
* These are the valid headers you can choose to trust.
5259
*
53-
* The runtime defaults trust X-Forwarded-For, X-Forwarded-Host,
54-
* X-Forwarded-Port, X-Forwarded-Proto, and the AWS ELB set. This is
55-
* the right answer for essentially every reverse-proxy deployment,
56-
* so there is nothing to change under normal circumstances.
60+
* The list of headers should be comma-separated; use as many as you need, but only use those that you actually
61+
* _need_ - don't just pick everything at random.
5762
*
58-
* Older versions of this file shipped a commented-out example
59-
* referencing Illuminate\Http\Request::HEADER_X_FORWARDED_ALL. That
60-
* constant was removed from Symfony (see symfony/symfony#38928) and
61-
* uncommenting the example produced a fatal "Undefined constant"
62-
* error. It has been removed to avoid the foot-gun. See #6852.
63+
* Generally you should *not* be using a combination of HEADER_X_FORWARDED_AWS_ELB and anything else,
64+
* nor HEADER_X_FORWARDED_TRAEFIK and anything else. If you're using HEADER_FORWARDED, you generally won't
65+
* need anything else either. the various X_FORWARDED_FOR, X_FORWARDED_HOST, etc are often used together.
6366
*
64-
* @link https://symfony.com/doc/current/deployment/proxies.html
67+
HEADER_FORWARDED // When using RFC 7239
68+
HEADER_X_FORWARDED_FOR
69+
HEADER_X_FORWARDED_HOST
70+
HEADER_X_FORWARDED_PROTO
71+
HEADER_X_FORWARDED_PORT
72+
HEADER_X_FORWARDED_PREFIX
73+
74+
HEADER_X_FORWARDED_AWS_ELB // AWS ELB doesn't send X-Forwarded-Host
75+
HEADER_X_FORWARDED_TRAEFIK // All "X-Forwarded-*" headers sent by Traefik reverse proxy
6576
*/
6677

6778
];

tests/Unit/TrustedProxiesTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
namespace Tests\Unit;
4+
5+
use App\Http\Middleware\TrustProxies;
6+
use Illuminate\Http\Request;
7+
use Tests\TestCase;
8+
9+
class TrustedProxiesTest extends TestCase
10+
{
11+
protected function tearDown(): void
12+
{
13+
// These are process-wide statics on the Symfony/Illuminate classes, not
14+
// part of the Laravel application container, so they survive the
15+
// per-test application reset and must be cleared manually.
16+
Request::setTrustedProxies([], -1);
17+
TrustProxies::flushState();
18+
19+
parent::tearDown();
20+
}
21+
22+
private function makeSpoofedRequest(string $remoteAddr, string $forwardedFor): Request
23+
{
24+
$request = Request::create('http://example.com/test', 'GET', [], [], [], [
25+
'REMOTE_ADDR' => $remoteAddr,
26+
]);
27+
$request->headers->set('X-Forwarded-For', $forwardedFor);
28+
29+
return $request;
30+
}
31+
32+
public function test_untrusted_direct_client_cannot_spoof_its_ip_by_default()
33+
{
34+
config([
35+
'trustedproxy.proxies' => '',
36+
'trustedproxy.headers' => '',
37+
]);
38+
39+
$request = $this->makeSpoofedRequest('203.0.113.9', '1.1.1.1');
40+
41+
$resolvedIp = (new TrustProxies())->handle($request, fn ($req) => $req->ip());
42+
43+
$this->assertSame('203.0.113.9', $resolvedIp);
44+
}
45+
46+
public function test_a_configured_trusted_proxy_can_report_the_real_client_ip()
47+
{
48+
config([
49+
'trustedproxy.proxies' => '10.0.0.5',
50+
'trustedproxy.headers' => 'HEADER_X_FORWARDED_FOR',
51+
]);
52+
53+
$request = $this->makeSpoofedRequest('10.0.0.5', '203.0.113.42');
54+
55+
$resolvedIp = (new TrustProxies())->handle($request, fn ($req) => $req->ip());
56+
57+
$this->assertSame('203.0.113.42', $resolvedIp);
58+
}
59+
60+
public function test_an_untrusted_client_cannot_spoof_its_ip_by_impersonating_a_trusted_proxy()
61+
{
62+
config([
63+
'trustedproxy.proxies' => '10.0.0.5',
64+
'trustedproxy.headers' => 'HEADER_X_FORWARDED_FOR',
65+
]);
66+
67+
$request = $this->makeSpoofedRequest('203.0.113.9', '1.1.1.1');
68+
69+
$resolvedIp = (new TrustProxies())->handle($request, fn ($req) => $req->ip());
70+
71+
$this->assertSame('203.0.113.9', $resolvedIp);
72+
}
73+
74+
public function test_an_invalid_header_name_in_config_is_ignored_without_breaking_valid_ones()
75+
{
76+
config([
77+
'trustedproxy.proxies' => '10.0.0.5',
78+
'trustedproxy.headers' => 'NOT_A_REAL_HEADER,HEADER_X_FORWARDED_FOR',
79+
]);
80+
81+
$request = $this->makeSpoofedRequest('10.0.0.5', '203.0.113.42');
82+
83+
$resolvedIp = (new TrustProxies())->handle($request, fn ($req) => $req->ip());
84+
85+
$this->assertSame('203.0.113.42', $resolvedIp);
86+
}
87+
88+
public function test_a_trusted_proxy_cannot_forward_headers_when_no_headers_are_configured()
89+
{
90+
config([
91+
'trustedproxy.proxies' => '10.0.0.5',
92+
'trustedproxy.headers' => '',
93+
]);
94+
95+
// The request genuinely comes from the trusted proxy, but since
96+
// APP_TRUSTED_HEADERS is unset, X-Forwarded-For must still be
97+
// ignored - it must NOT fall back to trusting the framework's
98+
// default header set.
99+
$request = $this->makeSpoofedRequest('10.0.0.5', '203.0.113.42');
100+
101+
$resolvedIp = (new TrustProxies())->handle($request, fn ($req) => $req->ip());
102+
103+
$this->assertSame('10.0.0.5', $resolvedIp);
104+
}
105+
106+
public function test_a_trusted_proxy_cannot_forward_headers_when_every_configured_header_is_invalid()
107+
{
108+
config([
109+
'trustedproxy.proxies' => '10.0.0.5',
110+
'trustedproxy.headers' => 'NOT_A_REAL_HEADER,ALSO_NOT_REAL',
111+
]);
112+
113+
$request = $this->makeSpoofedRequest('10.0.0.5', '203.0.113.42');
114+
115+
$resolvedIp = (new TrustProxies())->handle($request, fn ($req) => $req->ip());
116+
117+
$this->assertSame('10.0.0.5', $resolvedIp);
118+
}
119+
}

0 commit comments

Comments
 (0)