CVE-2021-3874 (PHP, published 2021-10-15T14:15Z)
Description: value: bookstack is vulnerable to Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Severity: MEDIUM (CVSS v3 base score: 6.5)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
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.
Repository: https://github.com/bookstackapp/bookstack
Files changed:
app/Config/filesystems.php
app/Uploads/AttachmentService.php
app/Uploads/ImageService.php
Commit message: 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/


Vulnerable code:
        'local_secure' => [
            'root'   => storage_path(),
        // Override default location if set to local public to ensure not visible.
        if ($storageType === 'local') {
            $storageType = 'local_secure';
        return $this->fileSystem->disk($storageType);
        return $this->getStorage()->get($attachment->path);
     *
     * @param UploadedFile $uploadedFile
     * @param int          $page_id
     *
     *
     * @return Attachment
    public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
        $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
        $attachment = Attachment::forceCreate([
     * Store a upload, saving to a file and deleting any existing uploads
     * @param UploadedFile $uploadedFile
     * @param Attachment   $attachment
     *
     *
     * @return Attachment
    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
     *
     * @param Attachment $attachment
     *
     *
     * @param Attachment $attachment
        $dirPath = dirname($attachment->path);
        $storage->delete($attachment->path);
     *
     * @param UploadedFile $uploadedFile
     *
     *
     * @return string
    protected function putFileInStorage(UploadedFile $uploadedFile)
        while ($storage->exists($basePath . $uploadFileName)) {
            $storage->put($attachmentPath, $attachmentData);
    protected function getStorage(string $type = ''): FileSystemInstance
        if ($type === 'system' && $storageType === 'local_secure') {
        return $this->fileSystem->disk($storageType);
        while ($storage->exists($imagePath . $fileName)) {
            $this->saveImageDataInPublicSpace($storage, $fullPath, $imageData);
        if ($storage->exists($thumbFilePath)) {
        $thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
        $this->saveImageDataInPublicSpace($storage, $thumbFilePath, $thumbData);
        $imagePath = $image->path;

        return $storage->get($imagePath);
        $this->destroyImagesFromPath($image->path);
    protected function destroyImagesFromPath(string $path): bool
        $storage = $this->getStorage();
     * Check whether or not a folder is empty.
     * Convert a image URI to a Base64 encoded string.

Fixed code:
        'local_secure_attachments' => [
            'root'   => storage_path('uploads/files/'),
        ],

        'local_secure_images' => [
            'driver' => 'local',
            'root'   => storage_path('uploads/images/'),
use League\Flysystem\Util;
    {
        return $this->fileSystem->disk($this->getStorageDiskName());
    }

    /**
     * Get the name of the storage disk to use.
     */
    protected function getStorageDiskName(): string
        // 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 'uploads/files/' . $path;
        return $this->getStorage()->get($this->adjustPathForStorageDisk($attachment->path));
    public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment
        $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $page_id)->max('order');
        /** @var Attachment $attachment */
        $attachment = Attachment::query()->forceCreate([
     * Store an upload, saving to a file and deleting any existing uploads
    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
        $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
        $storage->delete($this->adjustPathForStorageDisk($attachment->path));
    protected function putFileInStorage(UploadedFile $uploadedFile): string
        while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
            $storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
use League\Flysystem\Util;
    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
        if ($imageType === 'system' && $storageType === 'local_secure') {
        if ($storageType === 'local_secure') {
            $storageType = 'local_secure_images';
        }

        return $storageType;
        while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
            $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
        if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
        $thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
        $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
        return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
        $this->destroyImagesFromPath($image->path, $image->type);
    protected function destroyImagesFromPath(string $path, string $imageType): bool
        $path = $this->adjustPathForStorageDisk($path, $imageType);
        $storage = $this->getStorage($imageType);
     * Check whether a folder is empty.
     * Convert an image URI to a Base64 encoded string.
        $storagePath = $this->adjustPathForStorageDisk($storagePath);