Skip to content

Commit 1566276

Browse files
committed
Merge branch '5.x' into 6.x
# Conflicts: # .github/workflows/pint-lint.yml # .github/workflows/release.yml # .github/workflows/tests.yml # CHANGELOG.md # composer.json # resources/js/components/fieldtypes/assets/AssetRow.vue # resources/js/components/fieldtypes/assets/AssetTile.vue
2 parents 00608c0 + d8f4575 commit 1566276

6 files changed

Lines changed: 357 additions & 53 deletions

File tree

src/Forms/Exporters/CsvExporter.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Statamic\Forms\Exporters;
44

5+
use League\Csv\EscapeFormula;
56
use League\Csv\Writer;
67
use SplTempFileObject;
78
use Statamic\Support\Arr;
@@ -17,6 +18,7 @@ public function export(): string
1718
{
1819
$this->writer = Writer::createFromFileObject(new SplTempFileObject);
1920
$this->writer->setDelimiter(Arr::get($this->config, 'delimiter', config('statamic.forms.csv_delimiter', ',')));
21+
$this->writer->addFormatter(new EscapeFormula("'"));
2022

2123
$this->insertHeaders();
2224

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/Forms/CsvExporterTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Tests\Forms;
4+
5+
use PHPUnit\Framework\Attributes\Test;
6+
use Statamic\Facades\Blueprint;
7+
use Statamic\Facades\Form;
8+
use Statamic\Facades\FormSubmission;
9+
use Statamic\Forms\Exporters\CsvExporter;
10+
use Tests\PreventSavingStacheItemsToDisk;
11+
use Tests\TestCase;
12+
13+
class CsvExporterTest extends TestCase
14+
{
15+
use PreventSavingStacheItemsToDisk;
16+
17+
#[Test]
18+
public function it_neutralizes_formula_injection_in_submission_values()
19+
{
20+
$blueprint = Blueprint::makeFromFields(['name' => ['type' => 'text']]);
21+
Blueprint::shouldReceive('find')->with('forms.test')->andReturn($blueprint);
22+
23+
$form = tap(Form::make('test'))->save();
24+
FormSubmission::make()->form($form)->data(['name' => '=1+1'])->save();
25+
26+
$csv = (new CsvExporter)->setForm($form)->setConfig([])->export();
27+
28+
$this->assertStringContainsString('\'=1+1', $csv);
29+
$this->assertStringNotContainsString('"=1+1', $csv);
30+
}
31+
}

0 commit comments

Comments
 (0)