CVE-2021-3915 (PHP, published 2021-11-13T10:15Z)
Description: value: bookstack is vulnerable to Unrestricted Upload of File with Dangerous Type
Severity: LOW (CVSS v3 base score: 5.7)
Weakness: CWE-434 \u2014 Unrestricted Upload of File with Dangerous Type
The product allows the upload or transfer of dangerous file types that are automatically processed within its environment.
Repository: https://github.com/bookstackapp/bookstack
Files changed:
app/Http/Controllers/Controller.php
app/Util/WebSafeMimeSniffer.php
tests/Uploads/AttachmentTest.php
Commit message: Added safe mime sniffing to prevent serving HTML

(Amoung other content types)
For #3027


Vulnerable code:
            'Content-Type'        => 'application/octet-stream',
            'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mime = $finfo->buffer($content) ?: 'application/octet-stream';
            'Content-Type'        => $mime,
            'Content-Disposition' => 'inline; filename="' . $fileName . '"',

Fixed code:
use BookStack\Util\WebSafeMimeSniffer;
            'Content-Type'           => 'application/octet-stream',
            'Content-Disposition'    => 'attachment; filename="' . $fileName . '"',
            'X-Content-Type-Options' => 'nosniff',

        $mime = (new WebSafeMimeSniffer)->sniff($content);
            'Content-Type'           => $mime,
            'Content-Disposition'    => 'inline; filename="' . $fileName . '"',
            'X-Content-Type-Options' => 'nosniff',
<?php

namespace BookStack\Util;

use finfo;

/**
 * Helper class to sniff out the mime-type of content resulting in
 * a mime-type that's relatively safe to serve to a browser.
 */
class WebSafeMimeSniffer
{

    /**
     * @var string[]
     */
    protected $safeMimes = [
        'application/json',
        'application/octet-stream',
        'application/pdf',
        'image/bmp',
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/webp',
        'image/avif',
        'image/heic',
        'text/css',
        'text/csv',
        'text/javascript',
        'text/json',
        'text/plain',
        'video/x-msvideo',
        'video/mp4',
        'video/mpeg',
        'video/ogg',
        'video/webm',
        'video/vp9',
        'video/h264',
        'video/av1',
    ];

    /**
     * Sniff the mime-type from the given file content while running the result
     * through an allow-list to ensure a web-safe result.
     * Takes the content as a reference since the value may be quite large.
     */
    public function sniff(string &$content): string
    {
        $fInfo = new finfo(FILEINFO_MIME_TYPE);
        $mime = $fInfo->buffer($content) ?: 'application/octet-stream';

        if (in_array($mime, $this->safeMimes)) {
            return $mime;
        }

        [$category] = explode('/', $mime, 2);
        if ($category === 'text') {
            return 'text/plain';
        }

        return 'application/octet-stream';
    }

}
use Tests\TestResponse;
    /**
     * Create a new upload attachment from the given data.
     */
    protected function createUploadAttachment(Page $page, string $filename, string $content, string $mimeType): Attachment
    {
        $file = tmpfile();
        $filePath = stream_get_meta_data($file)['uri'];
        file_put_contents($filePath, $content);
        $upload = new UploadedFile($filePath, $filename, $mimeType, null, true);

        $this->call('POST', '/attachments/upload', ['uploaded_to' => $page->id], [], ['file' => $upload], []);
        return $page->attachments()->latest()->firstOrFail();
    }

        $attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff');

    public function test_html_file_access_with_open_forces_plain_content_type()
    {
        $page = Page::query()->first();
        $this->asAdmin();

        $attachment = $this->createUploadAttachment($page, 'test_file.html', '<html></html><p>testing</p>', 'text/html');

        $attachmentGet = $this->get($attachment->getUrl(true));
        // http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
        $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
        $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"');

        $this->deleteUploads();
    }