-
Notifications
You must be signed in to change notification settings - Fork 2
/
MTimeProtectedFastFileStorage.php
235 lines (214 loc) · 8.52 KB
/
MTimeProtectedFastFileStorage.php
1
2
3
4
5
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
<?php
namespace Drupal\Component\PhpStorage;
use Drupal\Component\Utility\Crypt;
/**
* 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.
*
* @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'];
}
/**
* {@inheritdoc}
*/
public function save($name, $data) {
$this->ensureDirectory($this->directory);
// Write the file out to a temporary location. Prepend with a '.' to keep it
// hidden from listings and web servers.
$temporary_path = $this->tempnam($this->directory, '.');
if (!$temporary_path || !@file_put_contents($temporary_path, $data)) {
return FALSE;
}
// The file will not be chmod() in the future so this is the final
// permission.
chmod($temporary_path, 0444);
// Determine the exact modification time of the file.
$mtime = $this->getUncachedMTime($temporary_path);
// Move the temporary file into the proper directory. Note that POSIX
// compliant systems as well as modern Windows perform the rename operation
// atomically, i.e. there is no point at which another process attempting to
// access the new path will find it missing.
$directory = $this->getContainingDirectoryFullPath($name);
$this->ensureDirectory($directory);
$full_path = $this->getFullPath($name, $directory, $mtime);
$result = rename($temporary_path, $full_path);
// Finally reset the modification time of the directory to match the one of
// the newly created file. In order to prevent the creation of a file if the
// directory does not exist, ensure that the path terminates with a
// directory separator.
//
// Recall that when subsequently loading the file, the hash is calculated
// based on the file name, the containing mtime, and a the secret string.
// Hence updating the mtime here is comparable to pointing a symbolic link
// at a new target, i.e., the newly created file.
if ($result) {
$result &= touch($directory . '/', $mtime);
}
return (bool) $result;
}
/**
* Gets 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.
*/
public 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 . '/' . Crypt::hmacBase64($name, $this->secret . $directory_mtime) . '.php';
}
/**
* {@inheritdoc}
*/
public function delete($name) {
$path = $this->getContainingDirectoryFullPath($name);
if (file_exists($path)) {
return $this->unlink($path);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function garbageCollection() {
$flags = \FilesystemIterator::CURRENT_AS_FILEINFO;
$flags += \FilesystemIterator::SKIP_DOTS;
foreach ($this->listAll() as $name) {
$directory = $this->getContainingDirectoryFullPath($name);
try {
$dir_iterator = new \FilesystemIterator($directory, $flags);
}
catch (\UnexpectedValueException) {
// FilesystemIterator throws an UnexpectedValueException if the
// specified path is not a directory, or if it is not accessible.
continue;
}
$directory_unlink = TRUE;
$directory_mtime = filemtime($directory);
foreach ($dir_iterator as $fileinfo) {
if ($directory_mtime > $fileinfo->getMTime()) {
// Ensure the folder is writable.
@chmod($directory, 0777);
@unlink($fileinfo->getPathName());
}
else {
// The directory still contains valid files.
$directory_unlink = FALSE;
}
}
if ($directory_unlink) {
$this->unlink($name);
}
}
}
/**
* Gets the full path of the file storage directory's parent.
*
* @param string $name
* The virtual file name. Can be a relative path.
*
* @return string
* The full path of the containing directory where the file is or should be
* stored.
*/
protected function getContainingDirectoryFullPath($name) {
// Remove the .php file extension from the directory name.
// Within a single directory, a subdirectory cannot have the same name as a
// file. Thus, when switching between MTimeProtectedFastFileStorage and
// FileStorage, the subdirectory or the file cannot be created in case the
// other file type exists already.
if (str_ends_with($name, '.php')) {
$name = substr($name, 0, -4);
}
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);
}
/**
* A brute force tempnam implementation supporting streams.
*
* @param string $directory
* The directory where the temporary filename will be created.
* @param string $prefix
* The prefix of the generated temporary filename.
*
* @return string
* Returns the new temporary filename (with path), or FALSE on failure.
*/
protected function tempnam($directory, $prefix) {
do {
$path = $directory . '/' . $prefix . Crypt::randomBytesBase64(20);
} while (file_exists($path));
return $path;
}
}