CVE-2022-0877: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site Scripting (XSS) - Stored in GitHub repository bookstackapp/bookstack prior to v22.02.3.
CWE description: 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.
CVSS v3 base score: 5.4
Published: 2022-03-08T13:15Z
Repository: https://github.com/bookstackapp/bookstack
Language: PHP
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 date: 2022-03-07 14:27:41 +0000
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

Diff: diff --git a/.env.example.complete b/.env.example.complete
index 9b3ae7c57b..fb947408df 100644
--- a/.env.example.complete
+++ b/.env.example.complete
@@ -329,10 +329,17 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
 # Space separated if multiple. BookStack host domain is auto-inferred.
 # For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
 # Setting this option will also auto-adjust cookies to be SameSite=None.
 ALLOWED_IFRAME_HOSTS=null
 
+# 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"
+
 # The default and maximum item-counts for listing API requests.
 API_DEFAULT_ITEM_COUNT=100
 API_MAX_ITEM_COUNT=500
 
 # The number of API requests that can be made per minute by a single user.
diff --git a/app/Config/app.php b/app/Config/app.php
index 39bfa7134f..2329043b64 100644
--- a/app/Config/app.php
+++ b/app/Config/app.php
@@ -55,10 +55,17 @@ return [
 
     // A list of hosts that BookStack can be iframed within.
     // Space separated if multiple. BookStack host domain is auto-inferred.
     'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
 
+    // 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'),
+
     // Application timezone for back-end date functions.
     'timezone' => env('APP_TIMEZONE', 'UTC'),
 
     // Default locale to use
     'locale' => env('APP_LANG', 'en'),
diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php
index 7edd1b50f9..9029d7270d 100644
--- a/app/Entities/Tools/ExportFormatter.php
+++ b/app/Entities/Tools/ExportFormatter.php
@@ -5,28 +5,31 @@ namespace BookStack\Entities\Tools;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\CspService;
 use DOMDocument;
 use DOMElement;
 use DOMXPath;
 use Exception;
 use Throwable;
 
 class ExportFormatter
 {
-    protected $imageService;
-    protected $pdfGenerator;
+    protected ImageService $imageService;
+    protected PdfGenerator $pdfGenerator;
+    protected CspService $cspService;
 
     /**
      * ExportService constructor.
      */
-    public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
+    public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
     {
         $this->imageService = $imageService;
         $this->pdfGenerator = $pdfGenerator;
+        $this->cspService = $cspService;
     }
 
     /**
      * Convert a page to a self-contained HTML file.
      * Includes required CSS & image content. Images are base64 encoded into the HTML.
@@ -35,12 +38,13 @@ class ExportFormatter
      */
     public function pageToContainedHtml(Page $page)
     {
         $page->html = (new PageContent($page))->render();
         $pageHtml = view('pages.export', [
-            'page'   => $page,
-            'format' => 'html',
+            'page'       => $page,
+            'format'     => 'html',
+            'cspContent' => $this->cspService->getCspMetaTagValue(),
         ])->render();
 
         return $this->containHtml($pageHtml);
     }
 
@@ -54,13 +58,14 @@ class ExportFormatter
         $pages = $chapter->getVisiblePages();
         $pages->each(function ($page) {
             $page->html = (new PageContent($page))->render();
         });
         $html = view('chapters.export', [
-            'chapter' => $chapter,
-            'pages'   => $pages,
-            'format'  => 'html',
+            'chapter'    => $chapter,
+            'pages'      => $pages,
+            'format'     => 'html',
+            'cspContent' => $this->cspService->getCspMetaTagValue(),
         ])->render();
 
         return $this->containHtml($html);
     }
 
@@ -74,10 +79,11 @@ class ExportFormatter
         $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('books.export', [
             'book'         => $book,
             'bookChildren' => $bookTree,
             'format'       => 'html',
+            'cspContent'   => $this->cspService->getCspMetaTagValue(),
         ])->render();
 
         return $this->containHtml($html);
     }
 
