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
This commit is contained in:
parent
48d0095aa2
commit
856fca8289
8 changed files with 162 additions and 54 deletions
|
@ -331,6 +331,13 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
|
||||||
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
||||||
ALLOWED_IFRAME_HOSTS=null
|
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.
|
# The default and maximum item-counts for listing API requests.
|
||||||
API_DEFAULT_ITEM_COUNT=100
|
API_DEFAULT_ITEM_COUNT=100
|
||||||
API_MAX_ITEM_COUNT=500
|
API_MAX_ITEM_COUNT=500
|
||||||
|
|
|
@ -57,6 +57,13 @@ return [
|
||||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||||
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
|
'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.
|
// Application timezone for back-end date functions.
|
||||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||||
use BookStack\Uploads\ImageService;
|
use BookStack\Uploads\ImageService;
|
||||||
|
use BookStack\Util\CspService;
|
||||||
use DOMDocument;
|
use DOMDocument;
|
||||||
use DOMElement;
|
use DOMElement;
|
||||||
use DOMXPath;
|
use DOMXPath;
|
||||||
|
@ -15,16 +16,18 @@ use Throwable;
|
||||||
|
|
||||||
class ExportFormatter
|
class ExportFormatter
|
||||||
{
|
{
|
||||||
protected $imageService;
|
protected ImageService $imageService;
|
||||||
protected $pdfGenerator;
|
protected PdfGenerator $pdfGenerator;
|
||||||
|
protected CspService $cspService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExportService constructor.
|
* ExportService constructor.
|
||||||
*/
|
*/
|
||||||
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
|
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
|
||||||
{
|
{
|
||||||
$this->imageService = $imageService;
|
$this->imageService = $imageService;
|
||||||
$this->pdfGenerator = $pdfGenerator;
|
$this->pdfGenerator = $pdfGenerator;
|
||||||
|
$this->cspService = $cspService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,8 +40,9 @@ class ExportFormatter
|
||||||
{
|
{
|
||||||
$page->html = (new PageContent($page))->render();
|
$page->html = (new PageContent($page))->render();
|
||||||
$pageHtml = view('pages.export', [
|
$pageHtml = view('pages.export', [
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'format' => 'html',
|
'format' => 'html',
|
||||||
|
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
return $this->containHtml($pageHtml);
|
return $this->containHtml($pageHtml);
|
||||||
|
@ -56,9 +60,10 @@ class ExportFormatter
|
||||||
$page->html = (new PageContent($page))->render();
|
$page->html = (new PageContent($page))->render();
|
||||||
});
|
});
|
||||||
$html = view('chapters.export', [
|
$html = view('chapters.export', [
|
||||||
'chapter' => $chapter,
|
'chapter' => $chapter,
|
||||||
'pages' => $pages,
|
'pages' => $pages,
|
||||||
'format' => 'html',
|
'format' => 'html',
|
||||||
|
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
return $this->containHtml($html);
|
return $this->containHtml($html);
|
||||||
|
@ -76,6 +81,7 @@ class ExportFormatter
|
||||||
'book' => $book,
|
'book' => $book,
|
||||||
'bookChildren' => $bookTree,
|
'bookChildren' => $bookTree,
|
||||||
'format' => 'html',
|
'format' => 'html',
|
||||||
|
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
return $this->containHtml($html);
|
return $this->containHtml($html);
|
||||||
|
|
|
@ -8,10 +8,7 @@ use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ApplyCspRules
|
class ApplyCspRules
|
||||||
{
|
{
|
||||||
/**
|
protected CspService $cspService;
|
||||||
* @var CspService
|
|
||||||
*/
|
|
||||||
protected $cspService;
|
|
||||||
|
|
||||||
public function __construct(CspService $cspService)
|
public function __construct(CspService $cspService)
|
||||||
{
|
{
|
||||||
|
@ -35,10 +32,8 @@ class ApplyCspRules
|
||||||
|
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
|
|
||||||
$this->cspService->setFrameAncestors($response);
|
$cspHeader = $this->cspService->getCspHeader();
|
||||||
$this->cspService->setScriptSrc($response);
|
$response->headers->set('Content-Security-Policy', $cspHeader, false);
|
||||||
$this->cspService->setObjectSrc($response);
|
|
||||||
$this->cspService->setBaseUri($response);
|
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,10 @@
|
||||||
namespace BookStack\Util;
|
namespace BookStack\Util;
|
||||||
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class CspService
|
class CspService
|
||||||
{
|
{
|
||||||
/** @var string */
|
protected string $nonce;
|
||||||
protected $nonce;
|
|
||||||
|
|
||||||
public function __construct(string $nonce = '')
|
public function __construct(string $nonce = '')
|
||||||
{
|
{
|
||||||
|
@ -24,37 +22,34 @@ class CspService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets CSP 'script-src' headers to restrict the forms of script that can
|
* Get the CSP headers for the application
|
||||||
* run on the page.
|
|
||||||
*/
|
*/
|
||||||
public function setScriptSrc(Response $response)
|
public function getCspHeader(): string
|
||||||
{
|
{
|
||||||
if (config('app.allow_content_scripts')) {
|
$headers = [
|
||||||
return;
|
$this->getFrameAncestors(),
|
||||||
}
|
$this->getFrameSrc(),
|
||||||
|
$this->getScriptSrc(),
|
||||||
$parts = [
|
$this->getObjectSrc(),
|
||||||
'http:',
|
$this->getBaseUri(),
|
||||||
'https:',
|
|
||||||
'\'nonce-' . $this->nonce . '\'',
|
|
||||||
'\'strict-dynamic\'',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$value = 'script-src ' . implode(' ', $parts);
|
return implode('; ', array_filter($headers));
|
||||||
$response->headers->set('Content-Security-Policy', $value, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
|
* Get the CSP rules for the application for a HTML meta tag.
|
||||||
* iframed within. Also adjusts the cookie samesite options so that cookies will
|
|
||||||
* operate in the third-party context.
|
|
||||||
*/
|
*/
|
||||||
public function setFrameAncestors(Response $response)
|
public function getCspMetaTagValue(): string
|
||||||
{
|
{
|
||||||
$iframeHosts = $this->getAllowedIframeHosts();
|
$headers = [
|
||||||
array_unshift($iframeHosts, "'self'");
|
$this->getFrameSrc(),
|
||||||
$cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
|
$this->getScriptSrc(),
|
||||||
$response->headers->set('Content-Security-Policy', $cspValue, false);
|
$this->getObjectSrc(),
|
||||||
|
$this->getBaseUri(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return implode('; ', array_filter($headers));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -66,25 +61,65 @@ class CspService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets CSP 'object-src' headers to restrict the types of dynamic content
|
* Create CSP 'script-src' rule to restrict the forms of script that can run on the page.
|
||||||
* that can be embedded on the page.
|
|
||||||
*/
|
*/
|
||||||
public function setObjectSrc(Response $response)
|
protected function getScriptSrc(): string
|
||||||
{
|
{
|
||||||
if (config('app.allow_content_scripts')) {
|
if (config('app.allow_content_scripts')) {
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
|
$parts = [
|
||||||
|
'http:',
|
||||||
|
'https:',
|
||||||
|
'\'nonce-' . $this->nonce . '\'',
|
||||||
|
'\'strict-dynamic\'',
|
||||||
|
];
|
||||||
|
|
||||||
|
return 'script-src ' . implode(' ', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets CSP 'base-uri' headers to restrict what base tags can be set on
|
* Create CSP "frame-ancestors" rule to restrict the hosts that BookStack can be iframed within.
|
||||||
|
*/
|
||||||
|
protected function getFrameAncestors(): string
|
||||||
|
{
|
||||||
|
$iframeHosts = $this->getAllowedIframeHosts();
|
||||||
|
array_unshift($iframeHosts, "'self'");
|
||||||
|
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
|
||||||
|
* that can be embedded on the page.
|
||||||
|
*/
|
||||||
|
protected function getObjectSrc(): string
|
||||||
|
{
|
||||||
|
if (config('app.allow_content_scripts')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return "object-src 'self'";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates CSP 'base-uri' rule to restrict what base tags can be set on
|
||||||
* the page to prevent manipulation of relative links.
|
* 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
|
protected function getAllowedIframeHosts(): array
|
||||||
|
@ -93,4 +128,21 @@ class CspService
|
||||||
|
|
||||||
return array_filter(explode(' ', $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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,10 @@
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||||
<title>@yield('title')</title>
|
<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-styles', ['format' => $format, 'engine' => $engine ?? ''])
|
||||||
@include('common.export-custom-head')
|
@include('common.export-custom-head')
|
||||||
</head>
|
</head>
|
||||||
|
|
|
@ -268,7 +268,7 @@ class ExportTest extends TestCase
|
||||||
foreach ($entities as $entity) {
|
foreach ($entities as $entity) {
|
||||||
$resp = $this->asEditor()->get($entity->getUrl('/export/html'));
|
$resp = $this->asEditor()->get($entity->getUrl('/export/html'));
|
||||||
$resp->assertDontSee('window.donkey');
|
$resp->assertDontSee('window.donkey');
|
||||||
$resp->assertDontSee('script');
|
$resp->assertDontSee('<script', false);
|
||||||
$resp->assertSee('.my-test-class { color: red; }');
|
$resp->assertSee('.my-test-class { color: red; }');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -448,4 +448,18 @@ class ExportTest extends TestCase
|
||||||
$resp = $this->get($page->getUrl('/export/pdf'));
|
$resp = $this->get($page->getUrl('/export/pdf'));
|
||||||
$resp->assertStatus(500); // Bad response indicates wkhtml usage
|
$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 "]');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -119,6 +119,25 @@ class SecurityHeaderTest extends TestCase
|
||||||
$this->assertEquals('base-uri \'self\'', $scriptHeader);
|
$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()
|
public function test_cache_control_headers_are_strict_on_responses_when_logged_in()
|
||||||
{
|
{
|
||||||
$this->asEditor();
|
$this->asEditor();
|
||||||
|
@ -133,10 +152,14 @@ class SecurityHeaderTest extends TestCase
|
||||||
*/
|
*/
|
||||||
protected function getCspHeader(TestResponse $resp, string $type): string
|
protected function getCspHeader(TestResponse $resp, string $type): string
|
||||||
{
|
{
|
||||||
$cspHeaders = collect($resp->headers->all('Content-Security-Policy'));
|
$cspHeaders = explode('; ', $resp->headers->get('Content-Security-Policy'));
|
||||||
|
|
||||||
return $cspHeaders->filter(function ($val) use ($type) {
|
foreach ($cspHeaders as $cspHeader) {
|
||||||
return strpos($val, $type) === 0;
|
if (strpos($cspHeader, $type) === 0) {
|
||||||
})->first() ?? '';
|
return $cspHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue