annotate core/modules/rest/tests/src/Functional/FileUploadResourceTestBase.php @ 5:12f9dff5fda9 tip

Update to Drupal core 8.7.1
author Chris Cannam
date Thu, 09 May 2019 15:34:47 +0100
parents a9cd425dd02b
children
rev   line source
Chris@4 1 <?php
Chris@4 2
Chris@4 3 namespace Drupal\Tests\rest\Functional;
Chris@4 4
Chris@4 5 use Drupal\Component\Render\PlainTextOutput;
Chris@4 6 use Drupal\Component\Utility\NestedArray;
Chris@4 7 use Drupal\Core\Field\FieldStorageDefinitionInterface;
Chris@4 8 use Drupal\Core\Url;
Chris@4 9 use Drupal\entity_test\Entity\EntityTest;
Chris@4 10 use Drupal\field\Entity\FieldConfig;
Chris@4 11 use Drupal\field\Entity\FieldStorageConfig;
Chris@4 12 use Drupal\file\Entity\File;
Chris@4 13 use Drupal\rest\RestResourceConfigInterface;
Chris@4 14 use Drupal\user\Entity\User;
Chris@4 15 use GuzzleHttp\RequestOptions;
Chris@4 16 use Psr\Http\Message\ResponseInterface;
Chris@4 17
Chris@4 18 /**
Chris@4 19 * Tests binary data file upload route.
Chris@4 20 */
Chris@4 21 abstract class FileUploadResourceTestBase extends ResourceTestBase {
Chris@4 22
Chris@4 23 use BcTimestampNormalizerUnixTestTrait;
Chris@4 24
Chris@4 25 /**
Chris@4 26 * {@inheritdoc}
Chris@4 27 */
Chris@4 28 public static $modules = ['rest_test', 'entity_test', 'file'];
Chris@4 29
Chris@4 30 /**
Chris@4 31 * {@inheritdoc}
Chris@4 32 */
Chris@4 33 protected static $resourceConfigId = 'file.upload';
Chris@4 34
Chris@4 35 /**
Chris@4 36 * The POST URI.
Chris@4 37 *
Chris@4 38 * @var string
Chris@4 39 */
Chris@4 40 protected static $postUri = 'file/upload/entity_test/entity_test/field_rest_file_test';
Chris@4 41
Chris@4 42 /**
Chris@4 43 * Test file data.
Chris@4 44 *
Chris@4 45 * @var string
Chris@4 46 */
Chris@4 47 protected $testFileData = 'Hares sit on chairs, and mules sit on stools.';
Chris@4 48
Chris@4 49 /**
Chris@4 50 * The test field storage config.
Chris@4 51 *
Chris@4 52 * @var \Drupal\field\Entity\FieldStorageConfig
Chris@4 53 */
Chris@4 54 protected $fieldStorage;
Chris@4 55
Chris@4 56 /**
Chris@4 57 * The field config.
Chris@4 58 *
Chris@4 59 * @var \Drupal\field\Entity\FieldConfig
Chris@4 60 */
Chris@4 61 protected $field;
Chris@4 62
Chris@4 63 /**
Chris@4 64 * The parent entity.
Chris@4 65 *
Chris@4 66 * @var \Drupal\Core\Entity\EntityInterface
Chris@4 67 */
Chris@4 68 protected $entity;
Chris@4 69
Chris@4 70 /**
Chris@4 71 * Created file entity.
Chris@4 72 *
Chris@4 73 * @var \Drupal\file\Entity\File
Chris@4 74 */
Chris@4 75 protected $file;
Chris@4 76
Chris@4 77 /**
Chris@4 78 * An authenticated user.
Chris@4 79 *
Chris@4 80 * @var \Drupal\user\UserInterface
Chris@4 81 */
Chris@4 82 protected $user;
Chris@4 83
Chris@4 84 /**
Chris@4 85 * The entity storage for the 'file' entity type.
Chris@4 86 *
Chris@4 87 * @var \Drupal\Core\Entity\EntityStorageInterface
Chris@4 88 */
Chris@4 89 protected $fileStorage;
Chris@4 90
Chris@4 91 /**
Chris@4 92 * {@inheritdoc}
Chris@4 93 */
Chris@4 94 public function setUp() {
Chris@4 95 parent::setUp();
Chris@4 96
Chris@4 97 $this->fileStorage = $this->container->get('entity_type.manager')
Chris@4 98 ->getStorage('file');
Chris@4 99
Chris@4 100 // Add a file field.
Chris@4 101 $this->fieldStorage = FieldStorageConfig::create([
Chris@4 102 'entity_type' => 'entity_test',
Chris@4 103 'field_name' => 'field_rest_file_test',
Chris@4 104 'type' => 'file',
Chris@4 105 'settings' => [
Chris@4 106 'uri_scheme' => 'public',
Chris@4 107 ],
Chris@4 108 ])
Chris@4 109 ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
Chris@4 110 $this->fieldStorage->save();
Chris@4 111
Chris@4 112 $this->field = FieldConfig::create([
Chris@4 113 'entity_type' => 'entity_test',
Chris@4 114 'field_name' => 'field_rest_file_test',
Chris@4 115 'bundle' => 'entity_test',
Chris@4 116 'settings' => [
Chris@4 117 'file_directory' => 'foobar',
Chris@4 118 'file_extensions' => 'txt',
Chris@4 119 'max_filesize' => '',
Chris@4 120 ],
Chris@4 121 ])
Chris@4 122 ->setLabel('Test file field')
Chris@4 123 ->setTranslatable(FALSE);
Chris@4 124 $this->field->save();
Chris@4 125
Chris@4 126 // Create an entity that a file can be attached to.
Chris@4 127 $this->entity = EntityTest::create([
Chris@4 128 'name' => 'Llama',
Chris@4 129 'type' => 'entity_test',
Chris@4 130 ]);
Chris@4 131 $this->entity->setOwnerId(isset($this->account) ? $this->account->id() : 0);
Chris@4 132 $this->entity->save();
Chris@4 133
Chris@4 134 // Provision entity_test resource.
Chris@4 135 $this->resourceConfigStorage->create([
Chris@4 136 'id' => 'entity.entity_test',
Chris@4 137 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
Chris@4 138 'configuration' => [
Chris@4 139 'methods' => ['POST'],
Chris@4 140 'formats' => [static::$format],
Chris@4 141 'authentication' => [static::$auth],
Chris@4 142 ],
Chris@4 143 'status' => TRUE,
Chris@4 144 ])->save();
Chris@4 145
Chris@4 146 $this->refreshTestStateAfterRestConfigChange();
Chris@4 147 }
Chris@4 148
Chris@4 149 /**
Chris@4 150 * Tests using the file upload POST route.
Chris@4 151 */
Chris@4 152 public function testPostFileUpload() {
Chris@4 153 $this->initAuthentication();
Chris@4 154
Chris@4 155 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 156
Chris@4 157 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 158
Chris@4 159 // DX: 403 when unauthorized.
Chris@4 160 $response = $this->fileRequest($uri, $this->testFileData);
Chris@4 161 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
Chris@4 162
Chris@4 163 $this->setUpAuthorization('POST');
Chris@4 164
Chris@4 165 // 404 when the field name is invalid.
Chris@4 166 $invalid_uri = Url::fromUri('base:file/upload/entity_test/entity_test/field_rest_file_test_invalid');
Chris@4 167 $response = $this->fileRequest($invalid_uri, $this->testFileData);
Chris@4 168 $this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist', $response);
Chris@4 169
Chris@4 170 // This request will have the default 'application/octet-stream' content
Chris@4 171 // type header.
Chris@4 172 $response = $this->fileRequest($uri, $this->testFileData);
Chris@4 173 $this->assertSame(201, $response->getStatusCode());
Chris@4 174 $expected = $this->getExpectedNormalizedEntity();
Chris@4 175 $this->assertResponseData($expected, $response);
Chris@4 176
Chris@4 177 // Check the actual file data.
Chris@4 178 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
Chris@4 179
Chris@4 180 // Test the file again but using 'filename' in the Content-Disposition
Chris@4 181 // header with no 'file' prefix.
Chris@4 182 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
Chris@4 183 $this->assertSame(201, $response->getStatusCode());
Chris@4 184 $expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt');
Chris@4 185 $this->assertResponseData($expected, $response);
Chris@4 186
Chris@4 187 // Check the actual file data.
Chris@4 188 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
Chris@4 189 $this->assertTrue($this->fileStorage->loadUnchanged(1)->isTemporary());
Chris@4 190
Chris@4 191 // Verify that we can create an entity that references the uploaded file.
Chris@4 192 $entity_test_post_url = Url::fromRoute('rest.entity.entity_test.POST')
Chris@4 193 ->setOption('query', ['_format' => static::$format]);
Chris@4 194 $request_options = [];
Chris@4 195 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
Chris@4 196 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
Chris@4 197
Chris@4 198 $request_options[RequestOptions::BODY] = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
Chris@4 199 $response = $this->request('POST', $entity_test_post_url, $request_options);
Chris@4 200 $this->assertResourceResponse(201, FALSE, $response);
Chris@4 201 $this->assertTrue($this->fileStorage->loadUnchanged(1)->isPermanent());
Chris@4 202 $this->assertSame([
Chris@4 203 [
Chris@4 204 'target_id' => '1',
Chris@4 205 'display' => NULL,
Chris@4 206 'description' => "The most fascinating file ever!",
Chris@4 207 ],
Chris@4 208 ], EntityTest::load(2)->get('field_rest_file_test')->getValue());
Chris@4 209 }
Chris@4 210
Chris@4 211 /**
Chris@4 212 * Returns the normalized POST entity referencing the uploaded file.
Chris@4 213 *
Chris@4 214 * @return array
Chris@4 215 *
Chris@4 216 * @see ::testPostFileUpload()
Chris@4 217 * @see \Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase::getNormalizedPostEntity()
Chris@4 218 */
Chris@4 219 protected function getNormalizedPostEntity() {
Chris@4 220 return [
Chris@4 221 'type' => [
Chris@4 222 [
Chris@4 223 'value' => 'entity_test',
Chris@4 224 ],
Chris@4 225 ],
Chris@4 226 'name' => [
Chris@4 227 [
Chris@4 228 'value' => 'Dramallama',
Chris@4 229 ],
Chris@4 230 ],
Chris@4 231 'field_rest_file_test' => [
Chris@4 232 [
Chris@4 233 'target_id' => 1,
Chris@4 234 'description' => 'The most fascinating file ever!',
Chris@4 235 ],
Chris@4 236 ],
Chris@4 237 ];
Chris@4 238 }
Chris@4 239
Chris@4 240 /**
Chris@4 241 * Tests using the file upload POST route with invalid headers.
Chris@4 242 */
Chris@4 243 public function testPostFileUploadInvalidHeaders() {
Chris@4 244 $this->initAuthentication();
Chris@4 245
Chris@4 246 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 247
Chris@4 248 $this->setUpAuthorization('POST');
Chris@4 249
Chris@4 250 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 251
Chris@4 252 // The wrong content type header should return a 415 code.
Chris@4 253 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => static::$mimeType]);
Chris@4 254 $this->assertResourceErrorResponse(415, sprintf('No route found that matches "Content-Type: %s"', static::$mimeType), $response);
Chris@4 255
Chris@4 256 // An empty Content-Disposition header should return a 400.
Chris@5 257 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => FALSE]);
Chris@4 258 $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided', $response);
Chris@4 259
Chris@4 260 // An empty filename with a context in the Content-Disposition header should
Chris@4 261 // return a 400.
Chris@4 262 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']);
Chris@4 263 $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response);
Chris@4 264
Chris@4 265 // An empty filename without a context in the Content-Disposition header
Chris@4 266 // should return a 400.
Chris@4 267 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']);
Chris@4 268 $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response);
Chris@4 269
Chris@4 270 // An invalid key-value pair in the Content-Disposition header should return
Chris@4 271 // a 400.
Chris@4 272 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']);
Chris@4 273 $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $response);
Chris@4 274
Chris@4 275 // Using filename* extended format is not currently supported.
Chris@4 276 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']);
Chris@4 277 $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header', $response);
Chris@4 278 }
Chris@4 279
Chris@4 280 /**
Chris@4 281 * Tests using the file upload POST route with a duplicate file name.
Chris@4 282 *
Chris@4 283 * A new file should be created with a suffixed name.
Chris@4 284 */
Chris@4 285 public function testPostFileUploadDuplicateFile() {
Chris@4 286 $this->initAuthentication();
Chris@4 287
Chris@4 288 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 289
Chris@4 290 $this->setUpAuthorization('POST');
Chris@4 291
Chris@4 292 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 293
Chris@4 294 // This request will have the default 'application/octet-stream' content
Chris@4 295 // type header.
Chris@4 296 $response = $this->fileRequest($uri, $this->testFileData);
Chris@4 297
Chris@4 298 $this->assertSame(201, $response->getStatusCode());
Chris@4 299
Chris@4 300 // Make the same request again. The file should be saved as a new file
Chris@4 301 // entity that has the same file name but a suffixed file URI.
Chris@4 302 $response = $this->fileRequest($uri, $this->testFileData);
Chris@4 303 $this->assertSame(201, $response->getStatusCode());
Chris@4 304
Chris@4 305 // Loading expected normalized data for file 2, the duplicate file.
Chris@4 306 $expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt');
Chris@4 307 $this->assertResponseData($expected, $response);
Chris@4 308
Chris@4 309 // Check the actual file data.
Chris@4 310 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
Chris@4 311 }
Chris@4 312
Chris@4 313 /**
Chris@4 314 * Tests using the file upload route with any path prefixes being stripped.
Chris@4 315 *
Chris@4 316 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Directives
Chris@4 317 */
Chris@4 318 public function testFileUploadStrippedFilePath() {
Chris@4 319 $this->initAuthentication();
Chris@4 320
Chris@4 321 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 322
Chris@4 323 $this->setUpAuthorization('POST');
Chris@4 324
Chris@4 325 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 326
Chris@4 327 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']);
Chris@4 328 $this->assertSame(201, $response->getStatusCode());
Chris@4 329 $expected = $this->getExpectedNormalizedEntity();
Chris@4 330 $this->assertResponseData($expected, $response);
Chris@4 331
Chris@4 332 // Check the actual file data. It should have been written to the configured
Chris@4 333 // directory, not /foobar/directory/example.txt.
Chris@4 334 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
Chris@4 335
Chris@4 336 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="../../example_2.txt"']);
Chris@4 337 $this->assertSame(201, $response->getStatusCode());
Chris@4 338 $expected = $this->getExpectedNormalizedEntity(2, 'example_2.txt', TRUE);
Chris@4 339 $this->assertResponseData($expected, $response);
Chris@4 340
Chris@4 341 // Check the actual file data. It should have been written to the configured
Chris@4 342 // directory, not /foobar/directory/example.txt.
Chris@4 343 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_2.txt'));
Chris@4 344 $this->assertFalse(file_exists('../../example_2.txt'));
Chris@4 345
Chris@4 346 // Check a path from the root. Extensions have to be empty to allow a file
Chris@4 347 // with no extension to pass validation.
Chris@4 348 $this->field->setSetting('file_extensions', '')
Chris@4 349 ->save();
Chris@4 350 $this->refreshTestStateAfterRestConfigChange();
Chris@4 351
Chris@4 352 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="/etc/passwd"']);
Chris@4 353 $this->assertSame(201, $response->getStatusCode());
Chris@4 354 $expected = $this->getExpectedNormalizedEntity(3, 'passwd', TRUE);
Chris@4 355 // This mime will be guessed as there is no extension.
Chris@4 356 $expected['filemime'][0]['value'] = 'application/octet-stream';
Chris@4 357 $this->assertResponseData($expected, $response);
Chris@4 358
Chris@4 359 // Check the actual file data. It should have been written to the configured
Chris@4 360 // directory, not /foobar/directory/example.txt.
Chris@4 361 $this->assertSame($this->testFileData, file_get_contents('public://foobar/passwd'));
Chris@4 362 }
Chris@4 363
Chris@4 364 /**
Chris@4 365 * Tests using the file upload route with a unicode file name.
Chris@4 366 */
Chris@4 367 public function testFileUploadUnicodeFilename() {
Chris@4 368 $this->initAuthentication();
Chris@4 369
Chris@4 370 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 371
Chris@4 372 $this->setUpAuthorization('POST');
Chris@4 373
Chris@4 374 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 375
Chris@5 376 // It is important that the filename starts with a unicode character. See
Chris@5 377 // https://bugs.php.net/bug.php?id=77239.
Chris@5 378 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="Èxample-✓.txt"']);
Chris@4 379 $this->assertSame(201, $response->getStatusCode());
Chris@5 380 $expected = $this->getExpectedNormalizedEntity(1, 'Èxample-✓.txt', TRUE);
Chris@4 381 $this->assertResponseData($expected, $response);
Chris@5 382 $this->assertSame($this->testFileData, file_get_contents('public://foobar/Èxample-✓.txt'));
Chris@4 383 }
Chris@4 384
Chris@4 385 /**
Chris@4 386 * Tests using the file upload route with a zero byte file.
Chris@4 387 */
Chris@4 388 public function testFileUploadZeroByteFile() {
Chris@4 389 $this->initAuthentication();
Chris@4 390
Chris@4 391 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 392
Chris@4 393 $this->setUpAuthorization('POST');
Chris@4 394
Chris@4 395 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 396
Chris@4 397 // Test with a zero byte file.
Chris@4 398 $response = $this->fileRequest($uri, NULL);
Chris@4 399 $this->assertSame(201, $response->getStatusCode());
Chris@4 400 $expected = $this->getExpectedNormalizedEntity();
Chris@4 401 // Modify the default expected data to account for the 0 byte file.
Chris@4 402 $expected['filesize'][0]['value'] = 0;
Chris@4 403 $this->assertResponseData($expected, $response);
Chris@4 404
Chris@4 405 // Check the actual file data.
Chris@4 406 $this->assertSame('', file_get_contents('public://foobar/example.txt'));
Chris@4 407 }
Chris@4 408
Chris@4 409 /**
Chris@4 410 * Tests using the file upload route with an invalid file type.
Chris@4 411 */
Chris@4 412 public function testFileUploadInvalidFileType() {
Chris@4 413 $this->initAuthentication();
Chris@4 414
Chris@4 415 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 416
Chris@4 417 $this->setUpAuthorization('POST');
Chris@4 418
Chris@4 419 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 420
Chris@4 421 // Test with a JSON file.
Chris@4 422 $response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']);
Chris@4 423 $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: <em class=\"placeholder\">txt</em>."), $response);
Chris@4 424
Chris@4 425 // Make sure that no file was saved.
Chris@4 426 $this->assertEmpty(File::load(1));
Chris@4 427 $this->assertFalse(file_exists('public://foobar/example.txt'));
Chris@4 428 }
Chris@4 429
Chris@4 430 /**
Chris@4 431 * Tests using the file upload route with a file size larger than allowed.
Chris@4 432 */
Chris@4 433 public function testFileUploadLargerFileSize() {
Chris@4 434 // Set a limit of 50 bytes.
Chris@4 435 $this->field->setSetting('max_filesize', 50)
Chris@4 436 ->save();
Chris@4 437 $this->refreshTestStateAfterRestConfigChange();
Chris@4 438
Chris@4 439 $this->initAuthentication();
Chris@4 440
Chris@4 441 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 442
Chris@4 443 $this->setUpAuthorization('POST');
Chris@4 444
Chris@4 445 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 446
Chris@4 447 // Generate a string larger than the 50 byte limit set.
Chris@4 448 $response = $this->fileRequest($uri, $this->randomString(100));
Chris@4 449 $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file is <em class=\"placeholder\">100 bytes</em> exceeding the maximum file size of <em class=\"placeholder\">50 bytes</em>."), $response);
Chris@4 450
Chris@4 451 // Make sure that no file was saved.
Chris@4 452 $this->assertEmpty(File::load(1));
Chris@4 453 $this->assertFalse(file_exists('public://foobar/example.txt'));
Chris@4 454 }
Chris@4 455
Chris@4 456 /**
Chris@4 457 * Tests using the file upload POST route with malicious extensions.
Chris@4 458 */
Chris@4 459 public function testFileUploadMaliciousExtension() {
Chris@4 460 $this->initAuthentication();
Chris@4 461
Chris@4 462 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 463 // Allow all file uploads but system.file::allow_insecure_uploads is set to
Chris@4 464 // FALSE.
Chris@4 465 $this->field->setSetting('file_extensions', '')->save();
Chris@4 466 $this->refreshTestStateAfterRestConfigChange();
Chris@4 467
Chris@4 468 $this->setUpAuthorization('POST');
Chris@4 469
Chris@4 470 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 471
Chris@4 472 $php_string = '<?php print "Drupal"; ?>';
Chris@4 473
Chris@4 474 // Test using a masked exploit file.
Chris@4 475 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']);
Chris@4 476 // The filename is not munged because .txt is added and it is a known
Chris@4 477 // extension to apache.
Chris@4 478 $expected = $this->getExpectedNormalizedEntity(1, 'example.php.txt', TRUE);
Chris@4 479 // Override the expected filesize.
Chris@4 480 $expected['filesize'][0]['value'] = strlen($php_string);
Chris@4 481 $this->assertResponseData($expected, $response);
Chris@4 482 $this->assertTrue(file_exists('public://foobar/example.php.txt'));
Chris@4 483
Chris@4 484 // Add php as an allowed format. Allow insecure uploads still being FALSE
Chris@4 485 // should still not allow this. So it should still have a .txt extension
Chris@4 486 // appended even though it is not in the list of allowed extensions.
Chris@4 487 $this->field->setSetting('file_extensions', 'php')
Chris@4 488 ->save();
Chris@4 489 $this->refreshTestStateAfterRestConfigChange();
Chris@4 490
Chris@4 491 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_2.php"']);
Chris@4 492 $expected = $this->getExpectedNormalizedEntity(2, 'example_2.php.txt', TRUE);
Chris@4 493 // Override the expected filesize.
Chris@4 494 $expected['filesize'][0]['value'] = strlen($php_string);
Chris@4 495 $this->assertResponseData($expected, $response);
Chris@4 496 $this->assertTrue(file_exists('public://foobar/example_2.php.txt'));
Chris@4 497 $this->assertFalse(file_exists('public://foobar/example_2.php'));
Chris@4 498
Chris@4 499 // Allow .doc file uploads and ensure even a mis-configured apache will not
Chris@4 500 // fallback to php because the filename will be munged.
Chris@4 501 $this->field->setSetting('file_extensions', 'doc')->save();
Chris@4 502 $this->refreshTestStateAfterRestConfigChange();
Chris@4 503
Chris@4 504 // Test using a masked exploit file.
Chris@4 505 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_3.php.doc"']);
Chris@4 506 // The filename is munged.
Chris@4 507 $expected = $this->getExpectedNormalizedEntity(3, 'example_3.php_.doc', TRUE);
Chris@4 508 // Override the expected filesize.
Chris@4 509 $expected['filesize'][0]['value'] = strlen($php_string);
Chris@4 510 // The file mime should be 'application/msword'.
Chris@4 511 $expected['filemime'][0]['value'] = 'application/msword';
Chris@4 512 $this->assertResponseData($expected, $response);
Chris@4 513 $this->assertTrue(file_exists('public://foobar/example_3.php_.doc'));
Chris@4 514 $this->assertFalse(file_exists('public://foobar/example_3.php.doc'));
Chris@4 515
Chris@4 516 // Now allow insecure uploads.
Chris@4 517 \Drupal::configFactory()
Chris@4 518 ->getEditable('system.file')
Chris@4 519 ->set('allow_insecure_uploads', TRUE)
Chris@4 520 ->save();
Chris@4 521 // Allow all file uploads. This is very insecure.
Chris@4 522 $this->field->setSetting('file_extensions', '')->save();
Chris@4 523 $this->refreshTestStateAfterRestConfigChange();
Chris@4 524
Chris@4 525 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_4.php"']);
Chris@4 526 $expected = $this->getExpectedNormalizedEntity(4, 'example_4.php', TRUE);
Chris@4 527 // Override the expected filesize.
Chris@4 528 $expected['filesize'][0]['value'] = strlen($php_string);
Chris@4 529 // The file mime should also now be PHP.
Chris@4 530 $expected['filemime'][0]['value'] = 'application/x-httpd-php';
Chris@4 531 $this->assertResponseData($expected, $response);
Chris@4 532 $this->assertTrue(file_exists('public://foobar/example_4.php'));
Chris@4 533 }
Chris@4 534
Chris@4 535 /**
Chris@4 536 * Tests using the file upload POST route no extension configured.
Chris@4 537 */
Chris@4 538 public function testFileUploadNoExtensionSetting() {
Chris@4 539 $this->initAuthentication();
Chris@4 540
Chris@4 541 $this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
Chris@4 542
Chris@4 543 $this->setUpAuthorization('POST');
Chris@4 544
Chris@4 545 $uri = Url::fromUri('base:' . static::$postUri);
Chris@4 546
Chris@4 547 $this->field->setSetting('file_extensions', '')
Chris@4 548 ->save();
Chris@4 549 $this->refreshTestStateAfterRestConfigChange();
Chris@4 550
Chris@4 551 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
Chris@4 552 $expected = $this->getExpectedNormalizedEntity(1, 'example.txt', TRUE);
Chris@4 553
Chris@4 554 $this->assertResponseData($expected, $response);
Chris@4 555 $this->assertTrue(file_exists('public://foobar/example.txt'));
Chris@4 556 }
Chris@4 557
Chris@4 558 /**
Chris@4 559 * {@inheritdoc}
Chris@4 560 */
Chris@4 561 protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
Chris@4 562 // The file upload resource only accepts binary data, so there are no
Chris@4 563 // normalization edge cases to test, as there are no normalized entity
Chris@4 564 // representations incoming.
Chris@4 565 }
Chris@4 566
Chris@4 567 /**
Chris@4 568 * {@inheritdoc}
Chris@4 569 */
Chris@4 570 protected function getExpectedUnauthorizedAccessMessage($method) {
Chris@4 571 return "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'.";
Chris@4 572 }
Chris@4 573
Chris@4 574 /**
Chris@4 575 * Gets the expected file entity.
Chris@4 576 *
Chris@4 577 * @param int $fid
Chris@4 578 * The file ID to load and create normalized data for.
Chris@4 579 * @param string $expected_filename
Chris@4 580 * The expected filename for the stored file.
Chris@4 581 * @param bool $expected_as_filename
Chris@4 582 * Whether the expected filename should be the filename property too.
Chris@4 583 *
Chris@4 584 * @return array
Chris@4 585 * The expected normalized data array.
Chris@4 586 */
Chris@4 587 protected function getExpectedNormalizedEntity($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE) {
Chris@4 588 $author = User::load(static::$auth ? $this->account->id() : 0);
Chris@4 589 $file = File::load($fid);
Chris@4 590
Chris@4 591 $expected_normalization = [
Chris@4 592 'fid' => [
Chris@4 593 [
Chris@4 594 'value' => (int) $file->id(),
Chris@4 595 ],
Chris@4 596 ],
Chris@4 597 'uuid' => [
Chris@4 598 [
Chris@4 599 'value' => $file->uuid(),
Chris@4 600 ],
Chris@4 601 ],
Chris@4 602 'langcode' => [
Chris@4 603 [
Chris@4 604 'value' => 'en',
Chris@4 605 ],
Chris@4 606 ],
Chris@4 607 'uid' => [
Chris@4 608 [
Chris@4 609 'target_id' => (int) $author->id(),
Chris@4 610 'target_type' => 'user',
Chris@4 611 'target_uuid' => $author->uuid(),
Chris@4 612 'url' => base_path() . 'user/' . $author->id(),
Chris@4 613 ],
Chris@4 614 ],
Chris@4 615 'filename' => [
Chris@4 616 [
Chris@4 617 'value' => $expected_as_filename ? $expected_filename : 'example.txt',
Chris@4 618 ],
Chris@4 619 ],
Chris@4 620 'uri' => [
Chris@4 621 [
Chris@4 622 'value' => 'public://foobar/' . $expected_filename,
Chris@4 623 'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename),
Chris@4 624 ],
Chris@4 625 ],
Chris@4 626 'filemime' => [
Chris@4 627 [
Chris@4 628 'value' => 'text/plain',
Chris@4 629 ],
Chris@4 630 ],
Chris@4 631 'filesize' => [
Chris@4 632 [
Chris@4 633 'value' => strlen($this->testFileData),
Chris@4 634 ],
Chris@4 635 ],
Chris@4 636 'status' => [
Chris@4 637 [
Chris@4 638 'value' => FALSE,
Chris@4 639 ],
Chris@4 640 ],
Chris@4 641 'created' => [
Chris@4 642 $this->formatExpectedTimestampItemValues($file->getCreatedTime()),
Chris@4 643 ],
Chris@4 644 'changed' => [
Chris@4 645 $this->formatExpectedTimestampItemValues($file->getChangedTime()),
Chris@4 646 ],
Chris@4 647 ];
Chris@4 648
Chris@4 649 return $expected_normalization;
Chris@4 650 }
Chris@4 651
Chris@4 652 /**
Chris@4 653 * Performs a file upload request. Wraps the Guzzle HTTP client.
Chris@4 654 *
Chris@4 655 * @see \GuzzleHttp\ClientInterface::request()
Chris@4 656 *
Chris@4 657 * @param \Drupal\Core\Url $url
Chris@4 658 * URL to request.
Chris@4 659 * @param string $file_contents
Chris@4 660 * The file contents to send as the request body.
Chris@4 661 * @param array $headers
Chris@4 662 * Additional headers to send with the request. Defaults will be added for
Chris@5 663 * Content-Type and Content-Disposition. In order to remove the defaults set
Chris@5 664 * the header value to FALSE.
Chris@4 665 *
Chris@4 666 * @return \Psr\Http\Message\ResponseInterface
Chris@4 667 */
Chris@4 668 protected function fileRequest(Url $url, $file_contents, array $headers = []) {
Chris@4 669 // Set the format for the response.
Chris@4 670 $url->setOption('query', ['_format' => static::$format]);
Chris@4 671
Chris@4 672 $request_options = [];
Chris@5 673 $headers = $headers + [
Chris@4 674 // Set the required (and only accepted) content type for the request.
Chris@4 675 'Content-Type' => 'application/octet-stream',
Chris@4 676 // Set the required Content-Disposition header for the file name.
Chris@4 677 'Content-Disposition' => 'file; filename="example.txt"',
Chris@4 678 ];
Chris@5 679 $request_options[RequestOptions::HEADERS] = array_filter($headers, function ($value) {
Chris@5 680 return $value !== FALSE;
Chris@5 681 });
Chris@4 682 $request_options[RequestOptions::BODY] = $file_contents;
Chris@4 683 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
Chris@4 684
Chris@4 685 return $this->request('POST', $url, $request_options);
Chris@4 686 }
Chris@4 687
Chris@4 688 /**
Chris@4 689 * {@inheritdoc}
Chris@4 690 */
Chris@4 691 protected function setUpAuthorization($method) {
Chris@4 692 switch ($method) {
Chris@4 693 case 'GET':
Chris@4 694 $this->grantPermissionsToTestedRole(['view test entity']);
Chris@4 695 break;
Chris@4 696 case 'POST':
Chris@4 697 $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities', 'access content']);
Chris@4 698 break;
Chris@4 699 }
Chris@4 700 }
Chris@4 701
Chris@4 702 /**
Chris@4 703 * Asserts expected normalized data matches response data.
Chris@4 704 *
Chris@4 705 * @param array $expected
Chris@4 706 * The expected data.
Chris@4 707 * @param \Psr\Http\Message\ResponseInterface $response
Chris@4 708 * The file upload response.
Chris@4 709 */
Chris@4 710 protected function assertResponseData(array $expected, ResponseInterface $response) {
Chris@4 711 static::recursiveKSort($expected);
Chris@4 712 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
Chris@4 713 static::recursiveKSort($actual);
Chris@4 714
Chris@4 715 $this->assertSame($expected, $actual);
Chris@4 716 }
Chris@4 717
Chris@4 718 /**
Chris@4 719 * {@inheritdoc}
Chris@4 720 */
Chris@4 721 protected function getExpectedUnauthorizedAccessCacheability() {
Chris@4 722 // There is cacheability metadata to check as file uploads only allows POST
Chris@4 723 // requests, which will not return cacheable responses.
Chris@4 724 }
Chris@4 725
Chris@4 726 }