Skip to content

Commit d8f4575

Browse files
authored
[5.x] Harden remote URL validation (#14761)
1 parent f17c098 commit d8f4575

4 files changed

Lines changed: 324 additions & 53 deletions

File tree

src/Imaging/GuzzleAdapter.php

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use GuzzleHttp\ClientInterface;
77
use GuzzleHttp\Exception\BadResponseException;
88
use GuzzleHttp\Exception\ClientException;
9+
use GuzzleHttp\Psr7\Uri;
10+
use GuzzleHttp\Psr7\UriResolver;
911
use League\Flysystem\Config;
1012
use League\Flysystem\FileAttributes;
1113
use League\Flysystem\FilesystemAdapter;
@@ -14,6 +16,13 @@
1416

1517
class GuzzleAdapter implements FilesystemAdapter
1618
{
19+
/**
20+
* The maximum number of redirects to follow.
21+
*
22+
* @var int
23+
*/
24+
const MAX_REDIRECTS = 5;
25+
1726
/**
1827
* Whether this endpoint supports head requests.
1928
*
@@ -149,18 +158,12 @@ public function visibility(string $path): FileAttributes
149158
protected function get($path)
150159
{
151160
try {
152-
$response = $this->client->get($this->base.$path, $this->requestOptions());
153-
} catch (InvalidRemoteUrlException $e) {
154-
throw $e;
161+
$response = $this->send('GET', $this->base.$path);
155162
} catch (BadResponseException $e) {
156163
return false;
157164
}
158165

159-
if ($response->getStatusCode() !== 200) {
160-
return false;
161-
}
162-
163-
return $response;
166+
return $response->getStatusCode() === 200 ? $response : false;
164167
}
165168

166169
/**
@@ -176,7 +179,7 @@ protected function head($path)
176179
}
177180

178181
try {
179-
$response = $this->client->head($this->base.$path, $this->requestOptions());
182+
$response = $this->send('HEAD', $this->base.$path);
180183
} catch (ClientException $e) {
181184
if ($e->getResponse()->getStatusCode() === 405) {
182185
$this->supportsHead = false;
@@ -185,27 +188,54 @@ protected function head($path)
185188
}
186189

187190
return false;
188-
} catch (InvalidRemoteUrlException $e) {
189-
throw $e;
190191
} catch (BadResponseException $e) {
191192
return false;
192193
}
193194

194-
if ($response->getStatusCode() !== 200) {
195-
return false;
195+
return $response->getStatusCode() === 200 ? $response : false;
196+
}
197+
198+
/**
199+
* Send a request, pinning the connection to the validated IP so the host
200+
* cannot be rebound to an internal address between validation and the
201+
* actual fetch. Redirects are followed manually so each hop is validated
202+
* and pinned too.
203+
*/
204+
protected function send(string $method, string $url, int $redirects = 0)
205+
{
206+
// The connection is pinned to the validated IP via curl's CURLOPT_RESOLVE,
207+
// which the stream handler has no equivalent for. Rather than silently fall
208+
// back to an unpinned (rebindable) request, refuse to fetch without curl.
209+
if (! $this->supportsConnectionPinning()) {
210+
throw new \RuntimeException('The curl PHP extension is required to fetch remote images.');
211+
}
212+
213+
$resolved = app(RemoteUrlValidator::class)->resolve($url);
214+
215+
$response = $this->client->request($method, $url, [
216+
'allow_redirects' => false,
217+
'curl' => [
218+
CURLOPT_RESOLVE => [sprintf('%s:%d:%s', $resolved['host'], $resolved['port'], implode(',', $resolved['ips']))],
219+
],
220+
]);
221+
222+
$status = $response->getStatusCode();
223+
224+
if ($status >= 300 && $status < 400 && $response->hasHeader('Location')) {
225+
if ($redirects >= self::MAX_REDIRECTS) {
226+
throw new InvalidRemoteUrlException('Too many redirects.');
227+
}
228+
229+
$location = UriResolver::resolve(new Uri($url), new Uri($response->getHeaderLine('Location')));
230+
231+
return $this->send($method, (string) $location, $redirects + 1);
196232
}
197233

198234
return $response;
199235
}
200236

201-
protected function requestOptions()
237+
protected function supportsConnectionPinning(): bool
202238
{
203-
return [
204-
'allow_redirects' => [
205-
'on_redirect' => function ($request, $response, $uri) {
206-
app(RemoteUrlValidator::class)->validate((string) $uri);
207-
},
208-
],
209-
];
239+
return extension_loaded('curl');
210240
}
211241
}

src/Imaging/RemoteUrlValidator.php

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,39 @@ public function __construct(?callable $resolver = null)
1515
}
1616

