*/
protected $middlewareGroups = [
'web' => [
- \BookStack\Http\Middleware\ControlIframeSecurity::class,
+ \BookStack\Http\Middleware\ApplyCspRules::class,
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
--- /dev/null
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Util\CspService;
+use Closure;
+use Illuminate\Http\Request;
+
+class ApplyCspRules
+{
+
+ /**
+ * @var CspService
+ */
+ protected $cspService;
+
+ public function __construct(CspService $cspService)
+ {
+ $this->cspService = $cspService;
+ }
+
+ /**
+ * Handle an incoming request.
+ *
+ * @param Request $request
+ * @param Closure $next
+ *
+ * @return mixed
+ */
+ public function handle($request, Closure $next)
+ {
+ view()->share('cspNonce', $this->cspService->getNonce());
+ if ($this->cspService->allowedIFrameHostsConfigured()) {
+ 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);
+
+ return $response;
+ }
+
+}
+++ /dev/null
-<?php
-
-namespace BookStack\Http\Middleware;
-
-use Closure;
-
-/**
- * Sets CSP 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.
- */
-class ControlIframeSecurity
-{
- /**
- * Handle an incoming request.
- *
- * @param \Illuminate\Http\Request $request
- * @param \Closure $next
- *
- * @return mixed
- */
- public function handle($request, Closure $next)
- {
- $iframeHosts = collect(explode(' ', config('app.iframe_hosts', '')))->filter();
- if ($iframeHosts->count() > 0) {
- config()->set('session.same_site', 'none');
- }
-
- $iframeHosts->prepend("'self'");
-
- $response = $next($request);
- $cspValue = 'frame-ancestors ' . $iframeHosts->join(' ');
- $response->headers->set('Content-Security-Policy', $cspValue);
-
- return $response;
- }
-}
use BookStack\Entities\Models\Page;
use BookStack\Settings\Setting;
use BookStack\Settings\SettingService;
+use BookStack\Util\CspService;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\View;
$this->app->singleton(SocialAuthService::class, function ($app) {
return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
});
+
+ $this->app->singleton(CspService::class, function($app) {
+ return new CspService();
+ });
}
}
--- /dev/null
+<?php
+
+namespace BookStack\Theming;
+
+use BookStack\Util\CspService;
+use BookStack\Util\HtmlContentFilter;
+use BookStack\Util\HtmlNonceApplicator;
+use Illuminate\Contracts\Cache\Repository as Cache;
+
+class CustomHtmlHeadContentProvider
+{
+ /**
+ * @var CspService
+ */
+ protected $cspService;
+
+ /**
+ * @var Cache
+ */
+ protected $cache;
+
+ public function __construct(CspService $cspService, Cache $cache)
+ {
+ $this->cspService = $cspService;
+ $this->cache = $cache;
+ }
+
+ /**
+ * Fetch our custom HTML head content prepared for use on web pages.
+ * Content has a nonce applied for CSP.
+ */
+ public function forWeb(): string
+ {
+ $content = $this->getSourceContent();
+ $hash = md5($content);
+ $html = $this->cache->remember('custom-head-web:' . $hash, 86400, function() use ($content) {
+ return HtmlNonceApplicator::prepare($content);
+ });
+ return HtmlNonceApplicator::apply($html, $this->cspService->getNonce());
+ }
+
+ /**
+ * Fetch our custom HTML head content prepared for use in export formats.
+ * Scripts are stripped to avoid potential issues.
+ */
+ public function forExport(): string
+ {
+ $content = $this->getSourceContent();
+ $hash = md5($content);
+ return $this->cache->remember('custom-head-export:' . $hash, 86400, function() use ($content) {
+ return HtmlContentFilter::removeScripts($content);
+ });
+ }
+
+ /**
+ * Get the original custom head content to use.
+ */
+ protected function getSourceContent(): string
+ {
+ return setting('app-custom-head', '');
+ }
+
+}
\ No newline at end of file
--- /dev/null
+<?php
+
+namespace BookStack\Util;
+
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\Response;
+
+class CspService
+{
+ /** @var string */
+ protected $nonce;
+
+ public function __construct(string $nonce = '')
+ {
+ $this->nonce = $nonce ?: Str::random(16);
+ }
+
+ /**
+ * Get the nonce value for CSP.
+ */
+ public function getNonce(): string
+ {
+ return $this->nonce;
+ }
+
+ /**
+ * Sets CSP 'script-src' headers to restrict the forms of script that can
+ * run on the page.
+ */
+ public function setScriptSrc(Response $response)
+ {
+ if (config('app.allow_content_scripts')) {
+ return;
+ }
+
+ $parts = [
+ 'http:',
+ 'https:',
+ '\'nonce-' . $this->nonce . '\'',
+ '\'strict-dynamic\'',
+ ];
+
+ $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)
+ {
+ $iframeHosts = $this->getAllowedIframeHosts();
+ array_unshift($iframeHosts, "'self'");
+ $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
+ * that can be embedded on the page.
+ */
+ public function setObjectSrc(Response $response)
+ {
+ if (config('app.allow_content_scripts')) {
+ 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
+ * the page to prevent manipulation of relative links.
+ */
+ public function setBaseUri(Response $response)
+ {
+ $response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
+ }
+
+ protected function getAllowedIframeHosts(): array
+ {
+ $hosts = config('app.iframe_hosts', '');
+ return array_filter(explode(' ', $hosts));
+ }
+
+}
\ No newline at end of file
namespace BookStack\Util;
+use DOMAttr;
use DOMDocument;
use DOMNodeList;
use DOMXPath;
class HtmlContentFilter
{
/**
- * Remove all of the script elements from the given HTML.
+ * Remove all the script elements from the given HTML.
*/
public static function removeScripts(string $html): string
{
static::removeNodes($scriptElems);
// Remove clickable links to JavaScript URI
- $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
+ $badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
static::removeNodes($badLinks);
// Remove forms with calls to JavaScript URI
- $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
+ $badForms = $xPath->query('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
static::removeNodes($badForms);
// Remove meta tag to prevent external redirects
- $metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
+ $metaTags = $xPath->query('//meta[' . static::xpathContains('@content', 'url') . ']');
static::removeNodes($metaTags);
// Remove data or JavaScript iFrames
- $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+ $badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
static::removeNodes($badIframes);
+ // Remove elements with a xlink:href attribute
+ // Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
+ $xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');
+ static::removeAttributes($xlinkHrefAttributes);
+
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
- foreach ($onAttributes as $attr) {
- /** @var \DOMAttr $attr */
- $attrName = $attr->nodeName;
- $attr->parentNode->removeAttribute($attrName);
- }
+ static::removeAttributes($onAttributes);
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
}
/**
- * Removed all of the given DOMNodes.
+ * Create a xpath contains statement with a translation automatically built within
+ * to affectively search in a cases-insensitive manner.
+ */
+ protected static function xpathContains(string $property, string $value): string
+ {
+ $value = strtolower($value);
+ $upperVal = strtoupper($value);
+ return 'contains(translate(' . $property . ', \'' . $upperVal . '\', \'' . $value . '\'), \'' . $value . '\')';
+ }
+
+ /**
+ * Remove all the given DOMNodes.
*/
protected static function removeNodes(DOMNodeList $nodes): void
{
$node->parentNode->removeChild($node);
}
}
+
+ /**
+ * Remove all the given attribute nodes.
+ */
+ protected static function removeAttributes(DOMNodeList $attrs): void
+ {
+ /** @var DOMAttr $attr */
+ foreach ($attrs as $attr) {
+ $attrName = $attr->nodeName;
+ $attr->parentNode->removeAttribute($attrName);
+ }
+ }
}
--- /dev/null
+<?php
+
+namespace BookStack\Util;
+
+use DOMDocument;
+use DOMElement;
+use DOMNodeList;
+use DOMXPath;
+
+class HtmlNonceApplicator
+{
+ protected static $placeholder = '[CSP_NONCE_VALUE]';
+
+ /**
+ * Prepare the given HTML content with nonce attributes including a placeholder
+ * value which we can target later.
+ */
+ public static function prepare(string $html): string
+ {
+ if (empty($html)) {
+ return $html;
+ }
+
+ $html = '<body>' . $html . '</body>';
+ libxml_use_internal_errors(true);
+ $doc = new DOMDocument();
+ $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+ $xPath = new DOMXPath($doc);
+
+ // Apply to scripts
+ $scriptElems = $xPath->query('//script');
+ static::addNonceAttributes($scriptElems, static::$placeholder);
+
+ // Apply to styles
+ $styleElems = $xPath->query('//style');
+ static::addNonceAttributes($styleElems, static::$placeholder);
+
+ $returnHtml = '';
+ $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+ foreach ($topElems as $child) {
+ $returnHtml .= $doc->saveHTML($child);
+ }
+
+ return $returnHtml;
+ }
+
+ /**
+ * Apply the give nonce value to the given prepared HTML.
+ */
+ public static function apply(string $html, string $nonce): string
+ {
+ return str_replace(static::$placeholder, $nonce, $html);
+ }
+
+ protected static function addNonceAttributes(DOMNodeList $nodes, string $attrValue): void
+ {
+ /** @var DOMElement $node */
+ foreach ($nodes as $node) {
+ $node->setAttribute('nonce', $attrValue);
+ }
+ }
+
+}
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
@if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
<!-- Custom user content -->
-{!! setting('app-custom-head') !!}
+{!! $headContent->forWeb() !!}
<!-- End custom user content -->
@endif
\ No newline at end of file
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
@if(setting('app-custom-head'))
<!-- Custom user content -->
-{!! \BookStack\Util\HtmlContentFilter::removeScripts(setting('app-custom-head')) !!}
+{!! $headContent->forExport() !!}
<!-- End custom user content -->
@endif
\ No newline at end of file
<meta property="og:title" content="{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}">
<meta property="og:url" content="{{ url()->current() }}">
@stack('social-meta')
-
<!-- Styles and Fonts -->
<link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
</div>
@yield('bottom')
- <script src="{{ versioned_asset('dist/app.js') }}"></script>
+ <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
@yield('scripts')
</body>
@extends('layouts.base')
@section('head')
- <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}"></script>
+ <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}" nonce="{{ $cspNonce }}"></script>
@stop
@section('body-class', 'flexbox')
* Routes have a uri prefix of /api/.
* Controllers are all within app/Http/Controllers/Api.
*/
-Route::get('docs', 'ApiDocsController@display');
Route::get('docs.json', 'ApiDocsController@json');
Route::get('books', 'BookApiController@list');
Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
->where('path', '.*$');
+ // API docs routes
+ Route::get('/api/docs', 'Api\ApiDocsController@display');
+
Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
// Shelves
namespace Tests\Api;
-use BookStack\Auth\User;
use Tests\TestCase;
class ApiDocsTest extends TestCase
protected $endpoint = '/api/docs';
- public function test_docs_page_not_visible_to_normal_viewers()
- {
- $viewer = $this->getViewer();
- $resp = $this->actingAs($viewer)->get($this->endpoint);
- $resp->assertStatus(403);
-
- $resp = $this->actingAsApiEditor()->get($this->endpoint);
- $resp->assertStatus(200);
- }
-
public function test_docs_page_returns_view_with_docs_content()
{
$resp = $this->actingAsApiEditor()->get($this->endpoint);
]],
]);
}
-
- public function test_docs_page_visible_by_public_user_if_given_permission()
- {
- $this->setSettings(['app-public' => true]);
- $guest = User::getDefault();
-
- $this->startSession();
- $resp = $this->get('/api/docs');
- $resp->assertStatus(403);
-
- $this->giveUserPermissions($guest, ['access-api']);
-
- $resp = $this->get('/api/docs');
- $resp->assertStatus(200);
- }
}
}
}
- public function test_iframe_js_and_base64_urls_are_removed()
+ public function test_js_and_base64_src_urls_are_removed()
{
$checks = [
'<iframe src="javascript:alert(document.cookie)"></iframe>',
+ '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
+ '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
'<iframe SRC=" javascript: alert(document.cookie)"></iframe>',
'<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+ '<iframe src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
'<iframe src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+ '<img src="javascript:alert(document.cookie)"/>',
+ '<img src="JavAScRipT:alert(document.cookie)"/>',
+ '<img src="JavAScRipT:alert(document.cookie)"/>',
+ '<img SRC=" javascript: alert(document.cookie)"/>',
+ '<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+ '<img src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+ '<img src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
'<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+ '<iframe SRCdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+ '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
];
$this->asEditor();
$pageView = $this->get($page->getUrl());
$pageView->assertStatus(200);
$pageView->assertElementNotContains('.page-content', '<iframe>');
+ $pageView->assertElementNotContains('.page-content', '<img');
$pageView->assertElementNotContains('.page-content', '</iframe>');
$pageView->assertElementNotContains('.page-content', 'src=');
$pageView->assertElementNotContains('.page-content', 'javascript:');
$checks = [
'<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
'<a id="xss" href="javascript: alert(document.cookie)>Click me</a>',
+ '<a id="xss" href="JaVaScRiPt: alert(document.cookie)>Click me</a>',
+ '<a id="xss" href=" JaVaScRiPt: alert(document.cookie)>Click me</a>',
];
$this->asEditor();
$pageView = $this->get($page->getUrl());
$pageView->assertStatus(200);
- $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+ $pageView->assertElementNotContains('.page-content', '<a id="xss"');
$pageView->assertElementNotContains('.page-content', 'href=javascript:');
}
}
{
$checks = [
'<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
+ '<form ><button id="xss" formaction="JaVaScRiPt:alert(document.domain)">Click me</button></form>',
'<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
'<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>',
+ '<form id="xss" action="JaVaScRiPt:alert(document.domain)"><input type=submit value=Submit></form>',
];
$this->asEditor();
{
$checks = [
'<meta http-equiv="refresh" content="0; url=//external_url">',
+ '<meta http-equiv="refresh" ConTeNt="0; url=//external_url">',
+ '<meta http-equiv="refresh" content="0; UrL=//external_url">',
];
$this->asEditor();
{
$checks = [
'<p onclick="console.log(\'test\')">Hello</p>',
+ '<p OnCliCk="console.log(\'test\')">Hello</p>',
'<div>Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p>',
'<div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div>',
'<div><div><div><div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div></div></div></div>',
'<div onclick="console.log(\'test\')">Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p><div></div>',
'<a a="<img src=1 onerror=\'alert(1)\'> ',
+ '\<a onclick="alert(document.cookie)"\>xss link\</a\>',
];
$this->asEditor();
$pageView->assertDontSee('abc123abc123');
}
+ public function test_svg_xlink_hrefs_are_removed()
+ {
+ $checks = [
+ '<svg id="test" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><a xlink:href="javascript:alert(document.domain)"><rect x="0" y="0" width="100" height="100" /></a></svg>',
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64 ,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjAiIGN4PSIwIiBjeT0iMCIgc3R5bGU9ImZpbGw6ICNGMDAiPgo8c2V0IGF0dHJpYnV0ZU5hbWU9ImZpbGwiIGF0dHJpYnV0ZVR5cGU9IkNTUyIgb25iZWdpbj0nYWxlcnQoZG9jdW1lbnQuZG9tYWluKScKb25lbmQ9J2FsZXJ0KCJvbmVuZCIpJyB0bz0iIzAwRiIgYmVnaW49IjBzIiBkdXI9Ijk5OXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/></svg>'
+ ];
+
+ $this->asEditor();
+ $page = Page::query()->first();
+
+ foreach ($checks as $check) {
+ $page->html = $check;
+ $page->save();
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
+ $pageView->assertElementNotContains('.page-content', 'alert');
+ $pageView->assertElementNotContains('.page-content', 'xlink:href');
+ $pageView->assertElementNotContains('.page-content', 'application/xml');
+ }
+ }
+
public function test_page_inline_on_attributes_show_if_configured()
{
$this->asEditor();
namespace Tests;
-use Illuminate\Support\Str;
+use BookStack\Util\CspService;
class SecurityHeaderTest extends TestCase
{
public function test_iframe_csp_self_only_by_default()
{
$resp = $this->get('/');
- $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
- $frameHeaders = $cspHeaders->filter(function ($val) {
- return Str::startsWith($val, 'frame-ancestors');
- });
+ $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
- $this->assertTrue($frameHeaders->count() === 1);
- $this->assertEquals('frame-ancestors \'self\'', $frameHeaders->first());
+ $this->assertEquals('frame-ancestors \'self\'', $frameHeader);
}
public function test_iframe_csp_includes_extra_hosts_if_configured()
{
$this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'https://a.example.com https://b.example.com', function () {
$resp = $this->get('/');
- $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
- $frameHeaders = $cspHeaders->filter(function ($val) {
- return Str::startsWith($val, 'frame-ancestors');
- });
+ $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
- $this->assertTrue($frameHeaders->count() === 1);
- $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeaders->first());
+ $this->assertNotEmpty($frameHeader);
+ $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeader);
});
}
+
+ public function test_script_csp_set_on_responses()
+ {
+ $resp = $this->get('/');
+ $scriptHeader = $this->getCspHeader($resp, 'script-src');
+ $this->assertStringContainsString('\'strict-dynamic\'', $scriptHeader);
+ $this->assertStringContainsString('\'nonce-', $scriptHeader);
+ }
+
+ public function test_script_csp_nonce_matches_nonce_used_in_custom_head()
+ {
+ $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+ $resp = $this->get('/login');
+ $scriptHeader = $this->getCspHeader($resp, 'script-src');
+
+ $nonce = app()->make(CspService::class)->getNonce();
+ $this->assertStringContainsString('nonce-' . $nonce, $scriptHeader);
+ $resp->assertSee('<script nonce="' . $nonce . '">console.log("cat");</script>');
+ }
+
+ public function test_script_csp_nonce_changes_per_request()
+ {
+ $resp = $this->get('/');
+ $firstHeader = $this->getCspHeader($resp, 'script-src');
+
+ $this->refreshApplication();
+
+ $resp = $this->get('/');
+ $secondHeader = $this->getCspHeader($resp, 'script-src');
+
+ $this->assertNotEquals($firstHeader, $secondHeader);
+ }
+
+ public function test_allow_content_scripts_settings_controls_csp_script_headers()
+ {
+ config()->set('app.allow_content_scripts', true);
+ $resp = $this->get('/');
+ $scriptHeader = $this->getCspHeader($resp, 'script-src');
+ $this->assertEmpty($scriptHeader);
+
+ config()->set('app.allow_content_scripts', false);
+ $resp = $this->get('/');
+ $scriptHeader = $this->getCspHeader($resp, 'script-src');
+ $this->assertNotEmpty($scriptHeader);
+ }
+
+ public function test_object_src_csp_header_set()
+ {
+ $resp = $this->get('/');
+ $scriptHeader = $this->getCspHeader($resp, 'object-src');
+ $this->assertEquals('object-src \'self\'', $scriptHeader);
+ }
+
+ public function test_base_uri_csp_header_set()
+ {
+ $resp = $this->get('/');
+ $scriptHeader = $this->getCspHeader($resp, 'base-uri');
+ $this->assertEquals('base-uri \'self\'', $scriptHeader);
+ }
+
+ /**
+ * 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'));
+ return $cspHeaders->filter(function ($val) use ($type) {
+ return strpos($val, $type) === 0;
+ })->first() ?? '';
+ }
}