mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-18 11:19:37 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88bcb68fcb | ||
|
|
7c000553ae | ||
|
|
d815e1b9f2 | ||
|
|
492af79c27 | ||
|
|
253f386f00 | ||
|
|
fd44e4ba74 | ||
|
|
040997fdc4 | ||
|
|
5e6092aaf8 | ||
|
|
391fa35c80 | ||
|
|
c6773a8c9f | ||
|
|
a579b7da21 | ||
|
|
bc34914ac1 | ||
|
|
7028025380 | ||
|
|
ff494be952 |
1
.github/translators.txt
vendored
1
.github/translators.txt
vendored
@@ -183,3 +183,4 @@ A Ibnu Hibban (abd.ibnuhibban) :: Indonesian
|
||||
Frost-ZX :: Chinese Simplified
|
||||
Kuzma Simonov (ovmach) :: Russian
|
||||
Vojtěch Krystek (acantophis) :: Czech
|
||||
Michał Lipok (mLipok) :: Polish
|
||||
|
||||
@@ -25,8 +25,8 @@ use Permissions;
|
||||
*/
|
||||
class Page extends BookChild
|
||||
{
|
||||
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at'];
|
||||
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at'];
|
||||
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
|
||||
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
|
||||
|
||||
protected $fillable = ['name', 'priority', 'markdown'];
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ class MfaTotpController extends Controller
|
||||
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
|
||||
|
||||
return view('mfa.totp-generate', [
|
||||
'secret' => $totpSecret,
|
||||
'svg' => $svg,
|
||||
'url' => $qrCodeUrl,
|
||||
'svg' => $svg,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class Kernel extends HttpKernel
|
||||
*/
|
||||
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,
|
||||
|
||||
47
app/Http/Middleware/ApplyCspRules.php
Normal file
47
app/Http/Middleware/ApplyCspRules.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use BookStack\Entities\Models\Chapter;
|
||||
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;
|
||||
@@ -71,5 +72,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
$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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
63
app/Theming/CustomHtmlHeadContentProvider.php
Normal file
63
app/Theming/CustomHtmlHeadContentProvider.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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', '');
|
||||
}
|
||||
|
||||
}
|
||||
96
app/Util/CspService.php
Normal file
96
app/Util/CspService.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use DOMAttr;
|
||||
use DOMDocument;
|
||||
use DOMNodeList;
|
||||
use DOMXPath;
|
||||
@@ -9,7 +10,7 @@ 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
|
||||
{
|
||||
@@ -28,28 +29,29 @@ class HtmlContentFilter
|
||||
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;
|
||||
@@ -61,7 +63,18 @@ class HtmlContentFilter
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
@@ -69,4 +82,16 @@ class HtmlContentFilter
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
app/Util/HtmlNonceApplicator.php
Normal file
63
app/Util/HtmlNonceApplicator.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
2
public/dist/export-styles.css
vendored
2
public/dist/export-styles.css
vendored
File diff suppressed because one or more lines are too long
2
public/dist/styles.css
vendored
2
public/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
@@ -48,8 +48,8 @@ return [
|
||||
'favourite_remove_notification' => '":name" ir izņemts no jūsu favorītiem',
|
||||
|
||||
// MFA
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
'mfa_setup_method_notification' => '2FA funkcija aktivizēta',
|
||||
'mfa_remove_method_notification' => '2FA funkcija noņemta',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'komentēts',
|
||||
|
||||
@@ -76,11 +76,11 @@ return [
|
||||
'user_invite_success' => 'Parole iestatīta, tagad varat piekļūt :appName!',
|
||||
|
||||
// Multi-factor Authentication
|
||||
'mfa_setup' => 'Setup Multi-Factor Authentication',
|
||||
'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
|
||||
'mfa_setup_configured' => 'Already configured',
|
||||
'mfa_setup_reconfigure' => 'Reconfigure',
|
||||
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
|
||||
'mfa_setup' => 'Iestati divfaktoru autentifikāciju (2FA)',
|
||||
'mfa_setup_desc' => 'Iestati divfaktoru autentifikāciju kā papildus drošību tavam lietotāja kontam.',
|
||||
'mfa_setup_configured' => 'Divfaktoru autentifikācija jau ir nokonfigurēta',
|
||||
'mfa_setup_reconfigure' => 'Mainīt 2FA konfigurāciju',
|
||||
'mfa_setup_remove_confirmation' => 'Vai esi drošs, ka vēlies noņemt divfaktoru autentifikāciju?',
|
||||
'mfa_setup_action' => 'Setup',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
|
||||
'mfa_option_totp_title' => 'Mobile App',
|
||||
|
||||
@@ -39,7 +39,7 @@ return [
|
||||
'reset' => 'Atiestatīt',
|
||||
'remove' => 'Noņemt',
|
||||
'add' => 'Pievienot',
|
||||
'configure' => 'Configure',
|
||||
'configure' => 'Mainīt konfigurāciju',
|
||||
'fullscreen' => 'Pilnekrāns',
|
||||
'favourite' => 'Pievienot favorītiem',
|
||||
'unfavourite' => 'Noņemt no favorītiem',
|
||||
|
||||
@@ -99,7 +99,7 @@ return [
|
||||
'shelves_permissions' => 'Grāmatplaukta atļaujas',
|
||||
'shelves_permissions_updated' => 'Grāmatplaukta atļaujas atjauninātas',
|
||||
'shelves_permissions_active' => 'Grāmatplaukta atļaujas ir aktīvas',
|
||||
'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
|
||||
'shelves_permissions_cascade_warning' => 'Grāmatu plauktu atļaujas netiek automātiski pārvietotas uz grāmatām. Tas ir tāpēc, ka grāmata var atrasties vairākos plauktos. Tomēr atļaujas var nokopēt uz plauktam pievienotajām grāmatām, izmantojot zemāk norādīto opciju.',
|
||||
'shelves_copy_permissions_to_books' => 'Kopēt grāmatplaukta atļaujas uz grāmatām',
|
||||
'shelves_copy_permissions' => 'Kopēt atļaujas',
|
||||
'shelves_copy_permissions_explain' => 'Šis piemēros pašreizējās grāmatplaukta piekļuves tiesības visām tajā esošajām grāmatām. Pirms ieslēgšanas pārliecinieties, ka ir saglabātas izmaiņas grāmatplaukta piekļuves tiesībām.',
|
||||
|
||||
@@ -15,7 +15,7 @@ return [
|
||||
'alpha_dash' => ':attribute var saturēt tikai burtus, ciparus, domuzīmes un apakš svītras.',
|
||||
'alpha_num' => ':attribute var saturēt tikai burtus un ciparus.',
|
||||
'array' => ':attribute ir jābūt masīvam.',
|
||||
'backup_codes' => 'The provided code is not valid or has already been used.',
|
||||
'backup_codes' => 'Ievadītais kods nav derīgs vai arī jau ir izmantots.',
|
||||
'before' => ':attribute jābūt datumam pirms :date.',
|
||||
'between' => [
|
||||
'numeric' => ':attribute jābūt starp :min un :max.',
|
||||
@@ -99,7 +99,7 @@ return [
|
||||
],
|
||||
'string' => ':attribute jābūt teksta virknei.',
|
||||
'timezone' => ':attribute jābūt derīgai zonai.',
|
||||
'totp' => 'The provided code is not valid or has expired.',
|
||||
'totp' => 'Ievadītais kods nav derīgs.',
|
||||
'unique' => ':attribute jau ir aizņemts.',
|
||||
'url' => ':attribute formāts nav derīgs.',
|
||||
'uploaded' => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.',
|
||||
|
||||
@@ -48,8 +48,8 @@ return [
|
||||
'favourite_remove_notification' => '":name" został usunięty z ulubionych',
|
||||
|
||||
// MFA
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
'mfa_setup_method_notification' => 'Metoda wieloskładnikowa została pomyślnie skonfigurowana',
|
||||
'mfa_remove_method_notification' => 'Metoda wieloskładnikowa pomyślnie usunięta',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'skomentował',
|
||||
|
||||
@@ -26,7 +26,7 @@ return [
|
||||
'remember_me' => '记住我',
|
||||
'ldap_email_hint' => '请输入用于此帐户的电子邮件。',
|
||||
'create_account' => '创建账户',
|
||||
'already_have_account' => '您已经有账号?',
|
||||
'already_have_account' => '已经有账号了?',
|
||||
'dont_have_account' => '您还没有账号吗?',
|
||||
'social_login' => 'SNS登录',
|
||||
'social_registration' => '使用社交网站账号注册',
|
||||
@@ -82,31 +82,31 @@ return [
|
||||
'mfa_setup_reconfigure' => '重新配置',
|
||||
'mfa_setup_remove_confirmation' => '您确定想要移除多重身份认证吗?',
|
||||
'mfa_setup_action' => '设置',
|
||||
'mfa_backup_codes_usage_limit_warning' => '您剩余的备用验证码少于 5 个,请在用完验证码之前生成并保存新的验证码,以防止您的帐户被锁定。',
|
||||
'mfa_backup_codes_usage_limit_warning' => '您剩余的备用认证码少于 5 个,请在用完认证码之前生成并保存新的认证码,以防止您的帐户被锁定。',
|
||||
'mfa_option_totp_title' => '移动设备 App',
|
||||
'mfa_option_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
|
||||
'mfa_option_backup_codes_title' => '备用验证码',
|
||||
'mfa_option_backup_codes_desc' => '安全地保存一组一次性使用的备用验证码,您可以输入这些验证码来验证您的身份。',
|
||||
'mfa_option_backup_codes_title' => '备用认证码',
|
||||
'mfa_option_backup_codes_desc' => '请安全地保存这些一次性使用的备用认证码,您可以输入这些认证码来验证您的身份。',
|
||||
'mfa_gen_confirm_and_enable' => '确认并启用',
|
||||
'mfa_gen_backup_codes_title' => '备用验证码设置',
|
||||
'mfa_gen_backup_codes_desc' => '将下面的验证码存放在一个安全的地方。访问系统时,您可以使用其中的一个验证码进行二次认证。',
|
||||
'mfa_gen_backup_codes_download' => '下载验证码',
|
||||
'mfa_gen_backup_codes_usage_warning' => '每个验证码只能使用一次',
|
||||
'mfa_gen_backup_codes_title' => '备用认证码设置',
|
||||
'mfa_gen_backup_codes_desc' => '将下面的认证码存放在一个安全的地方。访问系统时,您可以使用其中的一个验证码进行二次认证。',
|
||||
'mfa_gen_backup_codes_download' => '下载认证码',
|
||||
'mfa_gen_backup_codes_usage_warning' => '每个认证码只能使用一次',
|
||||
'mfa_gen_totp_title' => '移动设备 App',
|
||||
'mfa_gen_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
|
||||
'mfa_gen_totp_scan' => '要开始操作,请使用你的身份验证 App 扫描下面的二维码。',
|
||||
'mfa_gen_totp_verify_setup' => '验证设置',
|
||||
'mfa_gen_totp_verify_setup_desc' => '请在下面的框中输入您在身份验证 App 中生成的验证码来验证一切是否正常:',
|
||||
'mfa_gen_totp_provide_code_here' => '再此输入您的 App 生成的验证码',
|
||||
'mfa_gen_totp_verify_setup_desc' => '请在下面的框中输入您在身份验证 App 中生成的认证码来验证一切是否正常:',
|
||||
'mfa_gen_totp_provide_code_here' => '在此输入您的 App 生成的认证码',
|
||||
'mfa_verify_access' => '认证访问',
|
||||
'mfa_verify_access_desc' => '您的账户要求您在访问前通过额外的验证确认您的身份。使用您设置的认证方法认证以继续。',
|
||||
'mfa_verify_no_methods' => '没有设置认证方法',
|
||||
'mfa_verify_no_methods_desc' => '您的账户没有设置多重身份认证。您需要至少设置一种才能访问。',
|
||||
'mfa_verify_use_totp' => '使用移动设备 App 进行认证',
|
||||
'mfa_verify_use_backup_codes' => '使用备用验证码进行认证',
|
||||
'mfa_verify_backup_code' => '备用验证码',
|
||||
'mfa_verify_backup_code_desc' => '在下面输入你的其中一个备用验证码:',
|
||||
'mfa_verify_backup_code_enter_here' => '在这里输入备用验证码',
|
||||
'mfa_verify_totp_desc' => '在下面输入您的移动 App 生成的验证码:',
|
||||
'mfa_verify_use_backup_codes' => '使用备用认证码进行认证',
|
||||
'mfa_verify_backup_code' => '备用认证码',
|
||||
'mfa_verify_backup_code_desc' => '在下面输入你的其中一个备用认证码:',
|
||||
'mfa_verify_backup_code_enter_here' => '在这里输入备用认证码',
|
||||
'mfa_verify_totp_desc' => '在下面输入您的移动 App 生成的认证码:',
|
||||
'mfa_setup_login_notification' => '多重身份认证已设置,请使用新配置的方法重新登录。',
|
||||
];
|
||||
@@ -15,7 +15,7 @@ return [
|
||||
'alpha_dash' => ':attribute 只能包含字母、数字和短横线。',
|
||||
'alpha_num' => ':attribute 只能包含字母和数字。',
|
||||
'array' => ':attribute 必须是一个数组。',
|
||||
'backup_codes' => '您输入的验证码无效或已被使用。',
|
||||
'backup_codes' => '您输入的认证码无效或已被使用。',
|
||||
'before' => ':attribute 必须是在 :date 前的日期。',
|
||||
'between' => [
|
||||
'numeric' => ':attribute 必须在:min到:max之间。',
|
||||
@@ -99,7 +99,7 @@ return [
|
||||
],
|
||||
'string' => ':attribute 必须是字符串。',
|
||||
'timezone' => ':attribute 必须是有效的区域。',
|
||||
'totp' => '您输入的验证码无效或已过期。',
|
||||
'totp' => '您输入的认证码无效或已过期。',
|
||||
'unique' => ':attribute 已经被使用。',
|
||||
'url' => ':attribute 格式无效。',
|
||||
'uploaded' => '无法上传文件。 服务器可能不接受此大小的文件。',
|
||||
|
||||
@@ -145,6 +145,7 @@ body.flexbox {
|
||||
.flex {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
&.fit-content {
|
||||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@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
|
||||
@@ -1,5 +1,7 @@
|
||||
@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
|
||||
@@ -15,7 +15,6 @@
|
||||
<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') }}">
|
||||
@@ -51,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
@yield('bottom')
|
||||
<script src="{{ versioned_asset('dist/app.js') }}"></script>
|
||||
<script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
|
||||
@yield('scripts')
|
||||
|
||||
</body>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<div class="block inline">
|
||||
{!! $svg !!}
|
||||
</div>
|
||||
<div class="code-base small text-muted px-s py-xs my-xs" style="overflow-x: scroll; white-space: nowrap;">
|
||||
{{ $url }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="list-heading">{{ trans('auth.mfa_gen_totp_verify_setup') }}</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@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')
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* 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');
|
||||
|
||||
@@ -10,6 +10,9 @@ Route::group(['middleware' => 'auth'], function () {
|
||||
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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Tests\Api;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiDocsTest extends TestCase
|
||||
@@ -11,16 +10,6 @@ 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);
|
||||
@@ -42,19 +31,4 @@ class ApiDocsTest extends TestCase
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,12 @@ class MfaConfigurationTest extends TestCase
|
||||
$resp->assertSee('The provided code is not valid or has expired.');
|
||||
$revisitSvg = $resp->getElementHtml('#main-content .card svg');
|
||||
$this->assertTrue($svg === $revisitSvg);
|
||||
$secret = decrypt(session()->get('mfa-setup-totp-secret'));
|
||||
|
||||
$resp->assertSee(htmlentities("?secret={$secret}&issuer=BookStack&algorithm=SHA1&digits=6&period=30"));
|
||||
|
||||
// Successful confirmation
|
||||
$google2fa = new Google2FA();
|
||||
$secret = decrypt(session()->get('mfa-setup-totp-secret'));
|
||||
$otp = $google2fa->getCurrentOtp($secret);
|
||||
$resp = $this->post('/mfa/totp/confirm', [
|
||||
'code' => $otp,
|
||||
|
||||
@@ -135,14 +135,26 @@ class PageContentTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -155,6 +167,7 @@ class PageContentTest extends TestCase
|
||||
$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:');
|
||||
@@ -168,6 +181,8 @@ class PageContentTest extends TestCase
|
||||
$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();
|
||||
@@ -179,7 +194,7 @@ class PageContentTest extends TestCase
|
||||
|
||||
$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:');
|
||||
}
|
||||
}
|
||||
@@ -188,8 +203,10 @@ class PageContentTest extends TestCase
|
||||
{
|
||||
$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();
|
||||
@@ -213,6 +230,8 @@ class PageContentTest extends TestCase
|
||||
{
|
||||
$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();
|
||||
@@ -249,11 +268,13 @@ class PageContentTest extends TestCase
|
||||
{
|
||||
$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();
|
||||
@@ -284,6 +305,28 @@ class PageContentTest extends TestCase
|
||||
$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();
|
||||
|
||||
@@ -258,4 +258,25 @@ class SortTest extends TestCase
|
||||
$checkResp = $this->get(Page::find($checkPage->id)->getUrl());
|
||||
$checkResp->assertSee($newBook->name);
|
||||
}
|
||||
|
||||
public function test_pages_in_book_show_sorted_by_priority()
|
||||
{
|
||||
/** @var Book $book */
|
||||
$book = Book::query()->whereHas('pages')->first();
|
||||
$book->chapters()->forceDelete();
|
||||
/** @var Page[] $pages */
|
||||
$pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get();
|
||||
$book->pages()->whereNotIn('id', $pages->pluck('id'))->delete();
|
||||
|
||||
$resp = $this->asEditor()->get($book->getUrl());
|
||||
$resp->assertElementContains('.content-wrap a.page:nth-child(1)', $pages[0]->name);
|
||||
$resp->assertElementContains('.content-wrap a.page:nth-child(2)', $pages[1]->name);
|
||||
|
||||
$pages[0]->forceFill(['priority' => 10])->save();
|
||||
$pages[1]->forceFill(['priority' => 5])->save();
|
||||
|
||||
$resp = $this->asEditor()->get($book->getUrl());
|
||||
$resp->assertElementContains('.content-wrap a.page:nth-child(1)', $pages[1]->name);
|
||||
$resp->assertElementContains('.content-wrap a.page:nth-child(2)', $pages[0]->name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use BookStack\Util\CspService;
|
||||
|
||||
class SecurityHeaderTest extends TestCase
|
||||
{
|
||||
@@ -44,26 +44,89 @@ 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() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user