1717
public function parse($url)
18+
{
19+
$components = $this->validatedComponents($url);
20+
21+
return [
22+
'path' => Str::after($components['path'], '/'),
23+
'base' => $components['scheme'].'://'.$components['host'].$components['port_suffix'],
24+
'query' => $components['query'],
25+
];
26+
}
27+
28+
public function validate($url)
29+
{
30+
$this->parse($url);
31+
}
32+
33+
/**
34+
* Resolve and validate the URL host, returning the host, port, and the
35+
* validated public IPs it resolves to. These IPs are intended to be pinned
36+
* to the actual connection so the host cannot be rebound to an internal
37+
* address between this check and the request being made.
38+
*/
39+
public function resolve($url)
40+
{
41+
$components = $this->validatedComponents($url);
42+
43+
return [
44+
'host' => $components['host'],
45+
'port' => $components['port'],
46+
'ips' => $components['ips'],
47+
];
48+
}
49+
50+
protected function validatedComponents($url)
1851
{
1952
$parsed = parse_url($url);
2053

@@ -48,22 +81,19 @@ public function parse($url)
4881
throw new InvalidRemoteUrlException('Invalid URL host.');
4982
}
5083

51-
$this->ensureHostResolvesToPublicIps($host);
52-
53-
$port = isset($parsed['port']) ? ':'.$parsed['port'] : '';
84+
$ips = $this->ensureHostResolvesToPublicIps($host);
5485

5586
return [
56-
'path' => Str::after($parsed['path'] ?? '/', '/'),
57-
'base' => $scheme.'://'.$host.$port,
87+
'scheme' => $scheme,
88+
'host' => $host,
89+
'port' => $parsed['port'] ?? ($scheme === 'https' ? 443 : 80),
90+
'port_suffix' => isset($parsed['port']) ? ':'.$parsed['port'] : '',
91+
'path' => $parsed['path'] ?? '/',
5892
'query' => $parsed['query'] ?? null,
93+
'ips' => $ips,
5994
];
6095
}
6196

62-
public function validate($url)
63-
{
64-
$this->parse($url);
65-
}
66-
6797
protected function isValidHost($host)
6898
{
6999
return filter_var($host, FILTER_VALIDATE_IP)
@@ -75,7 +105,7 @@ protected function ensureHostResolvesToPublicIps($host)
75105
if (filter_var($host, FILTER_VALIDATE_IP)) {
76106
$this->assertPublicIp($host);
77107

78-
return;
108+
return [$host];
79109
}
80110

81111
$records = call_user_func($this->resolver, $host);
@@ -90,6 +120,8 @@ protected function ensureHostResolvesToPublicIps($host)
90120
foreach ($ips as $ip) {
91121
$this->assertPublicIp($ip);
92122
}
123+
124+
return $ips;
93125
}
94126

95127
protected function assertPublicIp($ip)

tests/Imaging/GuzzleAdapterTest.php

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
namespace Tests\Imaging;
44

55
use GuzzleHttp\ClientInterface;
6-
use GuzzleHttp\Psr7\Request;
76
use GuzzleHttp\Psr7\Response;
8-
use GuzzleHttp\Psr7\Uri;
97
use Mockery;
108
use PHPUnit\Framework\Attributes\Test;
119
use Statamic\Exceptions\InvalidRemoteUrlException;
@@ -23,48 +21,88 @@ public function setUp(): void
2321
return new RemoteUrlValidator(function ($host) {
2422
return match ($host) {
2523
'example.com' => [['ip' => '93.184.216.34']],
24+
'cdn.example.com' => [['ip' => '93.184.216.35']],
2625
default => [],
2726
};
2827
});
2928
});
3029
}
3130

