CVE-2021-3874 (published 2021-10-15T14:15Z)

bookstack is vulnerable to Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Severity: MEDIUM
CVSS v2 base score: 4.0 (AV:N/AC:L/Au:S/C:P/I:N/A:N)
CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/bookstackapp/bookstack
commit_details: repo_url: https://github.com/bookstackapp/bookstack
author: Dan Brown
author_date: 2021-10-08 17:47:14+01:00
msg: 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/
repository: repo_url: https://github.com/bookstackapp/bookstack
repo_name: BookStackApp/BookStack
description: A platform to create documentation/wiki content built with PHP & Laravel
repo_language: PHP
owner: bookstackapp
file_changes: filename: filesystems.php
change_type: ModificationType.MODIFY
diff: @@ -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' => [

programming_language: PHP
filename: AttachmentService.php
change_type: ModificationType.MODIFY
diff: @@ -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 $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;
     }
 
     /**
@@ -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());
 

programming_language: PHP
filename: ImageService.php
change_type: ModificationType.MODIFY
diff: @@ -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)) {

programming_language: PHP