Skip to content

Commit b9cc548

Browse files
committed
Merge branch '5.x' into 6.x
# Conflicts: # src/Exceptions/Concerns/RendersControlPanelExceptions.php
2 parents 4524847 + 3c008e0 commit b9cc548

11 files changed

Lines changed: 301 additions & 12 deletions

src/Exceptions/Concerns/RendersControlPanelExceptions.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Http\JsonResponse;
77
use Illuminate\Http\RedirectResponse;
88
use Inertia\Inertia;
9+
use Statamic\Facades\URL;
910
use Statamic\Statamic;
1011

1112
trait RendersControlPanelExceptions
@@ -35,7 +36,7 @@ protected function getAuthExceptionRedirectUrl()
3536

3637
// If we came to this URL from another, we'll send them back, but not
3738
// if it was the login page otherwise there'd be a redirect loop.
38-
if ($referrer && $referrer != cp_route('login')) {
39+
if ($referrer && $referrer != cp_route('login') && ! URL::isExternalToApplication($referrer)) {
3940
return $referrer;
4041
}
4142

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Statamic\Exceptions;
4+
5+
class InvalidRemoteUrlException extends \InvalidArgumentException
6+
{
7+
}

src/Http/Controllers/FormController.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ private function formFailure($params, $errors, $form)
118118

119119
$redirect = Arr::get($params, '_error_redirect');
120120

121-
$response = $redirect ? redirect($redirect) : back();
121+
$response = $redirect && ! \Statamic\Facades\URL::isExternalToApplication($redirect)
122+
? redirect($redirect)
123+
: back();
122124

123125
return $response->withInput()->withErrors($errors, 'form.'.$form);
124126
}
@@ -146,7 +148,9 @@ private function formSuccess($params, $submission, $silentFailure = false)
146148
]);
147149
}
148150

149-
$response = $redirect ? redirect($redirect) : back();
151+
$response = $redirect && ! \Statamic\Facades\URL::isExternalToApplication($redirect)
152+
? redirect($redirect)
153+
: back();
150154

151155
if (! \Statamic\Facades\URL::isExternal($redirect)) {
152156
session()->flash("form.{$submission->form()->handle()}.success", __('Submission successful.'));

src/Http/Controllers/GlideController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use League\Glide\Server;
88
use League\Glide\Signatures\SignatureException;
99
use League\Glide\Signatures\SignatureFactory;
10+
use Statamic\Exceptions\InvalidRemoteUrlException;
1011
use Statamic\Exceptions\NotFoundHttpException;
1112
use Statamic\Facades\Asset;
1213
use Statamic\Facades\AssetContainer;
@@ -113,6 +114,8 @@ private function generateBy($type, $item)
113114

114115
try {
115116
return $this->generator->$method($item, $this->request->all());
117+
} catch (InvalidRemoteUrlException $e) {
118+
abort(400, $e->getMessage());
116119
} catch (UnableToReadFile $e) {
117120
throw new NotFoundHttpException;
118121
}

src/Imaging/GuzzleAdapter.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use League\Flysystem\FileAttributes;
1111
use League\Flysystem\FilesystemAdapter;
1212
use League\Flysystem\UnableToReadFile;
13+
use Statamic\Exceptions\InvalidRemoteUrlException;
1314

1415
class GuzzleAdapter implements FilesystemAdapter
1516
{
@@ -148,7 +149,9 @@ public function visibility(string $path): FileAttributes
148149
protected function get($path)
149150
{
150151
try {
151-
$response = $this->client->get($this->base.$path);
152+
$response = $this->client->get($this->base.$path, $this->requestOptions());
153+
} catch (InvalidRemoteUrlException $e) {
154+
throw $e;
152155
} catch (BadResponseException $e) {
153156
return false;
154157
}
@@ -173,7 +176,7 @@ protected function head($path)
173176
}
174177

175178
try {
176-
$response = $this->client->head($this->base.$path);
179+
$response = $this->client->head($this->base.$path, $this->requestOptions());
177180
} catch (ClientException $e) {
178181
if ($e->getResponse()->getStatusCode() === 405) {
179182
$this->supportsHead = false;
@@ -182,6 +185,8 @@ protected function head($path)
182185
}
183186

184187
return false;
188+
} catch (InvalidRemoteUrlException $e) {
189+
throw $e;
185190
} catch (BadResponseException $e) {
186191
return false;
187192
}
@@ -192,4 +197,15 @@ protected function head($path)
192197

193198
return $response;
194199
}
200+
201+
protected function requestOptions()
202+
{
203+
return [
204+
'allow_redirects' => [
205+
'on_redirect' => function ($request, $response, $uri) {
206+
app(RemoteUrlValidator::class)->validate((string) $uri);
207+
},
208+
],
209+
];
210+
}
195211
}