3231
#[Test]
33-
public function it_allows_redirects_when_every_hop_is_public()
32+
public function it_pins_the_connection_to_the_validated_ip()
3433
{
3534
$client = Mockery::mock(ClientInterface::class);
36-
$client->shouldReceive('get')->once()->andReturnUsing(function ($url, $options) {
37-
$this->assertEquals('https://example.com/foo.jpg', $url);
38-
$this->assertArrayHasKey('allow_redirects', $options);
39-
$this->assertArrayHasKey('on_redirect', $options['allow_redirects']);
35+
$client->shouldReceive('request')->once()->andReturnUsing(function ($method, $url, $options) {
36+
$this->assertSame('GET', $method);
37+
$this->assertSame('https://example.com/foo.jpg', $url);
38+
$this->assertFalse($options['allow_redirects']);
39+
$this->assertSame(['example.com:443:93.184.216.34'], $options['curl'][CURLOPT_RESOLVE]);
4040

41-
$options['allow_redirects']['on_redirect'](
42-
new Request('GET', 'https://example.com/foo.jpg'),
43-
new Response(302, ['Location' => 'https://example.com/redirected/foo.jpg']),
44-
new Uri('https://example.com/redirected/foo.jpg')
45-
);
41+
return new Response(200, [], 'image-bytes');
42+
});
43+
44+
$adapter = new GuzzleAdapter('https://example.com', $client);
45+
46+
$this->assertSame('image-bytes', $adapter->read('foo.jpg'));
47+
}
48+
49+
#[Test]
50+
public function it_resolves_the_host_once_and_pins_that_ip()
51+
{
52+
// DNS rebinding works by returning a public IP for the validation lookup,
53+
// then a private one for the connection's lookup. The fix resolves the host
54+
// a single time and pins that IP to the connection via CURLOPT_RESOLVE, so
55+
// the rebound answer below (127.0.0.1) is never reached. We assert both: the
56+
// pin is the public IP, and the resolver is consulted exactly once.
57+
$lookups = 0;
58+
$resolver = function () use (&$lookups) {
59+
return [['ip' => ++$lookups === 1 ? '93.184.216.34' : '127.0.0.1']];
60+
};
61+
62+
$this->app->bind(RemoteUrlValidator::class, fn () => new RemoteUrlValidator($resolver));
63+
64+
$client = Mockery::mock(ClientInterface::class);
65+
$client->shouldReceive('request')->once()->andReturnUsing(function ($method, $url, $options) {
66+
$this->assertSame(['example.com:443:93.184.216.34'], $options['curl'][CURLOPT_RESOLVE]);
4667

4768
return new Response(200, [], 'image-bytes');
4869
});
4970

5071
$adapter = new GuzzleAdapter('https://example.com', $client);
5172

73+
$this->assertSame('image-bytes', $adapter->read('foo.jpg'));
74+
$this->assertSame(1, $lookups, 'The host should be resolved once; the connection must reuse that result, not re-resolve.');
75+
}
76+
77+
#[Test]
78+
public function it_follows_redirects_and_pins_every_hop()
79+
{
80+
$client = Mockery::mock(ClientInterface::class);
81+
82+
$client->shouldReceive('request')->once()
83+
->with('GET', 'https://example.com/foo.jpg', Mockery::on(function ($options) {
84+
return $options['curl'][CURLOPT_RESOLVE] === ['example.com:443:93.184.216.34'];
85+
}))
86+
->andReturn(new Response(302, ['Location' => 'https://cdn.example.com/foo.jpg']));
87+
88+
$client->shouldReceive('request')->once()
89+
->with('GET', 'https://cdn.example.com/foo.jpg', Mockery::on(function ($options) {
90+
return $options['curl'][CURLOPT_RESOLVE] === ['cdn.example.com:443:93.184.216.35'];
91+
}))
92+
->andReturn(new Response(200, [], 'image-bytes'));
93+
94+
$adapter = new GuzzleAdapter('https://example.com', $client);
95+
5296
$this->assertSame('image-bytes', $adapter->read('foo.jpg'));
5397
}
5498

5599
#[Test]
56100
public function it_blocks_redirects_to_non_public_destinations()
57101
{
58102
$client = Mockery::mock(ClientInterface::class);
59-
$client->shouldReceive('get')->once()->andReturnUsing(function ($url, $options) {
60-
$options['allow_redirects']['on_redirect'](
61-
new Request('GET', 'https://example.com/foo.jpg'),
62-
new Response(302, ['Location' => 'http://169.254.169.254/latest/meta-data/']),
63-
new Uri('http://169.254.169.254/latest/meta-data/')
64-
);
65-
66-
return new Response(200, [], 'should-not-return');
67-
});
103+
$client->shouldReceive('request')->once()->andReturn(
104+
new Response(302, ['Location' => 'http://169.254.169.254/latest/meta-data/'])
105+
);
68106

69107
$adapter = new GuzzleAdapter('https://example.com', $client);
70108

@@ -73,4 +111,41 @@ public function it_blocks_redirects_to_non_public_destinations()
73111

74112
$adapter->read('foo.jpg');
75113
}
114+
115+
#[Test]
116+
public function it_refuses_to_fetch_when_curl_is_unavailable()
117+
{
118+
// Without curl the connection can't be pinned to the validated IP, so rather
119+
// than silently fall back to a rebindable request we refuse to fetch at all.
120+
$client = Mockery::mock(ClientInterface::class);
121+
$client->shouldNotReceive('request');
122+
123+
$adapter = new class('https://example.com', $client) extends GuzzleAdapter
124+
{
125+
protected function supportsConnectionPinning(): bool
126+
{
127+
return false;
128+
}
129+
};
130+
131+
$this->expectException(\RuntimeException::class);
132+
$this->expectExceptionMessage('curl PHP extension is required');
133+
134+
$adapter->read('foo.jpg');
135+
}
136+
137+
#[Test]
138+
public function it_stops_following_redirects_after_the_limit()
139+
{
140+
$client = Mockery::mock(ClientInterface::class);
141+
$client->shouldReceive('request')
142+
->andReturn(new Response(302, ['Location' => 'https://example.com/foo.jpg']));
143+
144+
$adapter = new GuzzleAdapter('https://example.com', $client);
145+
146+
$this->expectException(InvalidRemoteUrlException::class);
147+
$this->expectExceptionMessage('Too many redirects.');
148+
149+
$adapter->read('foo.jpg');
150+
}
76151
}

0 commit comments

Comments
 (0)