Compare commits

...

14 Commits

Author SHA1 Message Date
Dan Brown
88bcb68fcb Updated version and assets for release v21.08.2 2021-09-04 15:07:20 +01:00
Dan Brown
7c000553ae Merge branch 'master' into release 2021-09-04 15:06:33 +01:00
Dan Brown
d815e1b9f2 Merge branch 'html-filtering' 2021-09-04 14:53:46 +01:00
Dan Brown
492af79c27 Added a couple of additional CSP rules
As per guidance from google's CSP evaluator.
2021-09-04 14:34:43 +01:00
Dan Brown
253f386f00 Finished off script CSP rules
- Added caching for custom html head parsing to add nonce.
- Also moved api docs page into web routes to prevent issues.
2021-09-04 13:57:04 +01:00
Dan Brown
fd44e4ba74 Started application of CSP headers 2021-09-03 23:32:42 +01:00
Dan Brown
040997fdc4 Added filter for xlink:href svg xss
Simply remove all such attributes
2021-09-03 22:34:49 +01:00
Dan Brown
5e6092aaf8 Added extra HTML filtering of dangerous content
In particular, That around the casing of dangerous values within
attributes. This uses some xpath translation to handle different casing
in contains searching.
2021-09-02 22:02:30 +01:00
Dan Brown
391fa35c80 Updated version and assets for release v21.08.1 2021-09-02 21:13:09 +01:00
Dan Brown
c6773a8c9f Merge branch 'master' into release 2021-09-02 21:12:06 +01:00
Dan Brown
a579b7da21 Updated translator attribution before release v21.08.1 2021-09-02 21:11:23 +01:00
Dan Brown
bc34914ac1 New Crowdin updates (#2906)
* New translations auth.php (Chinese Simplified)

* New translations auth.php (Chinese Simplified)

* New translations validation.php (Chinese Simplified)

* New translations activities.php (Latvian)

* New translations auth.php (Latvian)

* New translations common.php (Latvian)

* New translations validation.php (Latvian)

* New translations entities.php (Latvian)

* New translations activities.php (Polish)
2021-09-02 21:07:31 +01:00
Dan Brown
7028025380 Made the TOTP URL visible during setup
Useful for some non-scanner type apps.
Closes #2908
2021-09-01 20:58:19 +01:00
Dan Brown
ff494be952 Fixed lack of proper ordering of pages
Added test to cover
Fixes #2905
2021-09-01 20:30:02 +01:00
35 changed files with 509 additions and 134 deletions

View File

@@ -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

View File

@@ -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'];

View File

@@ -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,
]);
}

View File

@@ -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,

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
});
}
}

View 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
View 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));
}
}

View File

@@ -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);
}
}
}

View 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);
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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ł',

View File

@@ -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' => '多重身份认证已设置,请使用新配置的方法重新登录。',
];

View File

@@ -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' => '无法上传文件。 服务器可能不接受此大小的文件。',

View File

@@ -145,6 +145,7 @@ body.flexbox {
.flex {
min-height: 0;
flex: 1;
max-width: 100%;
&.fit-content {
flex-basis: auto;
flex-grow: 0;

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')

View File

@@ -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');

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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() ?? '';
}
}

View File

@@ -1 +1 @@
v21.08
v21.08.2