1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2024-11-24 03:42:32 +01:00

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(), 'root' => public_path(),
], ],
'local_secure' => [ 'local_secure_attachments' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path(), 'root' => storage_path('uploads/files/'),
],
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
], ],
's3' => [ 's3' => [

View File

@ -9,6 +9,7 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Flysystem\Util;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService class AttachmentService
@ -27,15 +28,39 @@ class AttachmentService
* Get the storage that will be used for storing files. * Get the storage that will be used for storing files.
*/ */
protected function getStorage(): FileSystemInstance 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'); $storageType = config('filesystems.attachments');
// Override default location if set to local public to ensure not visible. // Change to our secure-attachment disk if any of the local options
if ($storageType === 'local') { // are used to prevent escaping that location.
$storageType = 'local_secure'; 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 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. * Store a new attachment upon user upload.
*
* @param UploadedFile $uploadedFile
* @param int $page_id
*
* @throws FileUploadException * @throws FileUploadException
*
* @return Attachment
*/ */
public function saveNewUpload(UploadedFile $uploadedFile, $page_id) public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment
{ {
$attachmentName = $uploadedFile->getClientOriginalName(); $attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($uploadedFile); $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, 'name' => $attachmentName,
'path' => $attachmentPath, 'path' => $attachmentPath,
'extension' => $uploadedFile->getClientOriginalExtension(), '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. * attached to that file.
* *
* @param UploadedFile $uploadedFile
* @param Attachment $attachment
*
* @throws FileUploadException * @throws FileUploadException
*
* @return Attachment
*/ */
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment) public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
{ {
if (!$attachment->external) { if (!$attachment->external) {
$this->deleteFileInStorage($attachment); $this->deleteFileInStorage($attachment);
@ -159,9 +174,6 @@ class AttachmentService
/** /**
* Delete a File from the database and storage. * Delete a File from the database and storage.
*
* @param Attachment $attachment
*
* @throws Exception * @throws Exception
*/ */
public function deleteFile(Attachment $attachment) public function deleteFile(Attachment $attachment)
@ -179,15 +191,13 @@ class AttachmentService
/** /**
* Delete a file from the filesystem it sits on. * Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders. * Cleans any empty leftover folders.
*
* @param Attachment $attachment
*/ */
protected function deleteFileInStorage(Attachment $attachment) protected function deleteFileInStorage(Attachment $attachment)
{ {
$storage = $this->getStorage(); $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) { if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath); $storage->deleteDirectory($dirPath);
} }
@ -195,14 +205,9 @@ class AttachmentService
/** /**
* Store a file in storage with the given filename. * Store a file in storage with the given filename.
*
* @param UploadedFile $uploadedFile
*
* @throws FileUploadException * @throws FileUploadException
*
* @return string
*/ */
protected function putFileInStorage(UploadedFile $uploadedFile) protected function putFileInStorage(UploadedFile $uploadedFile): string
{ {
$attachmentData = file_get_contents($uploadedFile->getRealPath()); $attachmentData = file_get_contents($uploadedFile->getRealPath());
@ -210,14 +215,14 @@ class AttachmentService
$basePath = 'uploads/files/' . date('Y-m-M') . '/'; $basePath = 'uploads/files/' . date('Y-m-M') . '/';
$uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension(); $uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
while ($storage->exists($basePath . $uploadFileName)) { while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
$uploadFileName = Str::random(3) . $uploadFileName; $uploadFileName = Str::random(3) . $uploadFileName;
} }
$attachmentPath = $basePath . $uploadFileName; $attachmentPath = $basePath . $uploadFileName;
try { try {
$storage->put($attachmentPath, $attachmentData); $storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage()); 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 Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException; use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use League\Flysystem\Util;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageService class ImageService
@ -38,16 +39,43 @@ class ImageService
/** /**
* Get the storage that will be used for storing images. * 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'); $storageType = config('filesystems.images');
// Ensure system images (App logo) are uploaded to a public space // 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'; $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') . '/'; $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; $fileName = Str::random(3) . $fileName;
} }
@ -114,7 +142,7 @@ class ImageService
} }
try { try {
$this->saveImageDataInPublicSpace($storage, $fullPath, $imageData); $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
} catch (Exception $e) { } catch (Exception $e) {
\Log::error('Error when attempting image upload:' . $e->getMessage()); \Log::error('Error when attempting image upload:' . $e->getMessage());
@ -216,13 +244,13 @@ class ImageService
} }
$storage = $this->getStorage($image->type); $storage = $this->getStorage($image->type);
if ($storage->exists($thumbFilePath)) { if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
return $this->getPublicUrl($thumbFilePath); 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); $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath); return $this->getPublicUrl($thumbFilePath);
@ -279,10 +307,8 @@ class ImageService
*/ */
public function getImageData(Image $image): string public function getImageData(Image $image): string
{ {
$imagePath = $image->path;
$storage = $this->getStorage(); $storage = $this->getStorage();
return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
return $storage->get($imagePath);
} }
/** /**
@ -292,7 +318,7 @@ class ImageService
*/ */
public function destroy(Image $image) public function destroy(Image $image)
{ {
$this->destroyImagesFromPath($image->path); $this->destroyImagesFromPath($image->path, $image->type);
$image->delete(); $image->delete();
} }
@ -300,9 +326,10 @@ class ImageService
* Destroys an image at the given path. * Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided 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); $imageFolder = dirname($path);
$imageFileName = basename($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 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 * Attempts to convert the URL to a system storage url then
* fetch the data from the disk or storage location. * fetch the data from the disk or storage location.
* Returns null if the image data cannot be fetched from storage. * Returns null if the image data cannot be fetched from storage.
@ -388,6 +415,7 @@ class ImageService
return null; return null;
} }
$storagePath = $this->adjustPathForStorageDisk($storagePath);
$storage = $this->getStorage(); $storage = $this->getStorage();
$imageData = null; $imageData = null;
if ($storage->exists($storagePath)) { if ($storage->exists($storagePath)) {