MTimeProtectedFastFileStorage.php

Definition of Drupal\Component\PhpStorage\MTimeProtectedFastFileStorage.

Namespace

Drupal\Component\PhpStorage

File

drupal/core/lib/Drupal/Component/PhpStorage/MTimeProtectedFastFileStorage.php
View source
<?php

/**
 * @file
 * Definition of Drupal\Component\PhpStorage\MTimeProtectedFastFileStorage.
 */
namespace Drupal\Component\PhpStorage;

use DirectoryIterator;

/**
 * Stores PHP code in files with securely hashed names.
 *
 * The goal of this class is to ensure that if a PHP file is replaced with
 * an untrusted one, it does not get loaded. Since mtime granularity is 1
 * second, we cannot prevent an attack that happens within one second of the
 * initial save(). However, it is very unlikely for an attacker exploiting an
 * upload or file write vulnerability to also know when a legitimate file is
 * being saved, discover its hash, undo its file permissions, and override the
 * file with an upload all within a single second. Being able to accomplish
 * that would indicate a site very likely vulnerable to many other attack
 * vectors.
 *
 * Each file is stored in its own unique containing directory. The hash is based
 * on the virtual file name, the containing directory's mtime, and a
 * cryptographically hard to guess secret string. Thus, even if the hashed file
 * name is discovered and replaced by an untrusted file (e.g., via a
 * move_uploaded_file() invocation by a script that performs insufficient
 * validation), the directory's mtime gets updated in the process, invalidating
 * the hash and preventing the untrusted file from getting loaded.
 *
 * This class does not protect against overwriting a file in-place (e.g. a
 * malicious module that does a file_put_contents()) since this will not change
 * the mtime of the directory. MTimeProtectedFileStorage protects against this
 * at the cost of an additional system call for every load() and exists().
 *
 * The containing directory is created with the same name as the virtual file
 * name (slashes removed) to assist with debugging, since the file itself is
 * stored with a name that's meaningless to humans.
 */
class MTimeProtectedFastFileStorage extends FileStorage {

  /**
   * The .htaccess code to make a directory private.
   */
  const HTACCESS = "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nDeny from all\nOptions None\nOptions +FollowSymLinks";

  /**
   * The secret used in the HMAC.
   *
   * @var string
   */
  protected $secret;

  /**
   * Constructs this MTimeProtectedFastFileStorage object.
   *
   * @param array $configuration
   *   An associated array, containing at least these keys (the rest are
   *   ignored):
   *   - directory: The directory where the files should be stored.
   *   - secret: A cryptographically hard to guess secret string.
   *   -bin. The storage bin. Multiple storage objects can be instantiated with
   *   the same configuration, but for different bins.
   */
  public function __construct(array $configuration) {
    parent::__construct($configuration);
    $this->secret = $configuration['secret'];
  }

  /**
   * Implements Drupal\Component\PhpStorage\PhpStorageInterface::save().
   */
  public function save($name, $data) {
    $this
      ->ensureDirectory();

    // Write the file out to a temporary location. Prepend with a '.' to keep it
    // hidden from listings and web servers.
    $temporary_path = $this->directory . '/.' . str_replace('/', '#', $name);
    if (!@file_put_contents($temporary_path, $data)) {
      return FALSE;
    }
    chmod($temporary_path, 0400);

    // Prepare a directory dedicated for just this file. Ensure it has a current
    // mtime so that when the file (hashed on that mtime) is moved into it, the
    // mtime remains the same (unless the clock ticks to the next second during
    // the rename, in which case we'll try again).
    $directory = $this
      ->getContainingDirectoryFullPath($name);
    if (file_exists($directory)) {
      $this
        ->cleanDirectory($directory);
      touch($directory);
    }
    else {
      mkdir($directory);
    }

    // Move the file to its final place. The mtime of a directory is the time of
    // the last file create or delete in the directory. So the moving will
    // update the directory mtime. However, this update will very likely not
    // show up, because it has a coarse, one second granularity and typical
    // moves takes significantly less than that. In the unlucky case the clock
    // ticks during the move, we need to keep trying until the mtime we hashed
    // on and the updated mtime match.
    $previous_mtime = 0;
    $i = 0;
    while (($mtime = $this
      ->getUncachedMTime($directory)) && $mtime != $previous_mtime) {
      $previous_mtime = $mtime;
      chmod($directory, 0700);

      // Reset the file back in the temporary location if this is not the first
      // iteration.
      if ($i > 0) {
        rename($full_path, $temporary_path);

        // Make sure to not loop infinitely on a hopelessly slow filesystem.
        if ($i > 10) {
          $this
            ->unlink($temporary_path);
          return FALSE;
        }
      }
      $full_path = $this
        ->getFullPath($name, $directory, $mtime);
      rename($temporary_path, $full_path);

      // Leave the directory neither readable nor writable. Since the file
      // itself is not writable (set to 0400 at the beginning of this function),
      // there's no way to tamper with it without access to change permissions.
      chmod($directory, 0100);
      $i++;
    }
    return TRUE;
  }

