CVE-2022-0877 (PHP, published 2022-03-08T13:15Z)
Description: value: Cross-site Scripting (XSS) - Stored in GitHub repository bookstackapp/bookstack prior to v22.02.3.
Severity: LOW (CVSS v3 base score: 5.4)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
Repository: https://github.com/bookstackapp/bookstack
Files changed:
.env.example.complete
app/Config/app.php
app/Entities/Tools/ExportFormatter.php
app/Http/Middleware/ApplyCspRules.php
app/Util/CspService.php
resources/views/layouts/export.blade.php
tests/Entity/ExportTest.php
tests/SecurityHeaderTest.php
Commit message: Updated CSP with frame-src rules

- Configurable via 'ALLOWED_IFRAME_SOURCES' .env option.
- Also updated how CSP rules are set, with a single header being used
  instead of many.
- Also applied CSP rules to HTML export outputs.
- Updated tests to cover.

For #3314


Vulnerable code:
    protected $imageService;
    protected $pdfGenerator;
    public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
            'page'   => $page,
            'format' => 'html',
            'chapter' => $chapter,
            'pages'   => $pages,
            'format'  => 'html',
    /**
     * @var CspService
     */
    protected $cspService;
        $this->cspService->setFrameAncestors($response);
        $this->cspService->setScriptSrc($response);
        $this->cspService->setObjectSrc($response);
        $this->cspService->setBaseUri($response);
use Symfony\Component\HttpFoundation\Response;
    /** @var string */
    protected $nonce;
     * Sets CSP 'script-src' headers to restrict the forms of script that can
     * run on the page.
    public function setScriptSrc(Response $response)
            return;
        $value = 'script-src ' . implode(' ', $parts);
        $response->headers->set('Content-Security-Policy', $value, false);
     * Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
     * iframed within. Also adjusts the cookie samesite options so that cookies will
     * operate in the third-party context.
    public function setFrameAncestors(Response $response)
        $cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
        $response->headers->set('Content-Security-Policy', $cspValue, false);
     * Check if the user has configured some allowed iframe hosts.
    public function allowedIFrameHostsConfigured(): bool
        return count($this->getAllowedIframeHosts()) > 0;
     * Sets CSP 'object-src' headers to restrict the types of dynamic content
    public function setObjectSrc(Response $response)
            return;
        $response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
     * Sets CSP 'base-uri' headers to restrict what base tags can be set on
    public function setBaseUri(Response $response)
        $response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
            $resp->assertDontSee('script');
        $cspHeaders = collect($resp->headers->all('Content-Security-Policy'));
        return $cspHeaders->filter(function ($val) use ($type) {
            return strpos($val, $type) === 0;
        })->first() ?? '';

Fixed code:
# A list of sources/hostnames that can be loaded within iframes within BookStack.
# Space separated if multiple. BookStack host domain is auto-inferred.
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
# Defaults to a set of common services.
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"

    // A list of sources/hostnames that can be loaded within iframes within BookStack.
    // Space separated if multiple. BookStack host domain is auto-inferred.
    // Can be set to a lone "*" to allow all sources for iframe content (Not advised).
    // Defaults to a set of common services.
    // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
    'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),

