diff --git a/.env.example.complete b/.env.example.complete index 9b3ae7c57b4..fb947408dff 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -331,6 +331,13 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false # 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 diff --git a/app/Config/app.php b/app/Config/app.php index 39bfa7134f9..2329043b642 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -57,6 +57,13 @@ // 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'), diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index 7edd1b50f99..9029d7270dc 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -7,6 +7,7 @@ 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; @@ -15,16 +16,18 @@ 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; } /** @@ -37,8 +40,9 @@ 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); @@ -56,9 +60,10 @@ public function chapterToContainedHtml(Chapter $chapter) $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); @@ -76,6 +81,7 @@ public function bookToContainedHtml(Book $book) '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 6c9d14e7b65..9f3a8d1d84f 100644 --- a/app/Http/Middleware/ApplyCspRules.php +++ b/app/Http/Middleware/ApplyCspRules.php @@ -8,10 +8,7 @@ class ApplyCspRules { - /** - * @var CspService - */ - protected $cspService; + protected CspService $cspService; public function __construct(CspService $cspService) { @@ -35,10 +32,8 @@ public function handle($request, Closure $next) $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 812e1a4bed1..ba927c93b29 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -3,12 +3,10 @@ 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 = '') { @@ -24,13 +22,51 @@ public function getNonce(): string } /** - * 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 = [ @@ -40,51 +76,50 @@ public function setScriptSrc(Response $response) '\'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 @@ -93,4 +128,21 @@ protected function getAllowedIframeHosts(): array 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 a951e262de7..36568fef4da 100644 --- a/resources/views/layouts/export.blade.php +++ b/resources/views/layouts/export.blade.php @@ -4,6 +4,10 @@ @yield('title') + @if($cspContent ?? false) + + @endif + @include('common.export-styles', ['format' => $format, 'engine' => $engine ?? '']) @include('common.export-custom-head') diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 445cd24f384..fc15bb8f3b3 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -268,7 +268,7 @@ public function test_exports_removes_scripts_from_custom_head() foreach ($entities as $entity) { $resp = $this->asEditor()->get($entity->getUrl('/export/html')); $resp->assertDontSee('window.donkey'); - $resp->assertDontSee('script'); + $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); } } @@ -448,4 +448,18 @@ public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_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 78691badbbc..1a0a6c9b3a1 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -119,6 +119,25 @@ public function test_base_uri_csp_header_set() $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(); @@ -133,10 +152,14 @@ public function test_cache_control_headers_are_strict_on_responses_when_logged_i */ 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 ''; } }