- 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
# 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
// 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'),
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;
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;
}
/**
{
$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);
$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);
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
+ 'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
class ApplyCspRules
{
- /**
- * @var CspService
- */
- protected $cspService;
+ protected CspService $cspService;
public function __construct(CspService $cspService)
{
$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;
}
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 = '')
{
}
/**
- * 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 = [
'\'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
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;
+ }
}
<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>
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; }');
}
}
$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 "]');
+ }
+ }
}
$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();
*/
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 '';
}
}