src/Imaging/ImageGenerator.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,6 @@ private function guzzleSourceFilesystem($base)
351351

352352
private function parseUrl($url)
353353
{
354-
$parsed = parse_url($url);
355-
356-
return [
357-
'path' => Str::after($parsed['path'], '/'),
358-
'base' => $parsed['scheme'].'://'.$parsed['host'],
359-
'query' => $parsed['query'] ?? null,
360-
];
354+
return app(RemoteUrlValidator::class)->parse($url);
361355
}
362356
}

src/Imaging/RemoteUrlValidator.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace Statamic\Imaging;
4+
5+
use Statamic\Exceptions\InvalidRemoteUrlException;
6+
use Statamic\Support\Str;
7+
8+
class RemoteUrlValidator
9+
{
10+
protected $resolver;
11+
12+
public function __construct(?callable $resolver = null)
13+
{
14+
$this->resolver = $resolver ?? fn ($host) => dns_get_record($host, DNS_A + DNS_AAAA) ?: [];
15+
}
16+
17+
public function parse($url)
18+
{
19+
$parsed = parse_url($url);
20+
21+
if (! is_array($parsed)) {
22+
throw new InvalidRemoteUrlException('Invalid URL.');
23+
}
24+
25+
$scheme = strtolower($parsed['scheme'] ?? '');
26+
27+
if (! in_array($scheme, ['http', 'https'])) {
28+
throw new InvalidRemoteUrlException('Only http and https URLs are allowed.');
29+
}
30+
31+
if (isset($parsed['user']) || isset($parsed['pass'])) {
32+
throw new InvalidRemoteUrlException('URLs with credentials are not allowed.');
33+
}
34+
35+
$host = $parsed['host'] ?? null;
36+
37+
if (! is_string($host) || $host === '') {
38+
throw new InvalidRemoteUrlException('URL host is required.');
39+
}
40+
41+
$host = Str::lower(trim($host));
42+
43+
if ($host !== trim($host, '.')) {
44+
throw new InvalidRemoteUrlException('Invalid URL host.');
45+
}
46+
47+
if (! $this->isValidHost($host)) {
48+
throw new InvalidRemoteUrlException('Invalid URL host.');
49+
}
50+
51+
$this->ensureHostResolvesToPublicIps($host);
52+
53+
$port = isset($parsed['port']) ? ':'.$parsed['port'] : '';
54+
55+
return [
56+
'path' => Str::after($parsed['path'] ?? '/', '/'),
57+
'base' => $scheme.'://'.$host.$port,
58+
'query' => $parsed['query'] ?? null,
59+
];
60+
}
61+
62+
public function validate($url)
63+
{
64+
$this->parse($url);
65+
}
66+
67+
protected function isValidHost($host)
68+
{
69+
return filter_var($host, FILTER_VALIDATE_IP)
70+
|| filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
71+
}
72+
73+
protected function ensureHostResolvesToPublicIps($host)
74+
{
75+
if (filter_var($host, FILTER_VALIDATE_IP)) {
76+
$this->assertPublicIp($host);
77+
78+
return;
79+
}
80+
81+
$records = call_user_func($this->resolver, $host);
82+
$ips = collect($records)->flatMap(function ($record) {
83+
return [$record['ip'] ?? null, $record['ipv6'] ?? null];
84+
})->filter()->values()->all();
85+
86+
if (empty($ips)) {
87+
throw new InvalidRemoteUrlException('Unable to resolve URL host.');
88+
}
89+
90+
foreach ($ips as $ip) {
91+
$this->assertPublicIp($ip);
92+
}
93+
}
94+
95+
protected function assertPublicIp($ip)
96+
{
97+
$result = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
98+
99+
if (! $result) {
100+
throw new InvalidRemoteUrlException('Destination IP is not publicly routable.');
101+
}
102+
}
103+
}

tests/CP/AuthRedirectTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ public function it_redirects_somewhere_if_the_referrer_was_the_login_page()
6868
->assertSessionHas(['error' => "Can't touch this."]);
6969
}
7070

