MTimeProtectedFastFileStorage.php 6.56 KB
Newer Older
1 2 3 4
<?php

/**
 * @file
5
 * Contains \Drupal\Component\PhpStorage\MTimeProtectedFastFileStorage.
6
 */
7

8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
namespace Drupal\Component\PhpStorage;

/**
 * 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 secret used in the HMAC.
   *
   * @var string
   */
  protected $secret;

  /**
   * Constructs this MTimeProtectedFastFileStorage object.
   *
52
   * @param array $configuration
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
   *   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) {
69
    $this->ensureDirectory($this->directory);
70 71 72 73 74 75 76

    // 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;
    }
77 78 79
    // The file will not be chmod() in the future so this is the final
    // permission.
    chmod($temporary_path, 0444);
80 81 82 83 84 85 86

    // 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)) {
87
      $this->unlink($directory);
88
    }
89
    $this->ensureDirectory($directory);
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107

    // 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;
      // 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) {
108
          $this->unlink($temporary_path);
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
          return FALSE;
        }
      }
      $full_path = $this->getFullPath($name, $directory, $mtime);
      rename($temporary_path, $full_path);
      $i++;
    }
    return TRUE;
  }

  /**
   * 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.
137
   *
138
   * @return string
139
   *   The full path where the file is or should be stored.
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
   */
  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) {
162
    clearstatcache(TRUE, $directory);
163 164
    return filemtime($directory);
  }
165

166
}