Repository navigation
Expand file tree
/
Copy pathLocalStore.php
More file actions
401 lines (330 loc) · 13 KB
/
Copy pathLocalStore.php
File metadata and controls
401 lines (330 loc) · 13 KB
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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix Storage - LocalStore
*
* @package Italix\Storage
*/
declare(strict_types=1);
namespace Italix\Storage;
/**
* Files on the local filesystem, under one root.
*
* ## The caller never chooses the filename
*
* This is the whole safety model, and it is the same shape `Italix\Encode` and
* `Italix\DataSets` use: refuse the untrusted thing rather than try to clean it.
*
* The stored path ends in `{sha256}.{ext}` where:
*
* - **`sha256`** is the hash of the content. Nothing an uploader controls
* reaches the filename, so `../../../var/www/shell.php` is not a path
* traversal — it is a name recorded in a column and never used.
* - **`ext`** comes from **sniffing the bytes** through a whitelist, never from
* the upload. A file called `logo.png` whose content is PHP gets whatever its
* bytes actually are, and PHP is on no whitelist, so it is refused.
*
* A pleasant side effect: identical content stores once. Re-uploading the same
* document is free and de-duplicated.
*
* ## Three things are configurable, and none of them is the safety model
*
* - **`PathStrategy`** — where the file lands. Sizing and housekeeping.
* - **`MimeCatalog`** — which types are acceptable. Still a whitelist, still
* compulsory; the application decides what is on it.
* - **permissions** — for directories and files.
*
* What is *not* configurable: that the name comes from the content, that the
* type is sniffed rather than reported, and that the resolved path is verified
* to be inside the root. A knob for any of those would be a knob for turning
* the security off.
*
* ## The folder is checked too
*
* `$folder` usually comes from the application (`'tenant/' . $tenant_id`) and is
* therefore trusted — but "usually" is how traversals happen. Only
* `[A-Za-z0-9._/-]` is accepted, `..` is refused outright, and the resolved path
* is verified to be inside the root after the fact. Three checks for one
* property, because the cost of being wrong is arbitrary file write.
*
* The strategy's output goes through the same verification, so a third-party
* `PathStrategy` is a layout decision and never a security one.
*/
final class LocalStore implements FileStore
{
/** Where in-progress writes live. Inside the root, so the final move is a
* same-filesystem rename rather than a second full copy. */
private const TEMP_DIR = '.ix-tmp';
private string $root;
private PathStrategy $paths;
private MimeCatalog $mime;
private int $dir_permissions_n;
private int $file_permissions_n;
/**
* @param string $paths where files land; defaults to `HashedPath(2)`
* @param MimeCatalog|null $mime what may be stored; defaults to `MimeCatalog::defaults()`
*/
public function __construct(
string $root,
?PathStrategy $paths = null,
?MimeCatalog $mime = null,
int $dir_permissions_n = 0775,
int $file_permissions_n = 0664
) {
$real = realpath($root);
if ($real === false) {
if (!@mkdir($root, $dir_permissions_n, true) && !is_dir($root)) {
throw new StorageException("Cannot create the storage root: {$root}");
}
$real = realpath($root);
}
if ($real === false || !is_dir($real)) {
throw new StorageException("The storage root is not a directory: {$root}");
}
if (!is_writable($real)) {
throw new StorageException("The storage root is not writable: {$real}");
}
$this->root = rtrim($real, '/');
$this->paths = $paths ?? new HashedPath();
$this->mime = $mime ?? MimeCatalog::defaults();
$this->dir_permissions_n = $dir_permissions_n;
$this->file_permissions_n = $file_permissions_n;
}
public function root(): string
{
return $this->root;
}
public function paths(): PathStrategy
{
return $this->paths;
}
public function mime(): MimeCatalog
{
return $this->mime;
}
public function put(string $folder, string $source_path, string $original_name_c = ''): StoredFile
{
if (!is_file($source_path)) {
throw new StorageException("There is no file at: {$source_path}");
}
// The source belongs to the caller, so it is copied, never moved.
return $this->accept($folder, $source_path, $original_name_c, false);
}
public function put_contents(string $folder, string $contents, string $original_name_c = ''): StoredFile
{
$temp = $this->temp_path();
try {
if (@file_put_contents($temp, $contents) === false) {
throw new StorageException('Cannot write the temporary file.');
}
// Written to a file first so the type is sniffed from bytes on disk,
// exactly as an upload is — one code path, one set of rules.
return $this->accept($folder, $temp, $original_name_c, true);
} finally {
@unlink($temp);
}
}
/**
* @param resource $handle
*/
public function put_stream(string $folder, $handle, string $original_name_c = ''): StoredFile
{
if (!is_resource($handle)) {
throw new StorageException('put_stream() needs an open stream resource.');
}
$temp = $this->temp_path();
$out = @fopen($temp, 'wb');
if ($out === false) {
throw new StorageException("Cannot open a temporary file for writing: {$temp}");
}
try {
// Constant memory whatever the size: the bytes go straight from one
// handle to the other and are never assembled into a PHP string.
if (@stream_copy_to_stream($handle, $out) === false) {
throw new StorageException('Cannot copy the stream to disk.');
}
// Closed before sniffing, so finfo reads what was actually flushed
// rather than whatever happens to have reached the disk so far.
fclose($out);
$out = false;
return $this->accept($folder, $temp, $original_name_c, true);
} finally {
if (is_resource($out)) {
fclose($out);
}
@unlink($temp);
}
}
public function has(string $path_c): bool
{
return is_file($this->absolute($path_c));
}
public function read(string $path_c): ?string
{
$absolute = $this->absolute($path_c);
if (!is_file($absolute)) {
return null;
}
$contents = @file_get_contents($absolute);
return $contents === false ? null : $contents;
}
/**
* @return resource|null
*/
public function read_stream(string $path_c)
{
$absolute = $this->absolute($path_c);
if (!is_file($absolute)) {
return null;
}
$handle = @fopen($absolute, 'rb');
return $handle === false ? null : $handle;
}
public function absolute_path(string $path_c): ?string
{
$absolute = $this->absolute($path_c);
return is_file($absolute) ? $absolute : null;
}
/**
* Always null: a local store owns no URL space.
*
* Returning something invented — a `file://` URL, a guess at a document root
* — would be worse than null, because a caller would build a link out of it
* and only find out in production. Null says "serve this yourself", which is
* the truth, and an application that wants URLs writes one route.
*/
public function temporary_url(string $path_c, int $ttl_n = 300): ?string
{
return null;
}
public function delete(string $path_c): bool
{
$absolute = $this->absolute($path_c);
return !is_file($absolute) || @unlink($absolute);
}
public function size_n(string $path_c): int
{
$absolute = $this->absolute($path_c);
return is_file($absolute) ? (int) filesize($absolute) : 0;
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
/**
* Sniff, hash, place and write. The single path every public write goes
* through, so the rules cannot differ between an upload and a generated file.
*
* @param bool $may_move true when the source is our own temporary file, in
* which case it is renamed rather than copied — the
* difference between one pass over the bytes and two.
*/
private function accept(string $folder, string $source_path, string $original_name_c, bool $may_move): StoredFile
{
$mime_c = $this->mime->sniff($source_path);
if (!$this->mime->allows($mime_c)) {
throw new StorageException($this->mime->refusal_message($mime_c));
}
$sha256_c = hash_file('sha256', $source_path);
if ($sha256_c === false) {
throw new StorageException("Cannot hash: {$source_path}");
}
$path_c = $this->paths->path_for(
$this->clean_folder($folder),
$sha256_c,
$this->mime->extension_for($mime_c)
);
// The strategy is configuration, and configuration is not trusted: its
// output is verified to stay inside the root exactly like a folder is.
$absolute = $this->absolute($path_c);
$this->ensure_directory(dirname($absolute));
// Identical content is already there: writing again would be work for an
// identical result.
if (!is_file($absolute)) {
$ok = $may_move ? @rename($source_path, $absolute) : @copy($source_path, $absolute);
if (!$ok) {
throw new StorageException("Cannot write to: {$absolute}");
}
@chmod($absolute, $this->file_permissions_n);
}
return new StoredFile(
$path_c,
(int) filesize($absolute),
$sha256_c,
$mime_c,
self::clean_display_name($original_name_c)
);
}
/**
* A scratch path inside the root.
*
* Inside, not in `sys_get_temp_dir()`, so the final `rename()` is an
* intra-filesystem metadata operation. Across a mount point `rename()` fails
* outright on some systems, which would turn a large upload into a silent
* error at the last step.
*/
private function temp_path(): string
{
$dir = $this->root . '/' . self::TEMP_DIR;
$this->ensure_directory($dir);
return $dir . '/' . bin2hex(random_bytes(16)) . '.part';
}
private function clean_folder(string $folder): string
{
$folder = trim($folder, '/');
if ($folder === '') {
return '';
}
if (preg_match('~^[A-Za-z0-9._/-]+$~', $folder) !== 1) {
throw new StorageException(
"Refusing \"{$folder}\" as a folder: only letters, digits, dot, dash, underscore and slash."
);
}
if (strpos($folder, '..') !== false) {
throw new StorageException("Refusing \"{$folder}\" as a folder: it contains \"..\".");
}
return $folder;
}
/**
* Resolve a stored path, and verify it is inside the root.
*
* The third check. `clean_folder()` should already have made this
* impossible; verifying anyway costs one string comparison and is the
* difference between a bug and an arbitrary file write.
*/
private function absolute(string $path_c): string
{
$path_c = ltrim($path_c, '/');
if (strpos($path_c, '..') !== false || preg_match('~^[A-Za-z0-9._/-]+$~', $path_c) !== 1) {
throw new StorageException("Refusing \"{$path_c}\" as a stored path.");
}
$absolute = $this->root . '/' . $path_c;
// realpath() only works on something that exists, so compare the
// normalised prefix for writes and the resolved path for reads.
$resolved = realpath($absolute);
if ($resolved !== false && strncmp($resolved, $this->root . '/', strlen($this->root) + 1) !== 0) {
throw new StorageException("\"{$path_c}\" resolves outside the storage root.");
}
return $absolute;
}
private function ensure_directory(string $dir): void
{
if (is_dir($dir)) {
return;
}
if (!@mkdir($dir, $this->dir_permissions_n, true) && !is_dir($dir)) {
throw new StorageException("Cannot create the directory: {$dir}");
}
}
/**
* The original name, made safe to *display*. Still never used as a path.
*/
private static function clean_display_name(string $name_c): string
{
$name_c = str_replace(["\r", "\n", "\0", '/', '\\'], ' ', $name_c);
return mb_substr(trim($name_c), 0, 200);
}
}