From 6eaf86a45b4428eb49b1ff1c0939152fe8068e68 Mon Sep 17 00:00:00 2001 From: David Jardin Date: Fri, 1 May 2026 14:29:48 +0200 Subject: [PATCH] Harden server against SSR through rebinding --- .DS_Store | Bin 0 -> 8196 bytes app/Models/Site.php | 14 ++++- .../{DNSLookup.php => NetworkHelper.php} | 33 +++++++++- app/Providers/AppServiceProvider.php | 4 +- app/Rules/RemoteURL.php | 23 ++----- tests/Unit/Network/DNSLookupTest.php | 27 -------- tests/Unit/Network/NetworkHelperTest.php | 59 ++++++++++++++++++ tests/Unit/Rules/RemoteURLTest.php | 10 +-- 8 files changed, 117 insertions(+), 53 deletions(-) create mode 100644 .DS_Store rename app/Network/{DNSLookup.php => NetworkHelper.php} (51%) delete mode 100644 tests/Unit/Network/DNSLookupTest.php create mode 100644 tests/Unit/Network/NetworkHelperTest.php diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b08f176096a561ed5120f8b7022c1d6bede987cf GIT binary patch literal 8196 zcmeHMYitx%6h3EK=v>R=7J+tvwW|eEOR=@(VF6+LptLB@?Hgp7-5FrQbf)ag?xFY=JXyOlJh$b418vSETG*J^J#0MIGn3(v-KN?XJHt8eM^Ii(-Bp^0V7k4N(@xyq)&?JL{kx+R8*M*Ds#YSXN)Qol)IB% zQced83B<#Ga&~)cyM4!{r*;V!gYeMwe>t|6_qn)R#ma8>Z<$Fhx}4H zG{ZvBW#;z?Zov25Yu77GaYx(n<4j-B(6WAa=CC{N`Hw0uF!~a}>?vM7Hq|9J zD0ZtWiHfSSOJ7AU>5NqyL&GDZ8O{rVz1w!p&?(wIW@rZ)J~trm`g6XwGjA8ktKppQ z7REi&)Z|PzSD?vur|YJj2kc-Zw2y{jG#2>zuv<6 zXJ;))+_rp0>*{rzdiu8QIyZaHT+ZgP`NKsgaI&sFSaPiJ(5M;YY|nDMgJTpdFLVw# zcAnMX8aCjWt4d_gdt@Hs2B}T2f=w z8_RTkkC1ogZR(YnH5yHfoBBXN&Zs3;vKGbzHgK5Us4{J1t8{L$LD$UMt`e)3Qj& zYwH&FZ#9gu;Ewy#$41UDq2@ zjS5IIs12!B#c^>m8PE(Hp%=y|Q#>faDR=@-!*lR5oP{^wefStYfluKx_!2I_kMJ}6 z3ctbca1jx2!fKq2b8s;(#bvl0+i*Q@z>T;GdvF_W$DOzfhcJWtF^f5LFpmKiu!tw{ zVSEH1$5Z$$K94Wp8GI36$9M2ud=Edu&+!|)fZyTw_!Ittf8t+SrB*3(PNU1Ro>C;bwbS-pEkzQ&Tl(7nmlXMWmPm@4c{k5ra7#nu%CgP^Y1 zrOSw&NibLJ?M;aU*C@Lbdq+!x@e0ZvCT-iBh{cFmdlzH8lCntYHr!1th{RTWXBVv` za$;I^PK(Vm@Cv*JZ^MU#&GYar`~bheMfe*l35yG{j_|ky6NJY`Y{C`TjH_`icHlZf zXAkybANJ!`OyeL9;|PutHZ8RA0Qz`@@EH<9Pv9gzh!5dOJdID{Q}{GKBa!-&!q_F` z^_MZHD84!sdBt?zbA9h1$;)WH+$83%RRk*3!gZql-#q>O|FzmaUN??F9D(Z~0$ACT z?&+c*GTjGQ)Y?gU_R}Mpm^Z1YG@(wG<3wdSPV~|rhBQvnRAG~f=%k_~q4u9&1laNU MAD{mcdN*|U4;D=kWB>pF literal 0 HcmV?d00001 diff --git a/app/Models/Site.php b/app/Models/Site.php index b4fdf5b..8a4caed 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Network\NetworkHelper; use App\RemoteSite\Connection; use GuzzleHttp\Client; use Illuminate\Database\Eloquent\Model; @@ -38,7 +39,18 @@ protected function casts(): array public function getUrlAttribute(string $value): string { - return rtrim($value, "/"); + $url = rtrim($value, "/"); + + $networkHelper = App::make(NetworkHelper::class); + + // Revalidate current DNS resolution on every usage + if (!$networkHelper->isValidRemoteHost( + (string) parse_url($value, PHP_URL_HOST) + )) { + throw new \RuntimeException("Invalid URL provided"); + } + + return $url; } public function getConnectionAttribute(): Connection diff --git a/app/Network/DNSLookup.php b/app/Network/NetworkHelper.php similarity index 51% rename from app/Network/DNSLookup.php rename to app/Network/NetworkHelper.php index fe8777e..ca1cae8 100644 --- a/app/Network/DNSLookup.php +++ b/app/Network/NetworkHelper.php @@ -2,7 +2,7 @@ namespace App\Network; -class DNSLookup +class NetworkHelper { public function getIPs(string $hostname): array { @@ -26,4 +26,35 @@ public function getIPs(string $hostname): array return $ips; } + + public function isValidRemoteHost(string $hostname): bool + { + $ips = $this->getIPs($hostname); + + if (!count($ips)) { + return false; + } + + // Check each resolved IP + foreach ($ips as $ip) { + if (!$this->isValidRemoteIp($ip)) { + return false; + } + } + + return true; + } + + public function isValidRemoteIp(string $ip): bool + { + if (!filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + )) { + return false; + } + + return true; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7027757..724ed61 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,7 @@ namespace App\Providers; -use App\Network\DNSLookup; +use App\Network\NetworkHelper; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; @@ -34,7 +34,7 @@ public function boot(): void $siteIpLimits = []; if ($siteHost !== 'default') { - $siteIps = (new DNSLookup())->getIPs($siteHost); + $siteIps = (new NetworkHelper())->getIPs($siteHost); foreach ($siteIps as $siteIp) { $siteIpLimits[] = Limit::perMinute(5)->by("siteip-" . $siteIp); diff --git a/app/Rules/RemoteURL.php b/app/Rules/RemoteURL.php index f879985..4db8a0f 100644 --- a/app/Rules/RemoteURL.php +++ b/app/Rules/RemoteURL.php @@ -2,7 +2,7 @@ namespace App\Rules; -use App\Network\DNSLookup; +use App\Network\NetworkHelper; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\App; @@ -23,23 +23,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $host = (string) parse_url($value, PHP_URL_HOST); - $ips = App::make(DNSLookup::class)->getIPs($host); + /** @var NetworkHelper $networkHelper */ + $networkHelper = App::make(NetworkHelper::class); - // Could not resolve given address - if (count($ips) === 0) { - $fail("Invalid URL: unresolvable site URL."); - } - - // Check each resolved IP - foreach ($ips as $ip) { - if (!filter_var( - $ip, - FILTER_VALIDATE_IP, - FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE - ) - ) { - $fail("Invalid URL: local address are disallowed as site URL."); - } + // Check IPs + if (!$networkHelper->isValidRemoteHost($host)) { + $fail("Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs."); } } } diff --git a/tests/Unit/Network/DNSLookupTest.php b/tests/Unit/Network/DNSLookupTest.php deleted file mode 100644 index 88fafe2..0000000 --- a/tests/Unit/Network/DNSLookupTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertSame(['127.0.0.1'], $object->getIPs('127.0.0.1')); - } - - public function testEmptyArrayIsReturnedForInvalidHost() - { - $object = new DNSLookup(); - $this->assertSame([], $object->getIPs('invalid.host.with.bogus.tld')); - } - - public function testIpsAreReturned() - { - $object = new DNSLookup(); - $this->assertGreaterThan(5, $object->getIPs('joomla.org')); - } -} diff --git a/tests/Unit/Network/NetworkHelperTest.php b/tests/Unit/Network/NetworkHelperTest.php new file mode 100644 index 0000000..79ff790 --- /dev/null +++ b/tests/Unit/Network/NetworkHelperTest.php @@ -0,0 +1,59 @@ +assertSame(['127.0.0.1'], $object->getIPs('127.0.0.1')); + } + + public function testEmptyArrayIsReturnedForInvalidHost() + { + $object = new NetworkHelper(); + $this->assertSame([], $object->getIPs('invalid.host.with.bogus.tld')); + } + + public function testIpsAreReturned() + { + $object = new NetworkHelper(); + $this->assertGreaterThan(5, $object->getIPs('joomla.org')); + } + + #[DataProvider('hostnameDataProvider')] + public function testRemoteIpsAreValidate($ip, $result) + { + $object = new NetworkHelper(); + $this->assertEquals($result, $object->isValidRemoteHost($ip)); + } + + #[DataProvider('ipDataProvider')] + public function testLocalIpsAreForbidden($ip, $result) + { + $object = new NetworkHelper(); + $this->assertEquals($result, $object->isValidRemoteIp($ip)); + } + + public static function hostnameDataProvider(): array + { + return [ + ['localhost', false], + ['joomla.org', true] + ]; + } + + public static function ipDataProvider(): array + { + return [ + ['127.0.0.1', false], + ['10.0.0.1', false], + ['8.8.8.8', true] + ]; + } +} diff --git a/tests/Unit/Rules/RemoteURLTest.php b/tests/Unit/Rules/RemoteURLTest.php index 860ecf6..d6439b0 100644 --- a/tests/Unit/Rules/RemoteURLTest.php +++ b/tests/Unit/Rules/RemoteURLTest.php @@ -28,11 +28,11 @@ public function testRuleHandlesIpsAndHosts($host, $expectedResult, $expectedMess public static function urlDataProvider(): array { return [ - ['https://127.0.0.1', false, 'Invalid URL: local address are disallowed as site URL.'], - ['https://localhost', false, 'Invalid URL: local address are disallowed as site URL.'], - ['https://10.0.0.1', false, 'Invalid URL: local address are disallowed as site URL.'], - ['https://joomla.org', true, ''], - ['https://invalid.host.tld', false,'Invalid URL: unresolvable site URL.'], + ['https://127.0.0.1', false, 'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://localhost', false, 'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://10.0.0.1', false, 'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://invalid.host.tld', false,'Invalid URL: please provide a valid, resolvable Host that does not resolve to local IPs.'], + ['https://joomla.org', true, ''] ]; } }