  /**
   * Implements Drupal\Component\PhpStorage\PhpStorageInterface::delete().
   */
  public function delete($name) {
    $directory = dirname($this
      ->getFullPath($name));
    if (file_exists($directory)) {
      $this
        ->cleanDirectory($directory);
      return rmdir($directory);
    }
    return FALSE;
  }

  /**
   * Ensures the root directory exists and has correct permissions.
   */
  protected function ensureDirectory() {
    if (!file_exists($this->directory)) {
      mkdir($this->directory, 0700, TRUE);
    }
    chmod($this->directory, 0700);
    $htaccess_path = $this->directory . '/.htaccess';
    if (!file_exists($htaccess_path) && file_put_contents($htaccess_path, self::HTACCESS)) {
      @chmod($htaccess_path, 0444);
    }
  }

  /**
   * Removes everything in a directory, leaving it empty.
   *
   * @param string $directory
   *   The directory to be emptied out.
   */
  protected function cleanDirectory($directory) {
    chmod($directory, 0700);
    foreach (new DirectoryIterator($directory) as $fileinfo) {
      if (!$fileinfo
        ->isDot()) {
        $this
          ->unlink($fileinfo
          ->getPathName());
      }
    }
  }

  /**
   * Returns the full path where the file is or should be stored.
   *
   * This function creates a file path that includes a unique containing
   * directory for the file and a file name that is a hash of the virtual file
   * name, a cryptographic secret, and the containing directory mtime. If the
   * file is overridden by an insecure upload script, the directory mtime gets
   * modified, invalidating the file, thus protecting against untrusted code
   * getting executed.
   *
   * @param string $name
   *   The virtual file name. Can be a relative path.
   * @param string $directory
   *   (optional) The directory containing the file. If not passed, this is
   *   retrieved by calling getContainingDirectoryFullPath().
   * @param int $directory_mtime
   *   (optional) The mtime of $directory. Can be passed to avoid an extra
   *   filesystem call when the mtime of the directory is already known.
   *
   * @return string
   *   The full path where the file is or should be stored.
   */
  protected function getFullPath($name, &$directory = NULL, &$directory_mtime = NULL) {
    if (!isset($directory)) {
      $directory = $this
        ->getContainingDirectoryFullPath($name);
    }
    if (!isset($directory_mtime)) {
      $directory_mtime = file_exists($directory) ? filemtime($directory) : 0;
    }
    return $directory . '/' . hash_hmac('sha256', $name, $this->secret . $directory_mtime) . '.php';
  }

  /**
   * Returns the full path of the containing directory where the file is or should be stored.
   */
  protected function getContainingDirectoryFullPath($name) {
    return $this->directory . '/' . str_replace('/', '#', $name);
  }

  /**
   * Clears PHP's stat cache and returns the directory's mtime.
   */
  protected function getUncachedMTime($directory) {
    clearstatcache(TRUE, $directory);
    return filemtime($directory);
  }

}

Classes

Namesort descending Description
MTimeProtectedFastFileStorage Stores PHP code in files with securely hashed names.