mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-03-01 03:09:42 +03:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b9303a42 | ||
|
|
50a7183b32 | ||
|
|
25ed242f61 | ||
|
|
7aef0a48b3 | ||
|
|
1db1083064 | ||
|
|
664eb6d980 | ||
|
|
80204518a2 | ||
|
|
7528bc19b7 | ||
|
|
6854687d7c | ||
|
|
a8d96fd389 | ||
|
|
9d15c79fee | ||
|
|
ad540a015f | ||
|
|
f54f507854 | ||
|
|
e1de1f0583 | ||
|
|
a2017ffa55 | ||
|
|
f484fbc110 | ||
|
|
299d3b3149 | ||
|
|
9646339933 | ||
|
|
e4383765e1 | ||
|
|
5d547fcf4c | ||
|
|
826b36c985 | ||
|
|
3fa1174e7a | ||
|
|
50e8501027 | ||
|
|
8a221f64e4 | ||
|
|
035be66ebc | ||
|
|
227027fc45 | ||
|
|
0f040fe8b1 | ||
|
|
10ebe53bd9 |
@@ -351,10 +351,25 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Allow JavaScript, and other potentiall dangerous content in page content.
|
||||
# This also removes CSP-level JavaScript control.
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
||||
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
# Control the behaviour of content filtering, primarily used for page content.
|
||||
# This setting is a string of characters which represent different available filters:
|
||||
# - j - Filter out JavaScript and unknown binary data based content
|
||||
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
# - f - Filter out unexpected form elements
|
||||
# - a - Run content through a more complex allowlist filter
|
||||
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
||||
# to ensure you are always using the full range of filters.
|
||||
APP_CONTENT_FILTERING="jfha"
|
||||
|
||||
# Indicate if robots/crawlers should crawl your instance.
|
||||
# Can be 'true', 'false' or 'null'.
|
||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||
|
||||
@@ -8,6 +8,7 @@ use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -82,7 +83,8 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeActiveContentFromHtmlString($this->html ?? '');
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
|
||||
@@ -37,10 +37,15 @@ return [
|
||||
// The limit for all uploaded files, including images and attachments in MB.
|
||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||
|
||||
// Allow <script> tags to entered within page content.
|
||||
// <script> tags are escaped by default.
|
||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
||||
// Control the behaviour of content filtering, primarily used for page content.
|
||||
// This setting is a string of characters which represent different available filters:
|
||||
// - j - Filter out JavaScript and unknown binary data based content
|
||||
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
// - f - Filter out unexpected form elements
|
||||
// - a - Run content through a more complex allowlist filter
|
||||
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
||||
|
||||
// Allow server-side fetches to be performed to potentially unknown
|
||||
// and user-provided locations. Primarily used in exports when loading
|
||||
@@ -48,8 +53,8 @@ return [
|
||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||
|
||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||
// May be ignored if view has be overridden or modified.
|
||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
||||
// May be ignored if the underlying view has been overridden or modified.
|
||||
// Defaults to null in which case the 'app-public' status is used instead.
|
||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||
|
||||
// Application Base URL, Used by laravel in development commands
|
||||
|
||||
@@ -21,6 +21,8 @@ use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -173,7 +175,7 @@ class PageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page from an ajax request.
|
||||
* Get a page from an ajax request.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
@@ -183,6 +185,10 @@ class PageController extends Controller
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->makeHidden(['book']);
|
||||
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
@@ -50,7 +51,8 @@ class EntityHtmlDescription
|
||||
return $html;
|
||||
}
|
||||
|
||||
return HtmlContentFilter::removeActiveContentFromHtmlString($html);
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
@@ -13,6 +14,7 @@ use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Closure;
|
||||
@@ -317,11 +319,30 @@ class PageContent
|
||||
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
||||
}
|
||||
|
||||
if (!config('app.allow_content_scripts')) {
|
||||
HtmlContentFilter::removeActiveContentFromDocument($doc);
|
||||
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
|
||||
$cached = cache()->get($cacheKey, null);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
return $doc->getBodyInnerHtml();
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$filtered = $filter->filterDocument($doc);
|
||||
|
||||
$cacheTime = 86400 * 7; // 1 week
|
||||
cache()->put($cacheKey, $filtered, $cacheTime);
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
protected function getContentCacheKey(string $html): string
|
||||
{
|
||||
$contentHash = md5($html);
|
||||
$contentId = $this->page->id;
|
||||
$contentTime = $this->page->updated_at?->timestamp ?? time();
|
||||
$appVersion = AppVersion::get();
|
||||
$filterConfig = config('app.content_filtering') ?? '';
|
||||
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class PageEditorData
|
||||
{
|
||||
@@ -47,6 +49,7 @@ class PageEditorData
|
||||
$isDraftRevision = false;
|
||||
$this->warnings = [];
|
||||
$editActivity = new PageEditActivity($page);
|
||||
$lastEditorId = $page->updated_by ?? user()->id;
|
||||
|
||||
if ($editActivity->hasActiveEditing()) {
|
||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||
@@ -58,11 +61,20 @@ class PageEditorData
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
$lastEditorId = $userDraft->created_by;
|
||||
}
|
||||
|
||||
// Get editor type and handle changes
|
||||
$editorType = $this->getEditorType($page);
|
||||
$this->updateContentForEditor($page, $editorType);
|
||||
|
||||
// Filter HTML content if required
|
||||
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
}
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
|
||||
@@ -4,25 +4,16 @@ namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
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;
|
||||
public function __construct(
|
||||
protected CspService $cspService,
|
||||
protected Cache $cache
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +41,8 @@ class CustomHtmlHeadContentProvider
|
||||
$hash = md5($content);
|
||||
|
||||
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
|
||||
return HtmlContentFilter::removeActiveContentFromHtmlString($content);
|
||||
$config = new HtmlContentFilterConfig(filterOutNonContentElements: false, useAllowListFilter: false);
|
||||
return (new HtmlContentFilter($config))->filterString($content);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
158
app/Util/ConfiguredHtmlPurifier.php
Normal file
158
app/Util/ConfiguredHtmlPurifier.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use HTMLPurifier;
|
||||
use HTMLPurifier_Config;
|
||||
use HTMLPurifier_DefinitionCache_Serializer;
|
||||
use HTMLPurifier_HTML5Config;
|
||||
use HTMLPurifier_HTMLDefinition;
|
||||
|
||||
/**
|
||||
* Provides a configured HTML Purifier instance.
|
||||
* https://github.com/ezyang/htmlpurifier
|
||||
* Also uses this to extend support to HTML5 elements:
|
||||
* https://github.com/xemlock/htmlpurifier-html5
|
||||
*/
|
||||
class ConfiguredHtmlPurifier
|
||||
{
|
||||
protected HTMLPurifier $purifier;
|
||||
protected static bool $cachedChecked = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// This is done by the web-server at run-time, with the existing
|
||||
// storage/framework/cache folder to ensure we're using a server-writable folder.
|
||||
$cachePath = storage_path('framework/cache/purifier');
|
||||
$this->createCacheFolderIfNeeded($cachePath);
|
||||
|
||||
$config = HTMLPurifier_HTML5Config::createDefault();
|
||||
$this->setConfig($config, $cachePath);
|
||||
$this->resetCacheIfNeeded($config);
|
||||
|
||||
$htmlDef = $config->getDefinition('HTML', true, true);
|
||||
if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) {
|
||||
$this->configureDefinition($htmlDef);
|
||||
}
|
||||
|
||||
$this->purifier = new HTMLPurifier($config);
|
||||
}
|
||||
|
||||
protected function createCacheFolderIfNeeded(string $cachePath): void
|
||||
{
|
||||
if (!file_exists($cachePath)) {
|
||||
mkdir($cachePath, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void
|
||||
{
|
||||
if (self::$cachedChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cachedForVersion = cache('htmlpurifier::cache-version');
|
||||
$appVersion = AppVersion::get();
|
||||
if ($cachedForVersion !== $appVersion) {
|
||||
foreach (['HTML', 'CSS', 'URI'] as $name) {
|
||||
$cache = new HTMLPurifier_DefinitionCache_Serializer($name);
|
||||
$cache->flush($config);
|
||||
}
|
||||
cache()->set('htmlpurifier::cache-version', $appVersion);
|
||||
}
|
||||
|
||||
self::$cachedChecked = true;
|
||||
}
|
||||
|
||||
protected function setConfig(HTMLPurifier_Config $config, string $cachePath): void
|
||||
{
|
||||
$config->set('Cache.SerializerPath', $cachePath);
|
||||
$config->set('Core.AllowHostnameUnderscore', true);
|
||||
$config->set('CSS.AllowTricky', true);
|
||||
$config->set('HTML.SafeIframe', true);
|
||||
$config->set('HTML.TargetNoopener', false);
|
||||
$config->set('HTML.TargetNoreferrer', false);
|
||||
$config->set('Attr.EnableID', true);
|
||||
$config->set('Attr.ID.HTML5', true);
|
||||
$config->set('Output.FixInnerHTML', false);
|
||||
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
|
||||
$config->set('URI.AllowedSchemes', [
|
||||
'http' => true,
|
||||
'https' => true,
|
||||
'mailto' => true,
|
||||
'ftp' => true,
|
||||
'nntp' => true,
|
||||
'news' => true,
|
||||
'tel' => true,
|
||||
'file' => true,
|
||||
]);
|
||||
|
||||
// $config->set('Cache.DefinitionImpl', null); // Disable cache during testing
|
||||
}
|
||||
|
||||
public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void
|
||||
{
|
||||
// Allow the object element
|
||||
$definition->addElement(
|
||||
'object',
|
||||
'Inline',
|
||||
'Flow',
|
||||
'Common',
|
||||
[
|
||||
'data' => 'URI',
|
||||
'type' => 'Text',
|
||||
'width' => 'Length',
|
||||
'height' => 'Length',
|
||||
]
|
||||
);
|
||||
|
||||
// Allow the embed element
|
||||
$definition->addElement(
|
||||
'embed',
|
||||
'Inline',
|
||||
'Empty',
|
||||
'Common',
|
||||
[
|
||||
'src' => 'URI',
|
||||
'type' => 'Text',
|
||||
'width' => 'Length',
|
||||
'height' => 'Length',
|
||||
]
|
||||
);
|
||||
|
||||
// Allow checkbox inputs
|
||||
$definition->addElement(
|
||||
'input',
|
||||
'Formctrl',
|
||||
'Empty',
|
||||
'Common',
|
||||
[
|
||||
'checked' => 'Bool#checked',
|
||||
'disabled' => 'Bool#disabled',
|
||||
'name' => 'Text',
|
||||
'readonly' => 'Bool#readonly',
|
||||
'type' => 'Enum#checkbox',
|
||||
'value' => 'Text',
|
||||
]
|
||||
);
|
||||
|
||||
// Allow the drawio-diagram attribute on div elements
|
||||
$definition->addAttribute(
|
||||
'div',
|
||||
'drawio-diagram',
|
||||
'Number',
|
||||
);
|
||||
|
||||
// Allow target="_blank" on links
|
||||
$definition->addAttribute('a', 'target', 'Enum#_blank');
|
||||
|
||||
// Allow mention-ids on links
|
||||
$definition->addAttribute('a', 'data-mention-user-id', 'Number');
|
||||
}
|
||||
|
||||
public function purify(string $html): string
|
||||
{
|
||||
return $this->purifier->purify($html);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class CspService
|
||||
*/
|
||||
protected function getScriptSrc(): string
|
||||
{
|
||||
if (config('app.allow_content_scripts')) {
|
||||
if ($this->scriptFilteringDisabled()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class CspService
|
||||
*/
|
||||
protected function getObjectSrc(): string
|
||||
{
|
||||
if (config('app.allow_content_scripts')) {
|
||||
if ($this->scriptFilteringDisabled()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@ class CspService
|
||||
return "base-uri 'self'";
|
||||
}
|
||||
|
||||
protected function scriptFilteringDisabled(): bool
|
||||
{
|
||||
return !HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'))->filterOutJavaScript;
|
||||
}
|
||||
|
||||
protected function getAllowedIframeHosts(): array
|
||||
{
|
||||
$hosts = config('app.iframe_hosts') ?? '';
|
||||
|
||||
@@ -8,12 +8,46 @@ use DOMNodeList;
|
||||
|
||||
class HtmlContentFilter
|
||||
{
|
||||
/**
|
||||
* Remove all active content from the given HTML document.
|
||||
* This aims to cover anything which can dynamically deal with, or send, data
|
||||
* like any JavaScript actions or form content.
|
||||
*/
|
||||
public static function removeActiveContentFromDocument(HtmlDocument $doc): void
|
||||
public function __construct(
|
||||
protected HtmlContentFilterConfig $config
|
||||
) {
|
||||
}
|
||||
|
||||
public function filterDocument(HtmlDocument $doc): string
|
||||
{
|
||||
if ($this->config->filterOutJavaScript) {
|
||||
$this->filterOutScriptsFromDocument($doc);
|
||||
}
|
||||
if ($this->config->filterOutFormElements) {
|
||||
$this->filterOutFormElementsFromDocument($doc);
|
||||
}
|
||||
if ($this->config->filterOutBadHtmlElements) {
|
||||
$this->filterOutBadHtmlElementsFromDocument($doc);
|
||||
}
|
||||
if ($this->config->filterOutNonContentElements) {
|
||||
$this->filterOutNonContentElementsFromDocument($doc);
|
||||
}
|
||||
|
||||
$filtered = $doc->getBodyInnerHtml();
|
||||
if ($this->config->useAllowListFilter) {
|
||||
$filtered = $this->applyAllowListFiltering($filtered);
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
public function filterString(string $html): string
|
||||
{
|
||||
return $this->filterDocument(new HtmlDocument($html));
|
||||
}
|
||||
|
||||
protected function applyAllowListFiltering(string $html): string
|
||||
{
|
||||
$purifier = new ConfiguredHtmlPurifier();
|
||||
return $purifier->purify($html);
|
||||
}
|
||||
|
||||
protected function filterOutScriptsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
// Remove standard script tags
|
||||
$scriptElems = $doc->queryXPath('//script');
|
||||
@@ -27,17 +61,17 @@ class HtmlContentFilter
|
||||
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
|
||||
static::removeNodes($badForms);
|
||||
|
||||
// Remove meta tag to prevent external redirects
|
||||
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
|
||||
static::removeNodes($metaTags);
|
||||
|
||||
// Remove data or JavaScript iFrames
|
||||
// Remove data or JavaScript iFrames & embeds
|
||||
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
|
||||
static::removeNodes($badIframes);
|
||||
|
||||
// Remove data or JavaScript objects
|
||||
$badObjects = $doc->queryXPath('//*[' . static::xpathContains('@data', 'data:') . '] | //*[' . static::xpathContains('@data', 'javascript:') . ']');
|
||||
static::removeNodes($badObjects);
|
||||
|
||||
// Remove attributes, within svg children, hiding JavaScript or data uris.
|
||||
// A bunch of svg element and attribute combinations expose xss possibilities.
|
||||
// For example, SVG animate tag can exploit javascript in values.
|
||||
// For example, SVG animate tag can exploit JavaScript in values.
|
||||
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
|
||||
static::removeAttributes($badValuesAttrs);
|
||||
|
||||
@@ -49,7 +83,10 @@ class HtmlContentFilter
|
||||
// Remove 'on*' attributes
|
||||
$onAttributes = $doc->queryXPath('//@*[starts-with(name(), \'on\')]');
|
||||
static::removeAttributes($onAttributes);
|
||||
}
|
||||
|
||||
protected function filterOutFormElementsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
// Remove form elements
|
||||
$formElements = ['form', 'fieldset', 'button', 'textarea', 'select'];
|
||||
foreach ($formElements as $formElement) {
|
||||
@@ -75,41 +112,21 @@ class HtmlContentFilter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove active content from the given HTML string.
|
||||
* This aims to cover anything which can dynamically deal with, or send, data
|
||||
* like any JavaScript actions or form content.
|
||||
*/
|
||||
public static function removeActiveContentFromHtmlString(string $html): string
|
||||
protected function filterOutBadHtmlElementsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
if (empty($html)) {
|
||||
return $html;
|
||||
// Remove meta tag to prevent external redirects
|
||||
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
|
||||
static::removeNodes($metaTags);
|
||||
}
|
||||
|
||||
protected function filterOutNonContentElementsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
// Remove non-content elements
|
||||
$formElements = ['link', 'style', 'meta', 'title', 'template'];
|
||||
foreach ($formElements as $formElement) {
|
||||
$matchingFormElements = $doc->queryXPath('//' . $formElement);
|
||||
static::removeNodes($matchingFormElements);
|
||||
}
|
||||
|
||||
$doc = new HtmlDocument($html);
|
||||
static::removeActiveContentFromDocument($doc);
|
||||
|
||||
return $doc->getBodyInnerHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||
* To remove in future feature release.
|
||||
* @deprecated Use removeActiveContentFromDocument instead.
|
||||
*/
|
||||
public static function removeScriptsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
static::removeActiveContentFromDocument($doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||
* To remove in future feature release.
|
||||
* @deprecated Use removeActiveContentFromHtmlString instead.
|
||||
*/
|
||||
public static function removeScriptsFromHtmlString(string $html): string
|
||||
{
|
||||
return static::removeActiveContentFromHtmlString($html);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,4 +164,34 @@ class HtmlContentFilter
|
||||
$parentNode->removeAttribute($attrName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||
* To remove in future feature release.
|
||||
* @deprecated Use filterDocument instead.
|
||||
*/
|
||||
public static function removeScriptsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
$config = new HtmlContentFilterConfig(
|
||||
filterOutNonContentElements: false,
|
||||
useAllowListFilter: false,
|
||||
);
|
||||
$filter = new self($config);
|
||||
$filter->filterDocument($doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||
* To remove in future feature release.
|
||||
* @deprecated Use filterString instead.
|
||||
*/
|
||||
public static function removeScriptsFromHtmlString(string $html): string
|
||||
{
|
||||
$config = new HtmlContentFilterConfig(
|
||||
filterOutNonContentElements: false,
|
||||
useAllowListFilter: false,
|
||||
);
|
||||
$filter = new self($config);
|
||||
return $filter->filterString($html);
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Util/HtmlContentFilterConfig.php
Normal file
31
app/Util/HtmlContentFilterConfig.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
readonly class HtmlContentFilterConfig
|
||||
{
|
||||
public function __construct(
|
||||
public bool $filterOutJavaScript = true,
|
||||
public bool $filterOutBadHtmlElements = true,
|
||||
public bool $filterOutFormElements = true,
|
||||
public bool $filterOutNonContentElements = true,
|
||||
public bool $useAllowListFilter = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance from a config string, where the string
|
||||
* is a combination of characters to enable filters.
|
||||
*/
|
||||
public static function fromConfigString(string $config): self
|
||||
{
|
||||
$config = strtolower($config);
|
||||
return new self(
|
||||
filterOutJavaScript: str_contains($config, 'j'),
|
||||
filterOutBadHtmlElements: str_contains($config, 'h'),
|
||||
filterOutFormElements: str_contains($config, 'f'),
|
||||
filterOutNonContentElements: str_contains($config, 'h'),
|
||||
useAllowListFilter: str_contains($config, 'a'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,13 @@ class HtmlDocument
|
||||
*/
|
||||
public function getBody(): DOMNode
|
||||
{
|
||||
return $this->document->getElementsByTagName('body')[0];
|
||||
$bodies = $this->document->getElementsByTagName('body');
|
||||
|
||||
if ($bodies->length === 0) {
|
||||
return new DOMElement('body', '');
|
||||
}
|
||||
|
||||
return $bodies[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ext-zip": "*",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"ezyang/htmlpurifier": "^4.19",
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"intervention/image": "^3.5",
|
||||
"knplabs/knp-snappy": "^1.5",
|
||||
@@ -38,7 +39,8 @@
|
||||
"socialiteproviders/microsoft-azure": "^5.1",
|
||||
"socialiteproviders/okta": "^4.2",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^2.0.0"
|
||||
"ssddanbrown/htmldiff": "^2.0.0",
|
||||
"xemlock/htmlpurifier-html5": "^0.1.12"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.21",
|
||||
|
||||
693
composer.lock
generated
693
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
62a5824a1a12ba387aefe5d1e09cb90afd20d61cb84c7ac78b62de729e8baf2a
|
||||
6b4fe778d645978b3720f7d035a6c3965024d4d5d6d29e3114bb6db499b7e61e
|
||||
@@ -98,6 +98,13 @@ Copyright: Copyright (c) 2013-2023 Eduardo Gulias Davis
|
||||
Source: https://github.com/egulias/EmailValidator.git
|
||||
Link: https://github.com/egulias/EmailValidator
|
||||
-----------
|
||||
ezyang/htmlpurifier
|
||||
License: LGPL-2.1-or-later
|
||||
License File: vendor/ezyang/htmlpurifier/LICENSE
|
||||
Copyright: Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
Source: https://github.com/ezyang/htmlpurifier.git
|
||||
Link: http://htmlpurifier.org/
|
||||
-----------
|
||||
firebase/php-jwt
|
||||
License: BSD-3-Clause
|
||||
License File: vendor/firebase/php-jwt/LICENSE
|
||||
@@ -465,7 +472,7 @@ Link: https://github.com/php-fig/simple-cache.git
|
||||
psy/psysh
|
||||
License: MIT
|
||||
License File: vendor/psy/psysh/LICENSE
|
||||
Copyright: Copyright (c) 2012-2025 Justin Hileman
|
||||
Copyright: Copyright (c) 2012-2026 Justin Hileman
|
||||
Source: https://github.com/bobthecow/psysh.git
|
||||
Link: https://psysh.org
|
||||
-----------
|
||||
@@ -787,3 +794,10 @@ License File: vendor/voku/portable-ascii/LICENSE.txt
|
||||
Copyright: Copyright (C) 2019 Lars Moelleken
|
||||
Source: https://github.com/voku/portable-ascii.git
|
||||
Link: https://github.com/voku/portable-ascii
|
||||
-----------
|
||||
xemlock/htmlpurifier-html5
|
||||
License: MIT
|
||||
License File: vendor/xemlock/htmlpurifier-html5/LICENSE
|
||||
Copyright: Copyright (c) 2015 Xemlock
|
||||
Source: https://github.com/xemlock/htmlpurifier-html5.git
|
||||
Link: https://github.com/xemlock/htmlpurifier-html5
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<server name="AUTH_AUTO_INITIATE" value="false"/>
|
||||
<server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
|
||||
<server name="ALLOW_UNTRUSTED_SERVER_FETCHING" value="false"/>
|
||||
<server name="CONTENT_FILTERING" value="jhfa"/>
|
||||
<server name="ALLOW_CONTENT_SCRIPTS" value="false"/>
|
||||
<server name="AVATAR_URL" value=""/>
|
||||
<server name="LDAP_START_TLS" value="false"/>
|
||||
|
||||
@@ -155,7 +155,7 @@ The website which contains the project docs & blog can be found in the [BookStac
|
||||
The BookStack source is provided under the [MIT License](https://github.com/BookStackApp/BookStack/blob/development/LICENSE).
|
||||
|
||||
The libraries used by, and included with, BookStack are provided under their own licenses and copyright.
|
||||
The licenses for many of our core dependencies can be found in the attribution list below but this is not an exhaustive list of all projects used within BookStack.
|
||||
The licenses for many of our core dependencies can be found in the attribution list below, but this is not an exhaustive list of all projects used within BookStack.
|
||||
|
||||
## 👪 Attribution
|
||||
|
||||
@@ -187,5 +187,6 @@ Note: This is not an exhaustive list of all libraries and projects that would be
|
||||
* [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan) - _[MIT](https://github.com/phpstan/phpstan/blob/master/LICENSE) and [MIT](https://github.com/nunomaduro/larastan/blob/master/LICENSE.md)_
|
||||
* [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) - _[BSD 3-Clause](https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt)_
|
||||
* [JakeArchibald/IDB-Keyval](https://github.com/jakearchibald/idb-keyval) - _[Apache-2.0](https://github.com/jakearchibald/idb-keyval/blob/main/LICENCE)_
|
||||
* [HTML Purifier](https://github.com/ezyang/htmlpurifier) and [htmlpurifier-html5](https://github.com/xemlock/htmlpurifier-html5) - _[LGPL-2.1](https://github.com/ezyang/htmlpurifier/blob/master/LICENSE) and [MIT](https://github.com/xemlock/htmlpurifier-html5/blob/master/LICENSE)_
|
||||
|
||||
For a detailed breakdown of the JavaScript & PHP projects imported & used via NPM & composer package managers, along with their licenses, please see the [dev/licensing/js-library-licenses.txt](dev/licensing/js-library-licenses.txt) and [dev/licensing/php-library-licenses.txt](dev/licensing/php-library-licenses.txt) files.
|
||||
For a detailed breakdown of the JavaScript & PHP projects imported and used via NPM & composer package managers, along with their licenses, please see the [dev/licensing/js-library-licenses.txt](dev/licensing/js-library-licenses.txt) and [dev/licensing/php-library-licenses.txt](dev/licensing/php-library-licenses.txt) files.
|
||||
1
storage/framework/.gitignore
vendored
1
storage/framework/.gitignore
vendored
@@ -7,3 +7,4 @@ routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
purifier/
|
||||
|
||||
502
tests/Entity/PageContentFilteringTest.php
Normal file
502
tests/Entity/PageContentFilteringTest.php
Normal file
@@ -0,0 +1,502 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Entity;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class PageContentFilteringTest extends TestCase
|
||||
{
|
||||
public function test_page_content_scripts_removed_by_default()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
$script = 'abc123<script>console.log("hello-test")</script>abc123';
|
||||
$page->html = "escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertDontSee($script, false);
|
||||
$pageView->assertSee('abc123abc123');
|
||||
}
|
||||
|
||||
public function test_more_complex_content_script_escaping_scenarios()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$checks = [
|
||||
"<p>Some script</p><script>alert('cat')</script>",
|
||||
"<div><div><div><div><p>Some script</p><script>alert('cat')</script></div></div></div></div>",
|
||||
"<p>Some script<script>alert('cat')</script></p>",
|
||||
"<p>Some script <div><script>alert('cat')</script></div></p>",
|
||||
"<p>Some script <script><div>alert('cat')</script></div></p>",
|
||||
"<p>Some script <script><div>alert('cat')</script><script><div>alert('cat')</script></p><script><div>alert('cat')</script>",
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '<script>');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '</script>');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_js_and_base64_src_urls_are_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$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\'")`>',
|
||||
'<object data="javascript:alert(document.cookie)"></object>',
|
||||
'<object data="JavAScRipT:alert(document.cookie)"></object>',
|
||||
'<object data="JavAScRipT:alert(document.cookie)"></object>',
|
||||
'<object SRC=" javascript: alert(document.cookie)"></object>',
|
||||
'<object data="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></object>',
|
||||
'<object data="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></object>',
|
||||
'<object data=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></object>',
|
||||
'<embed src="javascript:alert(document.cookie)"/>',
|
||||
'<embed src="JavAScRipT:alert(document.cookie)"/>',
|
||||
'<embed src="JavAScRipT:alert(document.cookie)"/>',
|
||||
'<embed SRC=" javascript: alert(document.cookie)"/>',
|
||||
'<embed src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
|
||||
'<embed src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
|
||||
'<embed src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$html = $this->withHtml($pageView);
|
||||
$html->assertElementNotContains('.page-content', '<object');
|
||||
$html->assertElementNotContains('.page-content', 'data=');
|
||||
$html->assertElementNotContains('.page-content', '<iframe>');
|
||||
$html->assertElementNotContains('.page-content', '<img');
|
||||
$html->assertElementNotContains('.page-content', '</iframe>');
|
||||
$html->assertElementNotContains('.page-content', 'src=');
|
||||
$html->assertElementNotContains('.page-content', 'javascript:');
|
||||
$html->assertElementNotContains('.page-content', 'data:');
|
||||
$html->assertElementNotContains('.page-content', 'base64');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_javascript_uri_links_are_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '<a id="xss"');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_filtering_is_controlled_by_config()
|
||||
{
|
||||
config()->set('app.content_filtering', '');
|
||||
$page = $this->entities->page();
|
||||
$page->html = '<form><input type="text" id="dont-see-this" value="test"></form>';
|
||||
$page->save();
|
||||
|
||||
$this->asEditor()->get($page->getUrl())->assertSee('dont-see-this', false);
|
||||
|
||||
config()->set('app.content_filtering', 'f');
|
||||
$this->get($page->getUrl())->assertDontSee('dont-see-this', false);
|
||||
}
|
||||
|
||||
public function test_form_actions_with_javascript_are_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$checks = [
|
||||
'<customform><custominput id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><custominput></customform>',
|
||||
'<customform ><custombutton id="xss" formaction="JaVaScRiPt:alert(document.domain)">Click me</custombutton></customform>',
|
||||
'<customform ><custombutton id="xss" formaction=javascript:alert(document.domain)>Click me</custombutton></customform>',
|
||||
'<customform id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></customform>',
|
||||
'<customform id="xss" action="JaVaScRiPt:alert(document.domain)"><input type=submit value=Submit></customform>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertDontSee('id="xss"', false);
|
||||
$pageView->assertDontSee('action=javascript:', false);
|
||||
$pageView->assertDontSee('action=JaVaScRiPt:', false);
|
||||
$pageView->assertDontSee('formaction=javascript:', false);
|
||||
$pageView->assertDontSee('formaction=JaVaScRiPt:', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_elements_are_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'f');
|
||||
|
||||
$checks = [
|
||||
'<p>thisisacattofind</p><form>thisdogshouldnotbefound</form>',
|
||||
'<p>thisisacattofind</p><input type="text" value="thisdogshouldnotbefound">',
|
||||
'<p>thisisacattofind</p><select><option>thisdogshouldnotbefound</option></select>',
|
||||
'<p>thisisacattofind</p><textarea>thisdogshouldnotbefound</textarea>',
|
||||
'<p>thisisacattofind</p><fieldset>thisdogshouldnotbefound</fieldset>',
|
||||
'<p>thisisacattofind</p><button>thisdogshouldnotbefound</button>',
|
||||
'<p>thisisacattofind</p><BUTTON>thisdogshouldnotbefound</BUTTON>',
|
||||
<<<'TESTCASE'
|
||||
<svg width="200" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<foreignObject width="100%" height="100%">
|
||||
|
||||
<body xmlns="http://www.w3.org/1999/xhtml">
|
||||
<p>thisisacattofind</p>
|
||||
<form>
|
||||
<p>thisdogshouldnotbefound</p>
|
||||
</form>
|
||||
<input type="text" placeholder="thisdogshouldnotbefound" />
|
||||
<button type="submit">thisdogshouldnotbefound</button>
|
||||
</body>
|
||||
|
||||
</foreignObject>
|
||||
</svg>
|
||||
TESTCASE
|
||||
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertSee('thisisacattofind');
|
||||
$pageView->assertDontSee('thisdogshouldnotbefound');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_attributes_are_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'f');
|
||||
|
||||
$withinSvgSample = <<<'TESTCASE'
|
||||
<svg width="200" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<foreignObject width="100%" height="100%">
|
||||
|
||||
<body xmlns="http://www.w3.org/1999/xhtml">
|
||||
<p formaction="a">thisisacattofind</p>
|
||||
<p formaction="a">thisisacattofind</p>
|
||||
</body>
|
||||
|
||||
</foreignObject>
|
||||
</svg>
|
||||
TESTCASE;
|
||||
|
||||
$checks = [
|
||||
'formaction' => '<p formaction="a">thisisacattofind</p>',
|
||||
'form' => '<p form="a">thisisacattofind</p>',
|
||||
'formmethod' => '<p formmethod="a">thisisacattofind</p>',
|
||||
'formtarget' => '<p formtarget="a">thisisacattofind</p>',
|
||||
'FORMTARGET' => '<p FORMTARGET="a">thisisacattofind</p>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $attribute => $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertSee('thisisacattofind');
|
||||
$this->withHtml($pageView)->assertElementNotExists(".page-content [{$attribute}]");
|
||||
}
|
||||
|
||||
$page->html = $withinSvgSample;
|
||||
$page->save();
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$html = $this->withHtml($pageView);
|
||||
foreach ($checks as $attribute => $check) {
|
||||
$pageView->assertSee('thisisacattofind');
|
||||
$html->assertElementNotExists(".page-content [{$attribute}]");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_metadata_redirects_are_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'h');
|
||||
|
||||
$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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '<meta>');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '</meta>');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'content=');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'external_url');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_inline_on_attributes_removed_by_default()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
$script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
|
||||
$page->html = "escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertDontSee($script, false);
|
||||
$pageView->assertSee('<p>Hello</p>', false);
|
||||
}
|
||||
|
||||
public function test_more_complex_inline_on_attributes_escaping_scenarios()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'onclick');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_content_scripts_show_with_filters_disabled()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
config()->set('app.content_filtering', '');
|
||||
|
||||
$script = 'abc123<script>console.log("hello-test")</script>abc123';
|
||||
$page->html = "no escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertSee($script, false);
|
||||
$pageView->assertDontSee('abc123abc123');
|
||||
}
|
||||
|
||||
public function test_svg_script_usage_is_removed()
|
||||
{
|
||||
config()->set('app.content_filtering', 'j');
|
||||
|
||||
$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>',
|
||||
'<svg><animate href=#xss attributeName=href values=javascript:alert(1) /></svg>',
|
||||
'<svg><animate href="#xss" attributeName="href" values="a;javascript:alert(1)" /></svg>',
|
||||
'<svg><animate href="#xss" attributeName="href" values="a;data:alert(1)" /></svg>',
|
||||
'<svg><animate href=#xss attributeName=href from=javascript:alert(1) to=1 /><a id=xss><text x=20 y=20>XSS</text></a>',
|
||||
'<svg><set href=#xss attributeName=href from=? to=javascript:alert(1) /><a id=xss><text x=20 y=20>XSS</text></a>',
|
||||
'<svg><g><g><g><animate href=#xss attributeName=href values=javascript:alert(1) /></g></g></g></svg>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$html = $this->withHtml($pageView);
|
||||
$html->assertElementNotContains('.page-content', 'alert');
|
||||
$html->assertElementNotContains('.page-content', 'xlink:href');
|
||||
$html->assertElementNotContains('.page-content', 'application/xml');
|
||||
$html->assertElementNotContains('.page-content', 'javascript');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_inline_on_attributes_show_with_filters_disabled()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
config()->set('app.content_filtering', '');
|
||||
|
||||
$script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
|
||||
$page->html = "escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertSee($script, false);
|
||||
$pageView->assertDontSee('<p>Hello</p>', false);
|
||||
}
|
||||
|
||||
public function test_non_content_filtering_is_controlled_by_config()
|
||||
{
|
||||
config()->set('app.content_filtering', '');
|
||||
$page = $this->entities->page();
|
||||
$html = <<<'HTML'
|
||||
<style>superbeans!</style>
|
||||
<template id="template">superbeans!</template>
|
||||
HTML;
|
||||
$page->html = $html;
|
||||
$page->save();
|
||||
|
||||
$resp = $this->asEditor()->get($page->getUrl());
|
||||
$resp->assertSee('superbeans', false);
|
||||
|
||||
config()->set('app.content_filtering', 'h');
|
||||
|
||||
$resp = $this->asEditor()->get($page->getUrl());
|
||||
$resp->assertDontSee('superbeans', false);
|
||||
}
|
||||
|
||||
public function test_non_content_filtering()
|
||||
{
|
||||
config()->set('app.content_filtering', 'h');
|
||||
$page = $this->entities->page();
|
||||
$html = <<<'HTML'
|
||||
<style>superbeans!</style>
|
||||
<p>inbetweenpsection</p>
|
||||
<link rel="stylesheet" href="https://example.com/superbeans.css">
|
||||
<meta name="description" content="superbeans!">
|
||||
<title>superbeans!</title>
|
||||
<template id="template">superbeans!</template>
|
||||
HTML;
|
||||
|
||||
$page->html = $html;
|
||||
$page->save();
|
||||
|
||||
$resp = $this->asEditor()->get($page->getUrl());
|
||||
$resp->assertDontSee('superbeans', false);
|
||||
$resp->assertSee('inbetweenpsection', false);
|
||||
}
|
||||
|
||||
public function test_allow_list_filtering_is_controlled_by_config()
|
||||
{
|
||||
config()->set('app.content_filtering', '');
|
||||
$page = $this->entities->page();
|
||||
$page->html = '<div style="position: absolute; left: 0;color:#00FFEE;">Hello!</div>';
|
||||
$page->save();
|
||||
|
||||
$resp = $this->asEditor()->get($page->getUrl());
|
||||
$resp->assertSee('style="position: absolute; left: 0;color:#00FFEE;"', false);
|
||||
|
||||
config()->set('app.content_filtering', 'a');
|
||||
$resp = $this->get($page->getUrl());
|
||||
$resp->assertDontSee('style="position: absolute; left: 0;color:#00FFEE;"', false);
|
||||
$resp->assertSee('style="color:#00FFEE;"', false);
|
||||
}
|
||||
|
||||
public function test_allow_list_style_filtering()
|
||||
{
|
||||
$testCasesExpectedByInput = [
|
||||
'<div style="position:absolute;left:0;color:#00FFEE;">Hello!</div>' => '<div style="color:#00FFEE;">Hello!</div>',
|
||||
'<div style="background:#FF0000;left:0;color:#00FFEE;">Hello!</div>' => '<div style="background:#FF0000;color:#00FFEE;">Hello!</div>',
|
||||
'<div style="color:#00FFEE;">Hello!<style>testinghello!</style></div>' => '<div style="color:#00FFEE;">Hello!</div>',
|
||||
'<div drawio-diagram="5332" another-attr="cat">Hello!</div>' => '<div drawio-diagram="5332">Hello!</div>',
|
||||
];
|
||||
|
||||
config()->set('app.content_filtering', 'a');
|
||||
$page = $this->entities->page();
|
||||
$this->asEditor();
|
||||
|
||||
foreach ($testCasesExpectedByInput as $input => $expected) {
|
||||
$page->html = $input;
|
||||
$page->save();
|
||||
$resp = $this->get($page->getUrl());
|
||||
|
||||
$resp->assertSee($expected, false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_allow_list_does_not_filter_cases()
|
||||
{
|
||||
$testCasesExpectedByInput = [
|
||||
'<p><a href="https://example.com" target="_blank">New tab linkydoodle</a></p>',
|
||||
'<p><a href="https://example.com/user/1" data-mention-user-id="5">@mentionusertext</a></p>',
|
||||
'<details><summary>Hello</summary><p>Mydetailshere</p></details>',
|
||||
];
|
||||
|
||||
config()->set('app.content_filtering', 'a');
|
||||
$page = $this->entities->page();
|
||||
$this->asEditor();
|
||||
|
||||
foreach ($testCasesExpectedByInput as $input) {
|
||||
$page->html = $input;
|
||||
$page->save();
|
||||
$resp = $this->get($page->getUrl());
|
||||
|
||||
$resp->assertSee($input, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,351 +101,6 @@ class PageContentTest extends TestCase
|
||||
$pageResp->assertSee('Hello Barry');
|
||||
}
|
||||
|
||||
public function test_page_content_scripts_removed_by_default()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
$script = 'abc123<script>console.log("hello-test")</script>abc123';
|
||||
$page->html = "escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertDontSee($script, false);
|
||||
$pageView->assertSee('abc123abc123');
|
||||
}
|
||||
|
||||
public function test_more_complex_content_script_escaping_scenarios()
|
||||
{
|
||||
$checks = [
|
||||
"<p>Some script</p><script>alert('cat')</script>",
|
||||
"<div><div><div><div><p>Some script</p><script>alert('cat')</script></div></div></div></div>",
|
||||
"<p>Some script<script>alert('cat')</script></p>",
|
||||
"<p>Some script <div><script>alert('cat')</script></div></p>",
|
||||
"<p>Some script <script><div>alert('cat')</script></div></p>",
|
||||
"<p>Some script <script><div>alert('cat')</script><script><div>alert('cat')</script></p><script><div>alert('cat')</script>",
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '<script>');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '</script>');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$html = $this->withHtml($pageView);
|
||||
$html->assertElementNotContains('.page-content', '<iframe>');
|
||||
$html->assertElementNotContains('.page-content', '<img');
|
||||
$html->assertElementNotContains('.page-content', '</iframe>');
|
||||
$html->assertElementNotContains('.page-content', 'src=');
|
||||
$html->assertElementNotContains('.page-content', 'javascript:');
|
||||
$html->assertElementNotContains('.page-content', 'data:');
|
||||
$html->assertElementNotContains('.page-content', 'base64');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_javascript_uri_links_are_removed()
|
||||
{
|
||||
$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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '<a id="xss"');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_actions_with_javascript_are_removed()
|
||||
{
|
||||
$checks = [
|
||||
'<customform><custominput id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><custominput></customform>',
|
||||
'<customform ><custombutton id="xss" formaction="JaVaScRiPt:alert(document.domain)">Click me</custombutton></customform>',
|
||||
'<customform ><custombutton id="xss" formaction=javascript:alert(document.domain)>Click me</custombutton></customform>',
|
||||
'<customform id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></customform>',
|
||||
'<customform id="xss" action="JaVaScRiPt:alert(document.domain)"><input type=submit value=Submit></customform>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertDontSee('id="xss"', false);
|
||||
$pageView->assertDontSee('action=javascript:', false);
|
||||
$pageView->assertDontSee('action=JaVaScRiPt:', false);
|
||||
$pageView->assertDontSee('formaction=javascript:', false);
|
||||
$pageView->assertDontSee('formaction=JaVaScRiPt:', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_elements_are_removed()
|
||||
{
|
||||
$checks = [
|
||||
'<p>thisisacattofind</p><form>thisdogshouldnotbefound</form>',
|
||||
'<p>thisisacattofind</p><input type="text" value="thisdogshouldnotbefound">',
|
||||
'<p>thisisacattofind</p><select><option>thisdogshouldnotbefound</option></select>',
|
||||
'<p>thisisacattofind</p><textarea>thisdogshouldnotbefound</textarea>',
|
||||
'<p>thisisacattofind</p><fieldset>thisdogshouldnotbefound</fieldset>',
|
||||
'<p>thisisacattofind</p><button>thisdogshouldnotbefound</button>',
|
||||
'<p>thisisacattofind</p><BUTTON>thisdogshouldnotbefound</BUTTON>',
|
||||
<<<'TESTCASE'
|
||||
<svg width="200" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<foreignObject width="100%" height="100%">
|
||||
|
||||
<body xmlns="http://www.w3.org/1999/xhtml">
|
||||
<p>thisisacattofind</p>
|
||||
<form>
|
||||
<p>thisdogshouldnotbefound</p>
|
||||
</form>
|
||||
<input type="text" placeholder="thisdogshouldnotbefound" />
|
||||
<button type="submit">thisdogshouldnotbefound</button>
|
||||
</body>
|
||||
|
||||
</foreignObject>
|
||||
</svg>
|
||||
TESTCASE
|
||||
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertSee('thisisacattofind');
|
||||
$pageView->assertDontSee('thisdogshouldnotbefound');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_attributes_are_removed()
|
||||
{
|
||||
$withinSvgSample = <<<'TESTCASE'
|
||||
<svg width="200" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<foreignObject width="100%" height="100%">
|
||||
|
||||
<body xmlns="http://www.w3.org/1999/xhtml">
|
||||
<p formaction="a">thisisacattofind</p>
|
||||
<p formaction="a">thisisacattofind</p>
|
||||
</body>
|
||||
|
||||
</foreignObject>
|
||||
</svg>
|
||||
TESTCASE;
|
||||
|
||||
$checks = [
|
||||
'formaction' => '<p formaction="a">thisisacattofind</p>',
|
||||
'form' => '<p form="a">thisisacattofind</p>',
|
||||
'formmethod' => '<p formmethod="a">thisisacattofind</p>',
|
||||
'formtarget' => '<p formtarget="a">thisisacattofind</p>',
|
||||
'FORMTARGET' => '<p FORMTARGET="a">thisisacattofind</p>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $attribute => $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertSee('thisisacattofind');
|
||||
$this->withHtml($pageView)->assertElementNotExists(".page-content [{$attribute}]");
|
||||
}
|
||||
|
||||
$page->html = $withinSvgSample;
|
||||
$page->save();
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$html = $this->withHtml($pageView);
|
||||
foreach ($checks as $attribute => $check) {
|
||||
$pageView->assertSee('thisisacattofind');
|
||||
$html->assertElementNotExists(".page-content [{$attribute}]");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_metadata_redirects_are_removed()
|
||||
{
|
||||
$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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '<meta>');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', '</meta>');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'content=');
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'external_url');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_inline_on_attributes_removed_by_default()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
$script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
|
||||
$page->html = "escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertDontSee($script, false);
|
||||
$pageView->assertSee('<p>Hello</p>', false);
|
||||
}
|
||||
|
||||
public function test_more_complex_inline_on_attributes_escaping_scenarios()
|
||||
{
|
||||
$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();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$this->withHtml($pageView)->assertElementNotContains('.page-content', 'onclick');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_content_scripts_show_when_configured()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
config()->set('app.allow_content_scripts', 'true');
|
||||
|
||||
$script = 'abc123<script>console.log("hello-test")</script>abc123';
|
||||
$page->html = "no escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertSee($script, false);
|
||||
$pageView->assertDontSee('abc123abc123');
|
||||
}
|
||||
|
||||
public function test_svg_script_usage_is_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>',
|
||||
'<svg><animate href=#xss attributeName=href values=javascript:alert(1) /></svg>',
|
||||
'<svg><animate href="#xss" attributeName="href" values="a;javascript:alert(1)" /></svg>',
|
||||
'<svg><animate href="#xss" attributeName="href" values="a;data:alert(1)" /></svg>',
|
||||
'<svg><animate href=#xss attributeName=href from=javascript:alert(1) to=1 /><a id=xss><text x=20 y=20>XSS</text></a>',
|
||||
'<svg><set href=#xss attributeName=href from=? to=javascript:alert(1) /><a id=xss><text x=20 y=20>XSS</text></a>',
|
||||
'<svg><g><g><g><animate href=#xss attributeName=href values=javascript:alert(1) /></g></g></g></svg>',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$html = $this->withHtml($pageView);
|
||||
$html->assertElementNotContains('.page-content', 'alert');
|
||||
$html->assertElementNotContains('.page-content', 'xlink:href');
|
||||
$html->assertElementNotContains('.page-content', 'application/xml');
|
||||
$html->assertElementNotContains('.page-content', 'javascript');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_inline_on_attributes_show_if_configured()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
config()->set('app.allow_content_scripts', 'true');
|
||||
|
||||
$script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
|
||||
$page->html = "escape {$script}";
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertSee($script, false);
|
||||
$pageView->assertDontSee('<p>Hello</p>', false);
|
||||
}
|
||||
|
||||
public function test_duplicate_ids_does_not_break_page_render()
|
||||
{
|
||||
$this->asEditor();
|
||||
@@ -649,6 +304,7 @@ TESTCASE;
|
||||
|
||||
public function test_page_markdown_single_html_comment_saving()
|
||||
{
|
||||
config()->set('app.content_filtering', 'jfh');
|
||||
$this->asEditor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
@@ -656,7 +312,7 @@ TESTCASE;
|
||||
$this->put($page->getUrl(), [
|
||||
'name' => $page->name, 'markdown' => $content,
|
||||
'html' => '', 'summary' => '',
|
||||
]);
|
||||
])->assertRedirect();
|
||||
|
||||
$page->refresh();
|
||||
$this->assertStringMatchesFormat($content, $page->html);
|
||||
|
||||
@@ -160,9 +160,11 @@ class PageDraftTest extends TestCase
|
||||
{
|
||||
$this->asAdmin();
|
||||
$page = $this->entities->page();
|
||||
$page->html = '<p>test content<script>hellotherekitty</script></p>';
|
||||
$page->save();
|
||||
|
||||
$this->getJson('/ajax/page/' . $page->id)->assertJson([
|
||||
'html' => $page->html,
|
||||
'html' => '<p>test content</p>',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,4 +265,40 @@ class PageEditorTest extends TestCase
|
||||
$this->assertEquals($test['expected'], $page->refresh()->editor, "Failed asserting global editor {$test['setting']} with request editor {$test['request']} results in {$test['expected']} set for the page");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_editor_html_content_is_filtered_if_loaded_by_a_different_user()
|
||||
{
|
||||
$editor = $this->users->editor();
|
||||
$page = $this->entities->page();
|
||||
$page->html = '<style>hellotherethisisaturtlemonster</style>';
|
||||
$page->updated_by = $editor->id;
|
||||
$page->save();
|
||||
|
||||
$resp = $this->asAdmin()->get($page->getUrl('edit'));
|
||||
$resp->assertOk();
|
||||
$resp->assertDontSee('hellotherethisisaturtlemonster', false);
|
||||
|
||||
$resp = $this->asAdmin()->get("/ajax/page/{$page->id}");
|
||||
$resp->assertOk();
|
||||
$resp->assertDontSee('hellotherethisisaturtlemonster', false);
|
||||
}
|
||||
|
||||
public function test_editor_html_filtered_does_not_cause_error_if_empty()
|
||||
{
|
||||
$emptyExamples = ['', '<p></p>', '<p> </p>', ' ', "\n"];
|
||||
$editor = $this->users->editor();
|
||||
$page = $this->entities->page();
|
||||
$page->updated_by = $editor->id;
|
||||
|
||||
foreach ($emptyExamples as $emptyExample) {
|
||||
$page->html = $emptyExample;
|
||||
$page->save();
|
||||
|
||||
$resp = $this->asAdmin()->get($page->getUrl('edit'));
|
||||
$resp->assertOk();
|
||||
|
||||
$resp = $this->asAdmin()->get("/ajax/page/{$page->id}");
|
||||
$resp->assertOk();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,14 +93,14 @@ class SecurityHeaderTest extends TestCase
|
||||
$this->assertNotEquals($firstHeader, $secondHeader);
|
||||
}
|
||||
|
||||
public function test_allow_content_scripts_settings_controls_csp_script_headers()
|
||||
public function test_content_filtering_config_controls_csp_script_headers()
|
||||
{
|
||||
config()->set('app.allow_content_scripts', true);
|
||||
config()->set('app.content_filtering', '');
|
||||
$resp = $this->get('/');
|
||||
$scriptHeader = $this->getCspHeader($resp, 'script-src');
|
||||
$this->assertEmpty($scriptHeader);
|
||||
|
||||
config()->set('app.allow_content_scripts', false);
|
||||
config()->set('app.content_filtering', 'j');
|
||||
$resp = $this->get('/');
|
||||
$scriptHeader = $this->getCspHeader($resp, 'script-src');
|
||||
$this->assertNotEmpty($scriptHeader);
|
||||
|
||||
@@ -170,6 +170,27 @@ class ConfigTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_content_filtering_defaults_to_enabled()
|
||||
{
|
||||
$this->runWithEnv(['APP_CONTENT_FILTERING' => null, 'ALLOW_CONTENT_SCRIPTS' => null], function () {
|
||||
$this->assertEquals('jhfa', config('app.content_filtering'));
|
||||
});
|
||||
}
|
||||
|
||||
public function test_content_filtering_can_be_disabled()
|
||||
{
|
||||
$this->runWithEnv(['APP_CONTENT_FILTERING' => "", 'ALLOW_CONTENT_SCRIPTS' => null], function () {
|
||||
$this->assertEquals('', config('app.content_filtering'));
|
||||
});
|
||||
}
|
||||
|
||||
public function test_allow_content_scripts_disables_content_filtering()
|
||||
{
|
||||
$this->runWithEnv(['APP_CONTENT_FILTERING' => null, 'ALLOW_CONTENT_SCRIPTS' => 'true'], function () {
|
||||
$this->assertEquals('', config('app.content_filtering'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an environment variable of the given name and value
|
||||
* then check the given config key to see if it matches the given result.
|
||||
|
||||
Reference in New Issue
Block a user