fix(security): re-validate SSRF on every redirect hop in SafeHttpFetcher

The shared fetcher guarded only the initial URL, then followed redirects without re-checking each hop, so a public page could 302 to an internal address. Follow redirects manually and run the SSRF guard on every hop; throw when the redirect cap is exceeded. Also hardens brand autofill and logo downloads.
This commit is contained in:
Paulo Castellano 2026-07-17 14:32:40 -03:00
parent f84c6f818a
commit 9bb1f266e9
2 changed files with 103 additions and 7 deletions

View file

@ -8,6 +8,7 @@
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Symfony\Component\DomCrawler\UriResolver;
/**
* HTTP fetcher with SSRF protection, timeout, redirect cap and a branded user-agent.
@ -36,13 +37,38 @@ public function get(string $url): Response
{
$this->guardAgainstSsrf($url);
try {
$response = Http::timeout(self::TIMEOUT_SECONDS)
->withUserAgent(self::USER_AGENT)
->withOptions(['allow_redirects' => ['max' => self::MAX_REDIRECTS]])
->get($url);
} catch (ConnectionException $e) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
$currentUrl = $url;
// Redirects are followed manually (allow_redirects disabled) so that every
// hop's Location target is re-validated against the SSRF guard before it is
// ever requested. A public page could otherwise 302 to an internal host and
// Guzzle's built-in redirect following would fetch it without re-checking.
for ($hop = 0; ; $hop++) {
try {
$response = Http::timeout(self::TIMEOUT_SECONDS)
->withUserAgent(self::USER_AGENT)
->withOptions(['allow_redirects' => false])
->get($currentUrl);
} catch (ConnectionException $e) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
}
if (! $response->redirect() || $hop >= self::MAX_REDIRECTS) {
break;
}
$location = $response->header('Location');
if ($location === '') {
break;
}
$currentUrl = (string) UriResolver::resolve($location, $currentUrl);
$this->guardAgainstSsrf($currentUrl);
}
if ($response->redirect()) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => 'too many redirects']));
}
if ($response->failed()) {

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Support\Facades\Http;
// Public IP literals let SafeHttpFetcher's SSRF guard pass without a real DNS
// lookup; Http::fake intercepts the request before any network I/O.
test('blocks a redirect whose location resolves to a private ip', function () {
Http::fake([
// The malicious hop responds successfully (not with an error/exception) so a
// vulnerable implementation that auto-follows it would return this body —
// proving the guard, not an unrelated stray-request failure, stopped it.
'https://93.184.216.34/start' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
'http://127.0.0.1/internal' => Http::response('internal secret', 200),
]);
expect(app(SafeHttpFetcher::class)->tryGet('https://93.184.216.34/start'))->toBeNull();
Http::assertSent(fn ($request) => str_contains($request->url(), '93.184.216.34'));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
test('follows a legitimate redirect from one public host to another', function () {
Http::fake([
'https://93.184.216.34/start' => Http::response('', 301, ['Location' => 'https://1.1.1.1/final']),
'https://1.1.1.1/final' => Http::response('final body', 200),
]);
$response = app(SafeHttpFetcher::class)->get('https://93.184.216.34/start');
expect($response->status())->toBe(200)
->and($response->body())->toBe('final body');
Http::assertSentInOrder([
fn ($request) => str_contains($request->url(), '93.184.216.34'),
fn ($request) => str_contains($request->url(), '1.1.1.1'),
]);
});
test('resolves a relative location header against the current url before guarding', function () {
Http::fake([
'https://93.184.216.34/start' => Http::response('', 302, ['Location' => '/final']),
'https://93.184.216.34/final' => Http::response('final body', 200),
]);
$response = app(SafeHttpFetcher::class)->get('https://93.184.216.34/start');
expect($response->status())->toBe(200)
->and($response->body())->toBe('final body');
});
test('throws when a redirect chain exceeds the redirect cap', function () {
Http::fake([
'https://93.184.216.34/start' => Http::response('', 302, ['Location' => 'https://93.184.216.35/hop']),
'https://93.184.216.35/hop' => Http::response('', 302, ['Location' => 'https://93.184.216.36/hop']),
'https://93.184.216.36/hop' => Http::response('', 302, ['Location' => 'https://93.184.216.37/hop']),
'https://93.184.216.37/hop' => Http::response('', 302, ['Location' => 'https://93.184.216.38/hop']),
]);
expect(fn () => app(SafeHttpFetcher::class)->get('https://93.184.216.34/start'))
->toThrow(RuntimeException::class);
expect(app(SafeHttpFetcher::class)->tryGet('https://93.184.216.34/start'))->toBeNull();
// The cap (3) is hit after following 3 redirects (4 requests); the next hop must never fire.
Http::assertNotSent(fn ($request) => str_contains($request->url(), '93.184.216.38'));
});