Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ssddanbrown committed Mar 7, 2022
1 parent 48d0095 commit 856fca8
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 46 deletions.
7 changes: 7 additions & 0 deletions .env.example.complete
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/Config/app.php
Expand Up @@ -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'),

Expand Down
22 changes: 14 additions & 8 deletions app/Entities/Tools/ExportFormatter.php
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -76,6 +81,7 @@ public function bookToContainedHtml(Book $book)
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();

return $this->containHtml($html);
Expand Down
11 changes: 3 additions & 8 deletions app/Http/Middleware/ApplyCspRules.php
Expand Up @@ -8,10 +8,7 @@

class ApplyCspRules
{
/**
* @var CspService
*/
protected $cspService;
protected CspService $cspService;

public function __construct(CspService $cspService)
{
Expand All @@ -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;
}
Expand Down
102 changes: 77 additions & 25 deletions app/Util/CspService.php
Expand Up @@ -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 = '')
{
Expand All @@ -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 = [
Expand All @@ -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
Expand All @@ -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;
}
}
4 changes: 4 additions & 0 deletions resources/views/layouts/export.blade.php
Expand Up @@ -4,6 +4,10 @@
<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>
Expand Down
16 changes: 15 additions & 1 deletion tests/Entity/ExportTest.php
Expand Up @@ -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('<script', false);
$resp->assertSee('.my-test-class { color: red; }');
}
}
Expand Down Expand Up @@ -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 "]');
}
}
}
31 changes: 27 additions & 4 deletions tests/SecurityHeaderTest.php
Expand Up @@ -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();
Expand All @@ -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 '';
}
}

0 comments on commit 856fca8

Please sign in to comment.