use BookStack\Util\CspService;
    protected ImageService $imageService;
    protected PdfGenerator $pdfGenerator;
    protected CspService $cspService;
    public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
        $this->cspService = $cspService;
            'page'       => $page,
            'format'     => 'html',
            'cspContent' => $this->cspService->getCspMetaTagValue(),
            'chapter'    => $chapter,
            'pages'      => $pages,
            'format'     => 'html',
            'cspContent' => $this->cspService->getCspMetaTagValue(),
            'cspContent'   => $this->cspService->getCspMetaTagValue(),
    protected CspService $cspService;
        $cspHeader = $this->cspService->getCspHeader();
        $response->headers->set('Content-Security-Policy', $cspHeader, false);
    protected string $nonce;
     * Get the CSP headers for the application
    public function getCspHeader(): string
    {
        $headers = [
            $this->getFrameAncestors(),
            $this->getFrameSrc(),
            $this->getScriptSrc(),
            $this->getObjectSrc(),
            $this->getBaseUri(),
        ];

        return implode('; ', array_filter($headers));
    }

    /**
     * Get the CSP rules for the application for a HTML meta tag.
     */
    public function getCspMetaTagValue(): string
    {
        $headers = [
            $this->getFrameSrc(),
            $this->getScriptSrc(),
            $this->getObjectSrc(),
            $this->getBaseUri(),
        ];

        return implode('; ', array_filter($headers));
    }

    /**
     * Check if the user has configured some allowed iframe hosts.
     */
    public function allowedIFrameHostsConfigured(): bool
    {
        return count($this->getAllowedIframeHosts()) > 0;
    }

    /**
     * Create CSP 'script-src' rule to restrict the forms of script that can run on the page.
     */
    protected function getScriptSrc(): string
            return '';
        return 'script-src ' . implode(' ', $parts);
     * Create CSP "frame-ancestors" rule to restrict the hosts that BookStack can be iframed within.
    protected function getFrameAncestors(): string
        return 'frame-ancestors ' . implode(' ', $iframeHosts);
     * Creates CSP "frame-src" rule to restrict what hosts/sources can be loaded
     * within iframes to provide an allow-list-style approach to iframe content.
    protected function getFrameSrc(): string
        $iframeHosts = $this->getAllowedIframeSources();
        array_unshift($iframeHosts, "'self'");
        return 'frame-src ' . implode(' ', $iframeHosts);
     * Creates CSP 'object-src' rule to restrict the types of dynamic content
    protected function getObjectSrc(): string
            return '';
        return "object-src 'self'";
     * Creates CSP 'base-uri' rule to restrict what base tags can be set on
    protected function getBaseUri(): string
        return "base-uri 'self'";

    protected function getAllowedIframeSources(): array
    {
        $sources = config('app.iframe_sources', '');
        $hosts = array_filter(explode(' ', $sources));

        // Extract drawing service url to allow embedding if active
        $drawioConfigValue = config('services.drawio');
        if ($drawioConfigValue) {
            $drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/';
            $drawioSourceParsed = parse_url($drawioSource);
            $drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host'];
            $hosts[] = $drawioHost;
        }

        return $hosts;
    }
    @if($cspContent ?? false)
        <meta http-equiv="Content-Security-Policy" content="{{ $cspContent }}">
    @endif

            $resp->assertDontSee('<script', false);

    public function test_html_exports_contain_csp_meta_tag()
    {
        $entities = [
            Page::query()->first(),
            Book::query()->first(),
            Chapter::query()->first(),
        ];

        foreach ($entities as $entity) {
            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
            $resp->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]');
        }
    }
    public function test_frame_src_csp_header_set()
    {
        $resp = $this->get('/');
        $scriptHeader = $this->getCspHeader($resp, 'frame-src');
        $this->assertEquals('frame-src \'self\' https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com', $scriptHeader);
    }

    public function test_frame_src_csp_header_has_drawio_host_added()
    {
        config()->set([
            'app.iframe_sources' => 'https://example.com',
            'services.drawio'   => 'https://diagrams.example.com/testing?cat=dog',
        ]);

        $resp = $this->get('/');
        $scriptHeader = $this->getCspHeader($resp, 'frame-src');
        $this->assertEquals('frame-src \'self\' https://example.com https://diagrams.example.com', $scriptHeader);
    }

        $cspHeaders = explode('; ', $resp->headers->get('Content-Security-Policy'));

        foreach ($cspHeaders as $cspHeader) {
            if (strpos($cspHeader, $type) === 0) {
                return $cspHeader;
            }
        }
        return '';