Added protections against path traversal in file system operations

- Files within the storage/ path could be accessed via path traversal
  references in content, accessed upon HTML export.
- This addresses this via two layers:
  - Scoped local flysystem filesystems down to the specific image &
    file folders since flysystem has built-in checking against the
    escaping of the root folder.
  - Added path normalization before enforcement of uploads/{images,file}
    prefix to prevent traversal at a path level.

Thanks to @Haxatron via huntr.dev for discovery and reporting.
Ref: https://huntr.dev/bounties/ac268a17-72b5-446f-a09a-9945ef58607a/
This commit is contained in:
Dan Brown 2021-10-08 17:47:14 +01:00
parent 81d6b1b016
commit 7224fbcc89
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
3 changed files with 92 additions and 54 deletions

View File

@ -37,9 +37,14 @@ return [
'root' => public_path(),
],
'local_secure' => [
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path(),
'root' => storage_path('uploads/files/'),
],
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
],
's3' => [

View File

@ -9,6 +9,7 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\Flysystem\Util;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService
@ -27,15 +28,39 @@ class AttachmentService
* Get the storage that will be used for storing files.
*/
protected function getStorage(): FileSystemInstance
{
return $this->fileSystem->disk($this->getStorageDiskName());
}
/**
* Get the name of the storage disk to use.
*/
protected function getStorageDiskName(): string
{
$storageType = config('filesystems.attachments');
// Override default location if set to local public to ensure not visible.
if ($storageType === 'local') {
$storageType = 'local_secure';
// Change to our secure-attachment disk if any of the local options
// are used to prevent escaping that location.
if ($storageType === 'local' || $storageType === 'local_secure') {
$storageType = 'local_secure_attachments';
}
return $this->fileSystem->disk($storageType);
return $storageType;
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForStorageDisk(string $path): string
{
$path = Util::normalizePath(str_replace('uploads/files/', '', $path));
if ($this->getStorageDiskName() === 'local_secure_attachments') {
return $path;
}
return 'uploads/files/' . $path;
}
/**
@ -45,26 +70,21 @@ class AttachmentService
*/
public function getAttachmentFromStorage(Attachment $attachment): string
{
return $this->getStorage()->get($attachment->path);
return $this->getStorage()->get($this->adjustPathForStorageDisk($attachment->path));
}
/**
* Store a new attachment upon user upload.
*
* @param UploadedFile $uploadedFile
* @param int $page_id
*
* @throws FileUploadException
*
* @return Attachment
*/
public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment
{
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($uploadedFile);
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
$largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $page_id)->max('order');
$attachment = Attachment::forceCreate([
/** @var Attachment $attachment */
$attachment = Attachment::query()->forceCreate([
'name' => $attachmentName,
'path' => $attachmentPath,
'extension' => $uploadedFile->getClientOriginalExtension(),
@ -78,17 +98,12 @@ class AttachmentService
}
/**
* Store a upload, saving to a file and deleting any existing uploads
* Store an upload, saving to a file and deleting any existing uploads
* attached to that file.
*
* @param UploadedFile $uploadedFile
* @param Attachment $attachment
*
* @throws FileUploadException
*
* @return Attachment
*/
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
{
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
@ -159,9 +174,6 @@ class AttachmentService
/**
* Delete a File from the database and storage.
*
* @param Attachment $attachment
*
* @throws Exception
*/
public function deleteFile(Attachment $attachment)
@ -179,15 +191,13 @@ class AttachmentService
/**
* Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders.
*
* @param Attachment $attachment
*/
protected function deleteFileInStorage(Attachment $attachment)
{
$storage = $this->getStorage();
$dirPath = dirname($attachment->path);
$dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
$storage->delete($attachment->path);
$storage->delete($this->adjustPathForStorageDisk($attachment->path));
if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath);
}
@ -195,14 +205,9 @@ class AttachmentService
/**
* Store a file in storage with the given filename.
*
* @param UploadedFile $uploadedFile
*
* @throws FileUploadException
*
* @return string
*/
protected function putFileInStorage(UploadedFile $uploadedFile)
protected function putFileInStorage(UploadedFile $uploadedFile): string
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());
@ -210,14 +215,14 @@ class AttachmentService
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
$uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
while ($storage->exists($basePath . $uploadFileName)) {
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
$uploadFileName = Str::random(3) . $uploadFileName;
}
$attachmentPath = $basePath . $uploadFileName;
try {
$storage->put($attachmentPath, $attachmentData);
$storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
} catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage());

View File

@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\ImageManager;
use League\Flysystem\Util;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageService
@ -38,16 +39,43 @@ class ImageService
/**
* Get the storage that will be used for storing images.
*/
protected function getStorage(string $type = ''): FileSystemInstance
protected function getStorage(string $imageType = ''): FileSystemInstance
{
return $this->fileSystem->disk($this->getStorageDiskName($imageType));
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
{
$path = Util::normalizePath(str_replace('uploads/images/', '', $path));
if ($this->getStorageDiskName($imageType) === 'local_secure_images') {
return $path;
}
return 'uploads/images/' . $path;
}
/**
* Get the name of the storage disk to use.
*/
protected function getStorageDiskName(string $imageType): string
{
$storageType = config('filesystems.images');
// Ensure system images (App logo) are uploaded to a public space
if ($type === 'system' && $storageType === 'local_secure') {
if ($imageType === 'system' && $storageType === 'local_secure') {
$storageType = 'local';
}
return $this->fileSystem->disk($storageType);
if ($storageType === 'local_secure') {
$storageType = 'local_secure_images';
}
return $storageType;
}
/**
@ -104,7 +132,7 @@ class ImageService
$imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
while ($storage->exists($imagePath . $fileName)) {
while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
$fileName = Str::random(3) . $fileName;
}
@ -114,7 +142,7 @@ class ImageService
}
try {
$this->saveImageDataInPublicSpace($storage, $fullPath, $imageData);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
} catch (Exception $e) {
\Log::error('Error when attempting image upload:' . $e->getMessage());
@ -216,13 +244,13 @@ class ImageService
}
$storage = $this->getStorage($image->type);
if ($storage->exists($thumbFilePath)) {
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
return $this->getPublicUrl($thumbFilePath);
}
$thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
$thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $thumbFilePath, $thumbData);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
@ -279,10 +307,8 @@ class ImageService
*/
public function getImageData(Image $image): string
{
$imagePath = $image->path;
$storage = $this->getStorage();
return $storage->get($imagePath);
return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
}
/**
@ -292,7 +318,7 @@ class ImageService
*/
public function destroy(Image $image)
{
$this->destroyImagesFromPath($image->path);
$this->destroyImagesFromPath($image->path, $image->type);
$image->delete();
}
@ -300,9 +326,10 @@ class ImageService
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path.
*/
protected function destroyImagesFromPath(string $path): bool
protected function destroyImagesFromPath(string $path, string $imageType): bool
{
$storage = $this->getStorage();
$path = $this->adjustPathForStorageDisk($path, $imageType);
$storage = $this->getStorage($imageType);
$imageFolder = dirname($path);
$imageFileName = basename($path);
@ -326,7 +353,7 @@ class ImageService
}
/**
* Check whether or not a folder is empty.
* Check whether a folder is empty.
*/
protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
{
@ -374,7 +401,7 @@ class ImageService
}
/**
* Convert a image URI to a Base64 encoded string.
* Convert an image URI to a Base64 encoded string.
* Attempts to convert the URL to a system storage url then
* fetch the data from the disk or storage location.
* Returns null if the image data cannot be fetched from storage.
@ -388,6 +415,7 @@ class ImageService
return null;
}
$storagePath = $this->adjustPathForStorageDisk($storagePath);
$storage = $this->getStorage();
$imageData = null;
if ($storage->exists($storagePath)) {