CVE-2021-3916 (PHP, published 2021-11-05T15: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/Http/Controllers/Images/ImageController.php
app/Uploads/AttachmentService.php
app/Uploads/ImageService.php
tests/Uploads/ImageTest.php
Commit message: Updated showImage file serving to not be traversable

For #3030


Vulnerable code:
    public function __construct(Image $image, File $file, ImageRepo $imageRepo)
        $path = storage_path('uploads/images/' . $path);
        if (!file_exists($path)) {
        return response()->file($path);
    protected function getStorage(): FileSystemInstance
        return $this->getStorage()->get($this->adjustPathForStorageDisk($attachment->path));
        $storage = $this->getStorage();
        $storage = $this->getStorage();
    protected function getStorage(string $imageType = ''): FileSystemInstance
        $storage = $this->getStorage($type);
        $storage = $this->getStorage($image->type);
        $storage = $this->getStorage();
        $storage = $this->getStorage($imageType);
        $storage = $this->getStorage();

Fixed code:
use BookStack\Uploads\ImageService;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\Util;
    protected $imageService;
    public function __construct(Image $image, File $file, ImageRepo $imageRepo, ImageService $imageService)
        $this->imageService = $imageService;
        if (!$this->imageService->pathExistsInLocalSecure($path)) {
        return $this->imageService->streamImageFromStorageResponse('gallery', $path);
    protected function getStorageDisk(): FileSystemInstance
        return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path));
        $storage = $this->getStorageDisk();
        $storage = $this->getStorageDisk();
use Symfony\Component\HttpFoundation\StreamedResponse;
    protected function getStorageDisk(string $imageType = ''): FileSystemInstance
    /**
     * Check if local secure image storage (Fetched behind authentication)
     * is currently active in the instance.
     */
    protected function usingSecureImages(): bool
    {
        return $this->getStorageDiskName('gallery') === 'local_secure_images';
    }

        $storage = $this->getStorageDisk($type);
        $storage = $this->getStorageDisk($image->type);
        $storage = $this->getStorageDisk();
        $storage = $this->getStorageDisk($imageType);
        $storage = $this->getStorageDisk();
    /**
     * Check if the given path exists in the local secure image system.
     * Returns false if local_secure is not in use.
     */
    public function pathExistsInLocalSecure(string $imagePath): bool
    {
        $disk = $this->getStorageDisk('gallery');

        // Check local_secure is active
        return $this->usingSecureImages()
            // Check the image file exists
            && $disk->exists($imagePath)
            // Check the file is likely an image file
            && strpos($disk->getMimetype($imagePath), 'image/') === 0;
    }

    /**
     * For the given path, if existing, provide a response that will stream the image contents.
     */
    public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
    {
        $disk = $this->getStorageDisk($imageType);
        return $disk->response($path);
    }

    public function test_secure_image_paths_traversal_causes_500()
    {
        config()->set('filesystems.images', 'local_secure');
        $this->asEditor();

        $resp = $this->get('/uploads/images/../../logs/laravel.log');
        $resp->assertStatus(500);
    }

    public function test_secure_image_paths_traversal_on_non_secure_images_causes_404()
    {
        config()->set('filesystems.images', 'local');
        $this->asEditor();

        $resp = $this->get('/uploads/images/../../logs/laravel.log');
        $resp->assertStatus(404);
    }

    public function test_secure_image_paths_dont_serve_non_images()
    {
        config()->set('filesystems.images', 'local_secure');
        $this->asEditor();

        $testFilePath = storage_path('/uploads/images/testing.txt');
        file_put_contents($testFilePath, 'hello from test_secure_image_paths_dont_serve_non_images');

        $resp = $this->get('/uploads/images/testing.txt');
        $resp->assertStatus(404);
    }