Commit 69a0a835 authored by catch's avatar catch
Browse files

Issue #3032390 by alexpott, dww, Pancho, 3CWebDev, kim.pepper, larowlan,...

Issue #3032390 by alexpott, dww, Pancho, 3CWebDev, kim.pepper, larowlan, Berdir, catch, andypost, chr.fritsch, Wim Leers: Add an event to sanitize filenames during upload
parent febe06ca
Loading
Loading
Loading
Loading
+14 −4
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@
 */

use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;

/**
@@ -176,8 +177,14 @@ function file_build_uri($path) {
 *
 * @return string
 *   The potentially modified $filename.
 *
 * @deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Dispatch a
 *   \Drupal\Core\File\Event\FileUploadSanitizeNameEvent event instead.
 *
 * @see https://www.drupal.org/node/3032541
 */
function file_munge_filename($filename, $extensions, $alerts = TRUE) {
  @trigger_error('file_munge_filename() is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Dispatch a \Drupal\Core\File\Event\FileUploadSanitizeNameEvent event instead. See https://www.drupal.org/node/3032541', E_USER_DEPRECATED);
  $original = $filename;

  // Allow potentially insecure uploads for very savvy users and admin
@@ -189,10 +196,7 @@ function file_munge_filename($filename, $extensions, $alerts = TRUE) {
    $allowed_extensions = array_unique(explode(' ', strtolower(trim($extensions))));

    // Remove unsafe extensions from the allowed list of extensions.
    // @todo https://www.drupal.org/project/drupal/issues/3032390 Make the list
    //   of unsafe extensions a constant. The list is copied from
    //   FILE_INSECURE_EXTENSION_REGEX.
    $allowed_extensions = array_diff($allowed_extensions, explode('|', 'phar|php|pl|py|cgi|asp|js'));
    $allowed_extensions = array_diff($allowed_extensions, FileSystemInterface::INSECURE_EXTENSIONS);

    // Split the filename up by periods. The first part becomes the basename
    // the last part the final extension.
@@ -229,8 +233,14 @@ function file_munge_filename($filename, $extensions, $alerts = TRUE) {
 *
 * @return
 *   An unmunged filename string.
 *
 * @deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Use
 *   str_replace() instead.
 *
 * @see https://www.drupal.org/node/3032541
 */
function file_unmunge_filename($filename) {
  @trigger_error('file_unmunge_filename() is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Use str_replace() instead. See https://www.drupal.org/node/3032541', E_USER_DEPRECATED);
  return str_replace('_.', '.', $filename);
}

+125 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\File\Event;

use Drupal\Component\EventDispatcher\Event;

/**
 * An event during file upload that lets subscribers sanitize the filename.
 *
 * @see _file_save_upload_single()
 * @see \Drupal\file\Plugin\rest\resource\FileUploadResource::prepareFilename()
 * @see \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::sanitizeName()
 */
class FileUploadSanitizeNameEvent extends Event {

  /**
   * The name of the file being uploaded.
   *
   * @var string
   */
  protected $filename = '';

  /**
   * A list of allowed extensions.
   *
   * @var string[]
   */
  protected $allowedExtensions = [];

  /**
   * Indicates the filename has changed for security reasons.
   *
   * @var bool
   */
  protected $isSecurityRename = FALSE;

  /**
   * Constructs a file upload sanitize name event object.
   *
   * @param string $filename
   *   The full filename (with extension, but not directory) being uploaded.
   * @param string $allowed_extensions
   *   A list of allowed extensions. If empty all extensions are allowed.
   */
  public function __construct(string $filename, string $allowed_extensions) {
    $this->setFilename($filename);
    if ($allowed_extensions !== '') {
      $this->allowedExtensions = array_unique(explode(' ', trim(strtolower($allowed_extensions))));
    }
  }

  /**
   * Gets the filename.
   *
   * @return string
   *   The filename.
   */
  public function getFilename(): string {
    return $this->filename;
  }

  /**
   * Sets the filename.
   *
   * @param string $filename
   *   The filename to use for the uploaded file.
   *
   * @return $this
   *
   * @throws \InvalidArgumentException
   *   Thrown when $filename contains path information.
   */
  public function setFilename(string $filename): self {
    if (dirname($filename) !== '.') {
      throw new \InvalidArgumentException(sprintf('$filename must be a filename with no path information, "%s" provided', $filename));
    }
    $this->filename = $filename;
    return $this;
  }

  /**
   * Gets the list of allowed extensions.
   *
   * @return string[]
   *   The list of allowed extensions.
   */
  public function getAllowedExtensions(): array {
    return $this->allowedExtensions;
  }

  /**
   * Sets the security rename flag.
   *
   * @return $this
   */
  public function setSecurityRename(): self {
    $this->isSecurityRename = TRUE;
    return $this;
  }

  /**
   * Gets the security rename flag.
   *
   * @return bool
   *   TRUE if there is a rename for security reasons, otherwise FALSE.
   */
  public function isSecurityRename(): bool {
    return $this->isSecurityRename;
  }

  /**
   * {@inheritdoc}
   *
   * @throws \RuntimeException
   *   Thrown whenever this method is called. This event should always be fully
   *   processed so that SecurityFileUploadEventSubscriber::sanitizeName()
   *   gets a chance to run.
   *
   * @see \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber
   */
  public function stopPropagation() {
    throw new \RuntimeException('Propagation cannot be stopped for the FileUploadSanitizeNameEvent');
  }

}
+14 −0
Original line number Diff line number Diff line
@@ -32,6 +32,20 @@ interface FileSystemInterface {
   */
  const MODIFY_PERMISSIONS = 2;

  /**
   * A list of insecure extensions.
   *
   * @see \Drupal\Core\File\FileSystemInterface::INSECURE_EXTENSION_REGEX
   */
  public const INSECURE_EXTENSIONS = ['phar', 'php', 'pl', 'py', 'cgi', 'asp', 'js'];

  /**
   * The regex pattern used when checking for insecure file types.
   *
   * @see \Drupal\Core\File\FileSystemInterface::INSECURE_EXTENSIONS
   */
  public const INSECURE_EXTENSION_REGEX = '/\.(phar|php|pl|py|cgi|asp|js)(\.|$)/i';

  /**
   * Moves an uploaded file to a new location.
   *
+46 −0
Original line number Diff line number Diff line
@@ -5,6 +5,52 @@
 * Hooks for file module.
 */

/**
 * @addtogroup file
 * @{
 * @section file_security Uploading files and security considerations
 *
 * Using \Drupal\file\Element\ManagedFile field with a defined list of allowed
 * extensions is best way to provide a file upload field. It will ensure that:
 * - File names are sanitized by the FileUploadSanitizeNameEvent event.
 * - Files are validated by hook implementations of hook_file_validate().
 * - Files with insecure extensions will be blocked by default even if they are
 *   listed. If .txt is an allowed extension such files will be renamed.
 *
 * The \Drupal\Core\Render\Element\File field requires the developer to ensure
 * security concerns are taken care of. To do this, a developer should:
 * - Add the #upload_validators property to the form element. For example,
 * @code
 * $form['file_upload'] = [
 *   '#type' => 'file',
 *   '#title' => $this->t('Upload file'),
 *   '#upload_validators' => [
 *     'file_validate_extensions' => [
 *       'png gif jpg',
 *     ],
 *   ],
 * ];
 * @endcode
 * - Use file_save_upload() to trigger the FileUploadSanitizeNameEvent event and
 *   hook_file_validate().
 *
 * Important considerations, regardless of the form element used:
 * - Always use and validate against a list of allowed extensions.
 * - If the configuration system.file:allow_insecure_uploads is set to TRUE
 *   then potentially insecure files will not be renamed. This setting is not
 *   recommended.
 *
 * @see https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
 * @see \hook_file_validate()
 * @see file_save_upload()
 * @see \Drupal\Core\File\Event\FileUploadSanitizeNameEvent
 * @see \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber
 * @see \Drupal\file\Element\ManagedFile
 * @see \Drupal\Core\Render\Element\File
 *
 * @}
 */

/**
 * @addtogroup hooks
 * @{
+60 −56
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Drupal\file\FileInterface;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Unicode;
@@ -25,12 +26,15 @@
use Drupal\Core\Template\Attribute;
use Symfony\Component\Mime\MimeTypeGuesserInterface;

// cspell:ignore btxt

/**
 * The regex pattern used when checking for insecure file types.
 *
 * @deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Use
 *   \Drupal\Core\File\FileSystemInterface::INSECURE_EXTENSION_REGEX.
 *
 * @see https://www.drupal.org/node/3032541
 */
define('FILE_INSECURE_EXTENSION_REGEX', '/\.(phar|php|pl|py|cgi|asp|js)(\.|$)/i');
define('FILE_INSECURE_EXTENSION_REGEX', FileSystemInterface::INSECURE_EXTENSION_REGEX);

// Load all Field module hooks for File.
require_once __DIR__ . '/file.field.inc';
@@ -290,7 +294,7 @@ function file_validate(FileInterface $file, $validators = []) {
  // a malicious extension. Contributed and custom code that calls this method
  // needs to take similar steps if they need to permit files with malicious
  // extensions to be uploaded.
  if (empty($errors) && !\Drupal::config('system.file')->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $file->getFilename())) {
  if (empty($errors) && !\Drupal::config('system.file')->get('allow_insecure_uploads') && preg_match(FileSystemInterface::INSECURE_EXTENSION_REGEX, $file->getFilename())) {
    $errors[] = t('For security reasons, your upload has been rejected.');
  }
  return $errors;
@@ -923,7 +927,8 @@ function file_save_upload($form_field_name, $validators = [], $destination = FAL
 */
function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $validators = [], $destination = FALSE, $replace = FileSystemInterface::EXISTS_REPLACE) {
  $user = \Drupal::currentUser();
  $original_file_name = trim($file_info->getClientOriginalName(), '.');
  // Remember the original filename so we can print a message if it changes.
  $original_file_name = $file_info->getClientOriginalName();
  // Check for file upload errors and return FALSE for this file if a lower
  // level system error occurred. For a complete list of errors:
  // See http://php.net/manual/features.file-upload.errors.php.
@@ -951,24 +956,8 @@ function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $va
      return FALSE;

  }
  // Begin building file entity.
  $values = [
    'uid' => $user->id(),
    'status' => 0,
    'filename' => $original_file_name,
    'uri' => $file_info->getRealPath(),
    'filesize' => $file_info->getSize(),
  ];
  $guesser = \Drupal::service('file.mime_type.guesser');
  if ($guesser instanceof MimeTypeGuesserInterface) {
    $values['filemime'] = $guesser->guessMimeType($values['filename']);
  }
  else {
    $values['filemime'] = $guesser->guess($values['filename']);
    @trigger_error('\Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Implement \Symfony\Component\Mime\MimeTypeGuesserInterface instead. See https://www.drupal.org/node/3133341', E_USER_DEPRECATED);
  }
  $file = File::create($values);

  // Build a list of allowed extensions.
  $extensions = '';
  if (isset($validators['file_validate_extensions'])) {
    if (isset($validators['file_validate_extensions'][0])) {
@@ -984,41 +973,13 @@ function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $va
  }
  else {
    // No validator was provided, so add one using the default list.
    // Build a default non-munged safe list for file_munge_filename().
    // Build a default non-munged safe list for
    // \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::sanitizeName().
    $extensions = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp';
    $validators['file_validate_extensions'] = [];
    $validators['file_validate_extensions'][0] = $extensions;
  }

  //  Don't rename if 'allow_insecure_uploads' evaluates to TRUE.
  if (!\Drupal::config('system.file')->get('allow_insecure_uploads')) {
    if (!empty($extensions)) {
      // Munge the filename to protect against possible malicious extension
      // hiding within an unknown file type (ie: filename.html.foo).
      $file->setFilename(file_munge_filename($file->getFilename(), $extensions));
    }

    // Rename potentially executable files, to help prevent exploits (i.e. will
    // rename filename.php.foo and filename.php to filename.php_.foo_.txt and
    // filename.php_.txt, respectively).
    if (preg_match(FILE_INSECURE_EXTENSION_REGEX, $file->getFilename())) {
      // If there is no file extension validation at all, or .txt is considered
      // a valid extension and the file would otherwise pass validation, rename
      // it. If the file will be rejected due to extension validation, it should
      // not be renamed; rather, let file_validate_extensions() reject it below.
      if (!isset($validators['file_validate_extensions']) || (preg_match('/\btxt\b/', $extensions) && empty(file_validate_extensions($file, $extensions)))) {
        $file->setMimeType('text/plain');
        $filename = $file->getFilename();
        if (substr($filename, -4) != '.txt') {
          // The destination filename will also later be used to create the URI.
          $filename .= '.txt';
        }
        $file->setFilename(file_munge_filename($filename, $extensions));
        \Drupal::messenger()->addStatus(t('For security reasons, your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]));
      }
    }
  }

  // If the destination is not provided, use the temporary directory.
  if (empty($destination)) {
    $destination = 'temporary://';
@@ -1034,22 +995,50 @@ function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $va
    return FALSE;
  }

  $file->source = $form_field_name;
  // A file URI may already have a trailing slash or look like "public://".
  if (substr($destination, -1) != '/') {
    $destination .= '/';
  }

  // Call an event to sanitize the filename and to attempt to address security
  // issues caused by common server setups.
  $event = new FileUploadSanitizeNameEvent($original_file_name, $extensions);
  \Drupal::service('event_dispatcher')->dispatch($event);

  // Begin building the file entity.
  $values = [
    'uid' => $user->id(),
    'status' => 0,
    // This will be replaced later with a filename based on the destination.
    'filename' => $event->getFilename(),
    'uri' => $file_info->getRealPath(),
    'filesize' => $file_info->getSize(),
  ];
  $file = File::create($values);

  /** @var \Drupal\Core\File\FileSystemInterface $file_system */
  $file_system = \Drupal::service('file_system');
  try {
    $file->destination = $file_system->getDestinationFilename($destination . $file->getFilename(), $replace);
    // Use the result of the sanitization event as the destination name.
    $file->destination = $file_system->getDestinationFilename($destination . $event->getFilename(), $replace);
  }
  catch (FileException $e) {
    \Drupal::messenger()->addError(t('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $file->getFilename()]));
    return FALSE;
  }
  // If the destination is FALSE then there is an existing file and $replace is
  // set to return an error, so we need to exit.

  $guesser = \Drupal::service('file.mime_type.guesser');
  if ($guesser instanceof MimeTypeGuesserInterface) {
    $file->setMimeType($guesser->guessMimeType($values['filename']));
  }
  else {
    $file->setMimeType($guesser->guess($values['filename']));
    @trigger_error('\Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Implement \Symfony\Component\Mime\MimeTypeGuesserInterface instead. See https://www.drupal.org/node/3133341', E_USER_DEPRECATED);
  }
  $file->source = $form_field_name;

  // If the destination is FALSE then $replace === FILE_EXISTS_ERROR and
  // there's an existing file, so we need to bail.
  if ($file->destination === FALSE) {
    \Drupal::messenger()->addError(t('The file %source could not be uploaded because a file by that name already exists in the destination %directory.', ['%source' => $form_field_name, '%directory' => $destination]));
    return FALSE;
@@ -1086,6 +1075,21 @@ function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $va
    return FALSE;
  }

  // Update the filename with any changes as a result of the renaming due to an
  // existing file.
  $file->setFilename(\Drupal::service('file_system')->basename($file->destination));

  // If the filename has been modified, let the user know.
  if ($file->getFilename() !== $original_file_name) {
    if ($event->isSecurityRename()) {
      $message = t('For security reasons, your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]);
    }
    else {
      $message = t('Your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]);
    }
    \Drupal::messenger()->addStatus($message);
  }

  // Set the permissions on the new file.
  $file_system->chmod($file->getFileUri());

Loading