71+
#[Test]
72+
public function it_does_not_redirect_to_external_referrer()
73+
{
74+
$this->setTestRoles(['test' => ['access cp']]);
75+
$user = tap(User::make()->assignRole('test'))->save();
76+
77+
$this
78+
->actingAs($user)
79+
->withHeaders(['referer' => 'https://external.com'])
80+
->get('/cp/hammertime')
81+
->assertRedirect(cp_route('index'))
82+
->assertSessionHas(['error' => "Can't touch this."]);
83+
}
84+
7185
#[Test]
7286
public function it_redirects_to_unauthorized_view_if_there_would_be_a_redirect_loop()
7387
{
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace Tests\Imaging;
4+
5+
use GuzzleHttp\ClientInterface;
6+
use GuzzleHttp\Psr7\Request;
7+
use GuzzleHttp\Psr7\Response;
8+
use GuzzleHttp\Psr7\Uri;
9+
use Mockery;
10+
use PHPUnit\Framework\Attributes\Test;
11+
use Statamic\Exceptions\InvalidRemoteUrlException;
12+
use Statamic\Imaging\GuzzleAdapter;
13+
use Statamic\Imaging\RemoteUrlValidator;
14+
use Tests\TestCase;
15+
16+
class GuzzleAdapterTest extends TestCase
17+
{
18+
public function setUp(): void
19+
{
20+
parent::setUp();
21+
22+
$this->app->bind(RemoteUrlValidator::class, function () {
23+
return new RemoteUrlValidator(function ($host) {
24+
return match ($host) {
25+
'example.com' => [['ip' => '93.184.216.34']],
26+
default => [],
27+
};
28+
});
29+
});
30+
}
31+
32+
#[Test]
33+
public function it_allows_redirects_when_every_hop_is_public()
34+
{
35+
$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']);
40+
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+
);
46+
47+
return new Response(200, [], 'image-bytes');
48+
});
49+
50+
$adapter = new GuzzleAdapter('https://example.com', $client);
51+
52+
$this->assertSame('image-bytes', $adapter->read('foo.jpg'));
53+
}
54+
55+
#[Test]
56+
public function it_blocks_redirects_to_non_public_destinations()
57+
{
58+
$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+
});
68+
69+
$adapter = new GuzzleAdapter('https://example.com', $client);
70+
71+
$this->expectException(InvalidRemoteUrlException::class);
72+
$this->expectExceptionMessage('Destination IP is not publicly routable.');
73+
74+
$adapter->read('foo.jpg');
75+
}
76+
}

tests/Imaging/ImageGeneratorTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
use PHPUnit\Framework\Attributes\DataProvider;
1717
use PHPUnit\Framework\Attributes\Test;
1818
use Statamic\Events\GlideImageGenerated;
19+
use Statamic\Exceptions\InvalidRemoteUrlException;
1920
use Statamic\Facades\AssetContainer;
2021
use Statamic\Facades\File;
2122
use Statamic\Facades\Glide;
2223
use Statamic\Imaging\GuzzleAdapter;
2324
use Statamic\Imaging\ImageGenerator;
25+
use Statamic\Imaging\RemoteUrlValidator;
2426
use Statamic\Support\Str;
2527
use Tests\PreventSavingStacheItemsToDisk;
2628
use Tests\TestCase;
@@ -34,6 +36,16 @@ public function setUp(): void
3436
parent::setUp();
3537

3638
$this->clearGlideCache();
39+
40+
$this->app->bind(RemoteUrlValidator::class, function () {
41+
return new RemoteUrlValidator(function ($host) {
42+
return match ($host) {
43+
'example.com' => [['ip' => '93.184.216.34']],
44+
'internal.test' => [['ip' => '127.0.0.1']],
45+
default => [],
46+
};
47+
});
48+
});
3749
}
3850

3951
#[Test]
@@ -278,6 +290,24 @@ public function it_generates_an_image_by_external_url_with_query_string()
278290
Event::assertDispatchedTimes(GlideImageGenerated::class, 1);
279291
}
280292

293+
#[Test]
294+
public function it_blocks_external_urls_that_target_non_public_ip_ranges()
295+
{
296+
$this->expectException(InvalidRemoteUrlException::class);
297+
$this->expectExceptionMessage('Destination IP is not publicly routable.');
298+
299+
$this->makeGenerator()->generateByUrl('http://169.254.169.254/latest/meta-data/', ['w' => 100]);
300+
}
301+
302+
#[Test]
303+
public function it_blocks_watermark_urls_that_target_non_public_ip_ranges()
304+
{
305+
$this->expectException(InvalidRemoteUrlException::class);
306+
$this->expectExceptionMessage('Destination IP is not publicly routable.');
307+
308+
$this->makeGenerator()->setParams(['mark' => 'http://127.0.0.1/watermark.png']);
309+
}
310+
281311
#[Test]
282312
public function the_watermark_disk_is_the_public_directory_by_default()
283313
{

0 commit comments

Comments
 (0)