Skip to content

Commit

Permalink
Added protections against path traversal in file system operations
Browse files Browse the repository at this point in the history
- 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/
  • Loading branch information
ssddanbrown committed Oct 8, 2021
1 parent 81d6b1b commit 7224fbc
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 54 deletions.
9 changes: 7 additions & 2 deletions app/Config/filesystems.php
Expand Up @@ -37,9 +37,14 @@
'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' => [
Expand Down
77 changes: 41 additions & 36 deletions app/Uploads/AttachmentService.php
Expand Up @@ -9,6 +9,7 @@
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
Expand All @@ -27,15 +28,39 @@ public function __construct(FileSystem $fileSystem)
* 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 $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 $this->fileSystem->disk($storageType);
return 'uploads/files/' . $path;
}

/**
Expand All @@ -45,26 +70,21 @@ protected function getStorage(): FileSystemInstance
*/
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(),
Expand All @@ -78,17 +98,12 @@ public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
}

/**
* 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);
Expand Down Expand Up @@ -159,9 +174,6 @@ public function updateFile(Attachment $attachment, array $requestData): Attachme

/**
* Delete a File from the database and storage.
*
* @param Attachment $attachment
*
* @throws Exception
*/
public function deleteFile(Attachment $attachment)
Expand All @@ -179,45 +191,38 @@ public function deleteFile(Attachment $attachment)
/**
* 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);
}
}

/**
* 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());

$storage = $this->getStorage();
$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());

Expand Down
60 changes: 44 additions & 16 deletions app/Uploads/ImageService.php
Expand Up @@ -14,6 +14,7 @@
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
Expand All @@ -38,16 +39,43 @@ public function __construct(Image $image, ImageManager $imageTool, FileSystem $f
/**
* 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;
}

/**
Expand Down Expand Up @@ -104,7 +132,7 @@ public function saveNew(string $imageName, string $imageData, string $type, int

$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;
}

Expand All @@ -114,7 +142,7 @@ public function saveNew(string $imageName, string $imageData, string $type, int
}

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());

Expand Down Expand Up @@ -216,13 +244,13 @@ public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRat
}

$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);
Expand Down Expand Up @@ -279,10 +307,8 @@ protected function resizeImage(string $imageData, $width = 220, $height = null,
*/
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));
}

/**
Expand All @@ -292,17 +318,18 @@ public function getImageData(Image $image): string
*/
public function destroy(Image $image)
{
$this->destroyImagesFromPath($image->path);
$this->destroyImagesFromPath($image->path, $image->type);
$image->delete();
}

/**
* 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);
Expand All @@ -326,7 +353,7 @@ protected function destroyImagesFromPath(string $path): bool
}

/**
* Check whether or not a folder is empty.
* Check whether a folder is empty.
*/
protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
{
Expand Down Expand Up @@ -374,7 +401,7 @@ public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = t
}

/**
* 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.
Expand All @@ -388,6 +415,7 @@ public function imageUriToBase64(string $uri): ?string
return null;
}

$storagePath = $this->adjustPathForStorageDisk($storagePath);
$storage = $this->getStorage();
$imageData = null;
if ($storage->exists($storagePath)) {
Expand Down

0 comments on commit 7224fbc

Please sign in to comment.