Verified Commit c30d609d authored by Lee Rowlands's avatar Lee Rowlands
Browse files

task: #3516706 Disallow dangerous filenames e.g. command injection characters

By: mcdruid
By: smustgrave
By: cmlara
By: kim.pepper
By: benjifisher
By: alexpott
By: mohit_aghera
By: longwave
By: xjm
parent 0cbebc2f
Loading
Loading
Loading
Loading
Loading
+6 −1
Original line number Diff line number Diff line
@@ -107,7 +107,12 @@ public function testNodeDisplay(): void {
    $this->submitForm($edit, 'Save');
    $this->assertSession()->pageTextContains($description);

    // Ensure the filename in the link's title attribute is escaped.
    // Ensure the filename in the link's title attribute is escaped. Uploaded
    // filenames cannot contain characters that need escaping, so set one
    // directly on the file entity.
    $node_file->setFilename('escaped-&-text.txt');
    $node_file->save();
    $this->drupalGet('node/' . $nid);
    $this->assertSession()->responseContains('title="escaped-&-text.txt"');

    // Test that fields appear as expected after during the preview.
+2 −2
Original line number Diff line number Diff line
@@ -841,7 +841,7 @@ public function testSanitization(): void {
    $this->submitForm($edit, 'Submit');
    $this->assertSession()->statusCodeEquals(200);
    // Test that the file name has only been transliterated.
    $this->assertSession()->responseContains('File name is S  Pace--tab#	#---.txt.');
    $this->assertSession()->responseContains('File name is S  Pace--tab___---.txt.');

    // Leave transliteration on and enable whitespace replacement.
    $this->drupalLogin($admin);
@@ -855,7 +855,7 @@ public function testSanitization(): void {
    $this->submitForm($edit, 'Submit');
    $this->assertSession()->statusCodeEquals(200);
    // Test that the file name has been transliterated and whitespace replaced.
    $this->assertSession()->responseContains('File name is S--Pace--tab#-#---.txt.');
    $this->assertSession()->responseContains('File name is S--Pace--tab_-_---.txt.');

    // Leave transliteration and whitespace replacement on, replace non-alpha.
    $this->drupalLogin($admin);
+46 −0
Original line number Diff line number Diff line
@@ -14,6 +14,40 @@
 */
class SecurityFileUploadEventSubscriber implements EventSubscriberInterface {

  /**
   * A PCRE pattern matching characters that are never safe in a filename.
   *
   * @see \Drupal\Core\File\FileSystem::createFilename()
   */
  protected const string INSECURE_CHARACTERS = '@['
    // Unicode general category Cc, the C0 and C1 control characters. They are
    // meaningless in a filename and allow line splitting in anything that
    // later treats it as text. Null bytes are removed separately.
    . '\p{Cc}'
    // Unicode general category Cf, format characters. These are invisible, so
    // they cannot be seen in a filename, and the bidirectional overrides among
    // them allow tampering with the way a filename is displayed.
    . '\p{Cf}'
    // Shell meta-characters and quotes, which allow command injection if the
    // filename is ever passed to a shell. Dropping quotes and angle brackets
    // also protects against XSS should a filename ever be rendered unsafely.
    . '`$;|&<>(){}\[\]!*?~^\'"\\\\'
    // Path and stream separators, which allow traversal and, on Windows,
    // alternate data streams.
    . '/:'
    // Characters that change the meaning of the file URL.
    . '#%'
    . ']@u';

  /**
   * The filename used when sanitization leaves nothing behind.
   *
   * Collisions with a real upload of this name are resolved by the usual
   * suffixing in \Drupal\Core\File\FileSystem::createFilename(), producing
   * unnamed_0, unnamed_1 and so on.
   */
  protected const string FALLBACK_FILENAME = 'unnamed';

  /**
   * Constructs a new file event listener.
   *
@@ -52,6 +86,18 @@ public function sanitizeName(FileUploadSanitizeNameEvent $event): void {
    // http://php.net/manual/security.filesystem.nullbytes.php
    $filename = str_replace(chr(0), '', $filename);

    // Replace characters that are never safe.
    $filename = preg_replace(self::INSECURE_CHARACTERS, '_', $filename);

    // A leading dash is parsed as an option by most command line tools.
    $filename = ltrim($filename, '-');

    // Give the file a name if sanitization has left it without one; either
    // empty or with nothing before the extension.
    if ($filename === '' || str_starts_with($filename, '.')) {
      $filename = self::FALLBACK_FILENAME . $filename;
    }

    if ($filename !== $event->getFilename()) {
      $event->setFilename($filename)->setSecurityRename();
    }
+22 −0
Original line number Diff line number Diff line
@@ -95,6 +95,28 @@ public static function provideFilenames(): array {
      '.phtml, .txt extension allowed with .phtml file' => ['foo.phtml', 'phtml txt', 'foo.phtml_.txt', 'foo.phtml'],
      'All extensions allowed with .phtml file' => ['foo.phtml', '', 'foo.phtml_.txt', 'foo.phtml'],
      'dot files are renamed even if allowed and not in security list' => ['.git', 'git', 'git'],
      // Characters that are never safe are replaced regardless of the
      // filename_sanitization settings in file.settings.
      'Carriage return is replaced' => ["a\rb.txt", 'txt', 'a_b.txt'],
      'Tab is replaced' => ["a\tb.txt", 'txt', 'a_b.txt'],
      'Delete is replaced' => ["a\x7Fb.txt", 'txt', 'a_b.txt'],
      'Shell meta-characters are replaced' => ['a;b|c&d.txt', 'txt', 'a_b_c_d.txt'],
      'Backtick and dollar are replaced' => ['a`b$c.txt', 'txt', 'a_b_c.txt'],
      'Quotes are replaced' => ['a\'b"c.txt', 'txt', 'a_b_c.txt'],
      'Bidirectional override is replaced' => ["photo_\u{202E}gnp.txt", 'txt', 'photo__gnp.txt'],
      'Zero width space is replaced' => ["photo\u{200B}graph.txt", 'txt', 'photo_graph.txt'],
      'An ampersand is replaced, not dropped' => ['Love&War.pdf', 'pdf', 'Love_War.pdf'],
      'A name of only unsafe characters keeps its extension' => ['<>|.txt', 'txt', '___.txt'],
      'A name of only unsafe characters is not emptied' => ['<>|', '', '___'],
      'Leading dashes are stripped' => ['--opt.txt', 'txt', 'opt.txt'],
      'Leading dashes do not expose a dot file' => ['---.txt', 'txt', 'unnamed.txt'],
      'A name of only dots falls back' => ['...', '', 'unnamed'],
      'The fallback name is not itself disturbed' => ['unnamed', '', 'unnamed'],
      // The extension is still munged after the replacements.
      'Unsafe characters with a disallowed insecure extension' => ["a\rb.php", 'txt', 'a_b.php'],
      'Unsafe characters with an insecure extension' => ["a\rb.php", '', 'a_b.php_.txt', 'a_b.php'],
      'An ordinary filename is untouched' => ['holiday-photo.jpg', 'jpg', 'holiday-photo.jpg'],
      'Spaces are left to the sanitization settings' => ['drupal rocks.txt', 'txt', 'drupal rocks.txt'],
    ];
  }