diff --git a/app/Http/Middleware/ApplyCspRules.php b/app/Http/Middleware/ApplyCspRules.php
index 6c9d14e7b6..9f3a8d1d84 100644
--- a/app/Http/Middleware/ApplyCspRules.php
+++ b/app/Http/Middleware/ApplyCspRules.php
@@ -6,14 +6,11 @@ use BookStack\Util\CspService;
 use Closure;
 use Illuminate\Http\Request;
 
 class ApplyCspRules
 {
-    /**
-     * @var CspService
-     */
-    protected $cspService;
+    protected CspService $cspService;
 
     public function __construct(CspService $cspService)
     {
         $this->cspService = $cspService;
     }
@@ -33,13 +30,11 @@ class ApplyCspRules
             config()->set('session.same_site', 'none');
         }
 
         $response = $next($request);
 
-        $this->cspService->setFrameAncestors($response);
-        $this->cspService->setScriptSrc($response);
-        $this->cspService->setObjectSrc($response);
-        $this->cspService->setBaseUri($response);
+        $cspHeader = $this->cspService->getCspHeader();
+        $response->headers->set('Content-Security-Policy', $cspHeader, false);
 
         return $response;
     }
 }
diff --git a/app/Util/CspService.php b/app/Util/CspService.php
index 812e1a4bed..ba927c93b2 100644
--- a/app/Util/CspService.php
+++ b/app/Util/CspService.php
@@ -1,16 +1,14 @@
 <?php
 
 namespace BookStack\Util;
 
 use Illuminate\Support\Str;
-use Symfony\Component\HttpFoundation\Response;
 
 class CspService
 {
-    /** @var string */
-    protected $nonce;
+    protected string $nonce;
 
     public function __construct(string $nonce = '')
     {
         $this->nonce = $nonce ?: Str::random(24);
     }
@@ -22,75 +20,129 @@ class CspService
     {
         return $this->nonce;
     }
 
     /**
-     * Sets CSP 'script-src' headers to restrict the forms of script that can
-     * run on the page.
+     * Get the CSP headers for the application
      */
-    public function setScriptSrc(Response $response)
+    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
     {
         if (config('app.allow_content_scripts')) {
-            return;
+            return '';
         }
 
         $parts = [
             'http:',
             'https:',
             '\'nonce-' . $this->nonce . '\'',
             '\'strict-dynamic\'',
         ];
 
-        $value = 'script-src ' . implode(' ', $parts);
-        $response->headers->set('Content-Security-Policy', $value, false);
+        return 'script-src ' . implode(' ', $parts);
     }
 
     /**
-     * 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.
+     * Create CSP "frame-ancestors" rule to restrict the hosts that BookStack can be iframed within.
      */
-    public function setFrameAncestors(Response $response)
+    protected function getFrameAncestors(): string
     {
         $iframeHosts = $this->getAllowedIframeHosts();
         array_unshift($iframeHosts, "'self'");
-        $cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
-        $response->headers->set('Content-Security-Policy', $cspValue, false);
+        return 'frame-ancestors ' . implode(' ', $iframeHosts);
     }
 
     /**
-     * Check if the user has configured some allowed iframe hosts.
+     * 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.
      */
-    public function allowedIFrameHostsConfigured(): bool
+    protected function getFrameSrc(): string
     {
-        return count($this->getAllowedIframeHosts()) > 0;
+        $iframeHosts = $this->getAllowedIframeSources();
+        array_unshift($iframeHosts, "'self'");
+        return 'frame-src ' . implode(' ', $iframeHosts);
     }
 
     /**
-     * Sets CSP 'object-src' headers to restrict the types of dynamic content
+     * Creates CSP 'object-src' rule to restrict the types of dynamic content
      * that can be embedded on the page.
      */
-    public function setObjectSrc(Response $response)
+    protected function getObjectSrc(): string
     {
         if (config('app.allow_content_scripts')) {
-            return;
+            return '';
         }
 
-        $response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
+        return "object-src 'self'";
     }
 
     /**
-     * Sets CSP 'base-uri' headers to restrict what base tags can be set on
+     * Creates CSP 'base-uri' rule to restrict what base tags can be set on
      * the page to prevent manipulation of relative links.
      */
-    public function setBaseUri(Response $response)
+    protected function getBaseUri(): string
     {
-        $response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
+        return "base-uri 'self'";
     }
 
     protected function getAllowedIframeHosts(): array
     {
         $hosts = config('app.iframe_hosts', '');
 
         return array_filter(explode(' ', $hosts));
     }
+
+    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;
+    }
 }
