From a3e2bc758266efe0ac377562f9b1ceb78d863bf5 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Wed, 29 Jul 2026 12:22:33 +1000 Subject: [PATCH] Security Hardening --- .github/README.md | 432 +++++++++++++++++--- .github/workflows/php-syntax.yml | 16 +- .github/workflows/phpcs.yml | 16 +- .github/workflows/phpstan.yml | 16 +- .github/workflows/phpunit.yml | 29 +- CHANGELOG.md | 77 +++- UPGRADE.md | 201 +++++++++ composer.json | 5 +- src/Upload/Exception.php | 20 +- src/Upload/File.php | 177 +++++++- src/Upload/FileInfo.php | 168 +++++++- src/Upload/FileInfoInterface.php | 2 +- src/Upload/Storage/FileSystem.php | 321 ++++++++++++++- src/Upload/Validation/Extension.php | 8 +- src/Upload/Validation/FileType.php | 178 ++++++++ src/Upload/Validation/Mimetype.php | 8 +- src/Upload/Validation/Size.php | 9 +- tests/Upload/ExceptionTest.php | 40 ++ tests/Upload/FileInfoTest.php | 217 +++++++++- tests/Upload/FileTest.php | 311 ++++++++++++++ tests/Upload/Storage/FileSystemTest.php | 494 ++++++++++++++++++++++- tests/Upload/Validation/FileTypeTest.php | 209 ++++++++++ 22 files changed, 2787 insertions(+), 167 deletions(-) create mode 100644 UPGRADE.md create mode 100644 src/Upload/Validation/FileType.php create mode 100644 tests/Upload/ExceptionTest.php create mode 100644 tests/Upload/Validation/FileTypeTest.php diff --git a/.github/README.md b/.github/README.md index f7f4cc6..a3db3c0 100644 --- a/.github/README.md +++ b/.github/README.md @@ -3,99 +3,439 @@ [![codecov](https://codecov.io/gh/GravityPDF/Upload/branch/main/graph/badge.svg)](https://codecov.io/gh/GravityPDF/Upload) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -This component simplifies file validation and uploading. +A PHP library to validate and save uploaded files. **Why was this library forked?** * Original library was abandoned (untouched since 2018) -* Adjusted namespace from \Upload to \GravityPdf\Upload -* Bumped minimum PHP version to 7.3+ -* Sanitized filename and extension, and add UTF-8 filename support +* Safe defaults: existing files are never overwritten, executable and markup extensions are + never written, stored files get mode `0640`, and `upload()` requires validation +* `Validation\FileType` requires the extension and mimetype to match +* Sanitizes filenames and extensions, with UTF-8 filename support +* Stops path traversal, symlinked destinations, dotfiles and control characters in + filenames, and writes through a staged file moved into place rather than writing to the + destination directly * Strict type checking * Added `FileSystem::getDirectory()` and `FileInfo::setNameWithExtension()` methods * Included unreleased code from upstream repo +* Bumped minimum PHP version to 7.3+ * PSR-12 Code Formatting * Automated tools: PHPUnit, PHPStan, PHPCS, and PHP Syntax Checker -TODO: [PSR-7 and PSR-17 support (help wanted)](https://github.com/GravityPDF/Upload/issues/8) - ## Installation ``` composer require gravitypdf/upload ``` +### Requirements + +PHP 7.3 to 8.5 and `ext-fileinfo`. Optional but recommended: `ext-mbstring` (or `symfony/polyfill-mbstring`) so filenames can be guaranteed UTF-8. + +Migrating from `codeguy/upload`? Version 3.x of this package is a drop-in replacement: +update your imports from `\Upload\…` to `\GravityPdf\Upload\…`. + +Upgrading from 3.x? Version 4.0 turns new protections on by default. The +[upgrade guide](https://github.com/GravityPDF/Upload/blob/main/UPGRADE.md) covers what +changed and what to check. + ## Usage +### Single-file upload + Assume a file is uploaded with this HTML form: ```html
- +
``` -When the HTML form is submitted, the server-side PHP code can validate and upload the file like this: +Server-side, validate the upload, rename it, and store it: ```php -$storage = new \GravityPdf\Upload\Storage\FileSystem('/path/to/directory'); -// To override existing files when uploading, pass `true` as the second parameter -// $storage = new \GravityPdf\Upload\Storage\FileSystem('/path/to/directory', true); -$file = new \GravityPdf\Upload\File('foo', $storage); +use GravityPdf\Upload\File; +use GravityPdf\Upload\Storage\FileSystem; +use GravityPdf\Upload\Validation\FileType; +use GravityPdf\Upload\Validation\Size; -// Validate file upload -// MimeType List => http://www.iana.org/assignments/media-types/media-types.xhtml -$file->addValidations([ - // Ensure file is of type "image/png" - new \GravityPdf\Upload\Validation\Mimetype('image/png'), - new \GravityPdf\Upload\Validation\Extension('png'), +// Store uploads where the web server will not execute or serve them directly +$storage = new FileSystem('/path/to/uploads'); - //You can also add multi mimetype validation or extensions - //new \GravityPdf\Upload\Validation\Mimetype(['image/png', 'image/gif']) - //new \GravityPdf\Upload\Validation\Extension(['png', 'gif']), +// Reads $_FILES['avatar'] +$file = new File('avatar', $storage); - // Ensure file is no larger than 5M (use "B", "K", M", or "G") - new \GravityPdf\Upload\Validation\Size('5M'), +// upload() refuses to run unless at least one validation is added +$file->addValidations([ + new FileType('png', 'image/png'), // extension and file contents must both say PNG + new Size('2M'), // max 2 MiB ("B", "K", "M" or "G") ]); -// Access data about the file -// If upload accepts multiple files an array will be returned for each of these +// isValid() also checks is_uploaded_file(), so call it before reading any metadata +if ($file->isValid() === false) { + foreach ($file->getErrors() as $message) { + echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), '
'; // always escape on output + } + + return; +} + +// Store under a random name; keep the client's (sanitized) name for display only +$displayName = $file->getNameWithExtension(); +$file->setName(bin2hex(random_bytes(16))); + +try { + $file->upload(); + + $storedPath = $file->getUploadedFiles()[0]; +} catch (\Exception $e) { + // Validation has already passed, so this is a storage failure: the destination + // exists, the extension is blocked, or the disk is full +} +``` + +`FileType` accepts any registered +[IANA media type](https://www.iana.org/assignments/media-types/media-types.xhtml), such as +`image/png` or `application/pdf`. + +### Reading file metadata + +```php $data = [ - 'name' => $file->getNameWithExtension(), - 'extension' => $file->getExtension(), - 'mime' => $file->getMimetype(), - 'size' => $file->getSize(), - 'md5' => $file->getMd5(), - 'dimensions' => $file->getDimensions(), + 'name' => $file->getNameWithExtension(), // sanitized client name; display only + 'extension' => $file->getExtension(), + 'mime' => $file->getMimetype(), // sniffed from the contents, not the client's claim + 'size' => $file->getSize(), // bytes, or false if the file is unreadable + 'hash' => $file->getHash(), // sha256 unless you pass another algorithm + 'dimensions' => $file->getDimensions(), // ['width' => int, 'height' => int] ]; +``` + +These calls are forwarded to every file in the collection. With one file you get the value +back, with several you get an array of values, and with none you get `null`. When a field +accepts multiple files, read metadata per file instead. + +### Multi-file upload + +```html +
+ + +
+``` + +The `$_FILES` key drops the brackets: `new File('photos', $storage)`. `File` acts as a +collection of the individual files, so `count()`, `foreach` and array offsets all work. A +file that failed to transfer (too large, nothing selected) is left out, and its error +message is already in `getErrors()`. + +```php +use GravityPdf\Upload\File; +use GravityPdf\Upload\Storage\FileSystem; +use GravityPdf\Upload\Validation\FileType; +use GravityPdf\Upload\Validation\Size; + +$storage = new FileSystem('/path/to/uploads'); +$file = new File('photos', $storage); + +$file->addValidations([ + // One format per call, otherwise every extension is paired with every media type + (new FileType(['jpg', 'jpeg'], 'image/jpeg')) + ->allow('png', 'image/png') + ->allow('webp', 'image/webp'), + new Size('10M'), +]); + +// An empty collection has nothing to fail validation, so check the count as well +if (count($file) === 0 || $file->isValid() === false) { + foreach ($file->getErrors() as $message) { + echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), '
'; + } -// If you have an upload field that accepts multiple files you can access each file's info individually -$firstFileName = $file[0]->getNameWithExtension(); -if(isset($file[1])) { - $secondFileName = $file[1]->getNameWithExtension(); + return; } -// or loop over all files for this key -foreach($file as $i => $upload) { - $name = $upload->getNameWithExtension(); - $upload->setName('file-'.$i); +// Rename each file server-side, keeping the client names for display +$manifest = []; +foreach ($file as $photo) { + $displayName = $photo->getNameWithExtension(); + $photo->setName(bin2hex(random_bytes(16))); + + $manifest[] = [ + 'display' => $displayName, + 'stored' => $photo->getNameWithExtension(), + 'hash' => $photo->getHash(), + ]; } -// Try to upload file(s) try { - // Success! $file->upload(); } catch (\Exception $e) { - // Validation errors - $errors = $file->getErrors(); - if(count($errors) === 0) { - // Failed for another reason, like the file already exists - $error = $e->getMessage(); + // Multi-file uploads are not atomic: earlier files may already be on disk. + // getUploadedFiles() lists what was written so the batch can be rolled back. + foreach ($file->getUploadedFiles() as $uploadedPath) { + unlink($uploadedPath); + } + + return; +} +``` + +Individual files are also reachable by offset. Check with `isset($file[0])` first, because +files that failed to transfer are missing from the collection. + +### Lifecycle callbacks + +Four optional hooks fire once per file, each receiving that file's `FileInfoInterface`: +`beforeValidate`, `afterValidate`, `beforeUpload` and `afterUpload`. Use them for per-file +work like renaming or audit logging without writing your own loops: + +```php +use GravityPdf\Upload\FileInfoInterface; + +$file->beforeUpload(static function (FileInfoInterface $fileInfo): void { + $fileInfo->setName(bin2hex(random_bytes(16))); +}); + +$file->afterUpload(static function (FileInfoInterface $fileInfo): void { + error_log(sprintf('Stored upload as %s', $fileInfo->getNameWithExtension())); +}); +``` + +Renaming in `beforeUpload` cannot dodge validation. `upload()` re-runs every validation, +and the storage deny-list is checked against the final name when the file is written. + +### Custom validation rules + +Implement `ValidationInterface` and throw `GravityPdf\Upload\Exception` to reject a file. +The exception message is what `getErrors()` shows the end user: + +```php +use GravityPdf\Upload\Exception; +use GravityPdf\Upload\FileInfoInterface; +use GravityPdf\Upload\ValidationInterface; + +class MaxDimensions implements ValidationInterface +{ + private $maxWidth; + private $maxHeight; + + public function __construct(int $maxWidth, int $maxHeight) + { + $this->maxWidth = $maxWidth; + $this->maxHeight = $maxHeight; + } + + public function validate(FileInfoInterface $fileInfo): void + { + $size = $fileInfo->getDimensions(); + + if ($size['width'] > $this->maxWidth || $size['height'] > $this->maxHeight) { + throw new Exception( + sprintf('Image must be no larger than %dx%d pixels', $this->maxWidth, $this->maxHeight), + $fileInfo + ); + } + } +} + +$file->addValidation(new MaxDimensions(2048, 2048)); +``` + +Failures accumulate rather than abort: every validation runs against every file, and +`getErrors()` reports them all at once. Throwing anything other than +`GravityPdf\Upload\Exception` is caught too, but its message is dropped because PHP +runtime messages can leak server paths. + +### Custom storage backends + +Implement `StorageInterface` to store files somewhere other than the local filesystem: +read from `$fileInfo->getPathname()`, return the destination, and throw +`GravityPdf\Upload\Exception` on failure. + +```php +use GravityPdf\Upload\FileInfoInterface; +use GravityPdf\Upload\StorageInterface; + +class ObjectStorage implements StorageInterface +{ + public function upload(FileInfoInterface $fileInfo): string + { + $key = 'uploads/' . $fileInfo->getNameWithExtension(); + + // ... stream $fileInfo->getPathname() to your object store ... + + return $key; } } ``` +The protections described under "Security notes" (the extension deny-list, the +`basename()` reduction, the symlink refusal, the staged write) live in +`Storage\FileSystem`. A custom backend needs equivalents of its own. + +## Security notes + +**Prefer `FileType` over `Mimetype` and `Extension` separately.** Those two check independent +lists, so content sniffed as `image/gif` stored as `avatar.png` satisfies both. `FileType` +requires the extension and the contents to describe the same format. + +**Generate storage names server-side.** Sanitizing normalises client names, so `report.txt` +and `report!.txt` both land on `report.txt`. A server-side name avoids the collision and the +predictable destination: + +```php +$file->setName(bin2hex(random_bytes(16))); // keep the client name as display metadata only +``` + +**Sanitizing is not escaping.** Unsafe characters are rewritten, not escaped. Escape on +output and use parameterised queries. This applies to `getErrors()`. + +**Call `isValid()` before reading metadata.** It performs the `is_uploaded_file()` check; +the metadata accessors do not. This matters where `$_FILES` is rebuilt by something other +than the PHP SAPI (PSR-7 bridges, test harnesses, middleware). + +**Serve uploads from a directory the web server won't execute.** The storage defaults below +are backstops, not a substitute for that. + +### Extensions blocked by default + +`FileSystem` refuses to write these, whatever the validations allowed. The check runs against +the sanitized extension about to be written and throws `\GravityPdf\Upload\Exception` rather +than recording a validation error. + +| Group | Extensions | +|---|---| +| PHP | `php` `php2` `php3` `php4` `php5` `php6` `php7` `php8` `phps` `phtml` `phtm` `phar` `pht` `inc` | +| Server-side includes | `shtml` `shtm` `stm` | +| CGI and scripts | `cgi` `fcgi` `pl` `py` `rb` `sh` `bash` `ps1` | +| Java | `jsp` `jspx` `jspf` `jsw` `jsv` `jshtml` `jar` `war` | +| ASP / ASP.NET | `asp` `aspx` `asa` `asax` `ascx` `ashx` `asmx` `cer` `cshtml` `vbhtml` | +| Windows binaries | `exe` `dll` `com` `bat` `cmd` `msi` `scr` `vbs` `ws` `wsf` `hta` | +| Server configuration | `htaccess` `htpasswd` `ini` `conf` `config` | +| Markup and script | `html` `htm` `xhtml` `xht` `xhtm` `svg` `svgz` `xml` `xsl` `xslt` `js` `mjs` `swf` `mht` `mhtml` | + +The first seven groups are `FileSystem::EXECUTABLE_EXTENSIONS`, which a server runs. The last +is `FileSystem::MARKUP_EXTENSIONS`, which a browser renders: serving one of those from your own +origin is stored XSS, and SVG belongs there because it carries `