diff --git a/resources/views/layouts/export.blade.php b/resources/views/layouts/export.blade.php
index a951e262de..36568fef4d 100644
--- a/resources/views/layouts/export.blade.php
+++ b/resources/views/layouts/export.blade.php
@@ -2,10 +2,14 @@
 <html lang="{{ config('app.lang') }}">
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <title>@yield('title')</title>
 
+    @if($cspContent ?? false)
+        <meta http-equiv="Content-Security-Policy" content="{{ $cspContent }}">
+    @endif
+
     @include('common.export-styles', ['format' => $format, 'engine' => $engine ?? ''])
     @include('common.export-custom-head')
 </head>
 <body>
 <div class="page-content">
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php
index 445cd24f38..fc15bb8f3b 100644
--- a/tests/Entity/ExportTest.php
+++ b/tests/Entity/ExportTest.php
@@ -266,11 +266,11 @@ class ExportTest extends TestCase
         setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
 
         foreach ($entities as $entity) {
             $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
             $resp->assertDontSee('window.donkey');
-            $resp->assertDontSee('script');
+            $resp->assertDontSee('<script', false);
             $resp->assertSee('.my-test-class { color: red; }');
         }
     }
 
     public function test_page_export_with_deleted_creator_and_updater()
@@ -446,6 +446,20 @@ class ExportTest extends TestCase
 
         config()->set('app.allow_untrusted_server_fetching', true);
         $resp = $this->get($page->getUrl('/export/pdf'));
         $resp->assertStatus(500); // Bad response indicates wkhtml usage
     }
+
+    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 "]');
+        }
+    }
 }
diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php
index 78691badbb..1a0a6c9b3a 100644
--- a/tests/SecurityHeaderTest.php
+++ b/tests/SecurityHeaderTest.php
@@ -117,10 +117,29 @@ class SecurityHeaderTest extends TestCase
         $resp = $this->get('/');
         $scriptHeader = $this->getCspHeader($resp, 'base-uri');
         $this->assertEquals('base-uri \'self\'', $scriptHeader);
     }
 
+    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);
+    }
+
     public function test_cache_control_headers_are_strict_on_responses_when_logged_in()
     {
         $this->asEditor();
         $resp = $this->get('/');
         $resp->assertHeader('Cache-Control', 'max-age=0, no-store, private');
@@ -131,12 +150,16 @@ class SecurityHeaderTest extends TestCase
     /**
      * Get the value of the first CSP header of the given type.
      */
     protected function getCspHeader(TestResponse $resp, string $type): string
     {
-        $cspHeaders = collect($resp->headers->all('Content-Security-Policy'));
+        $cspHeaders = explode('; ', $resp->headers->get('Content-Security-Policy'));
+
+        foreach ($cspHeaders as $cspHeader) {
+            if (strpos($cspHeader, $type) === 0) {
+                return $cspHeader;
+            }
+        }
 
-        return $cspHeaders->filter(function ($val) use ($type) {
-            return strpos($val, $type) === 0;
-        })->first() ?? '';
+        return '';
     }
 }

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 '';