mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-10-12 02:01:37 +00:00
Content Filter: Updated srcset and URI handling
- Added more complex srcset parsing and URI handling via custom built HTMLPurifier filter, which I've also provided upstream. - Added a custom URI filter to force require URI schemes.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Util\HtmlPurifier;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Util\HtmlPurifier\Filters\UriEnsureScheme;
|
||||
use BookStack\Util\HtmlPurifier\Filters\UriLimitFileProtocolToAnchors;
|
||||
use BookStack\Util\UrlFilter;
|
||||
use HTMLPurifier;
|
||||
@@ -84,14 +85,18 @@ class ConfiguredHtmlPurifier
|
||||
$config->set('Attr.EnableID', true);
|
||||
$config->set('Attr.ID.HTML5', true);
|
||||
$config->set('Output.FixInnerHTML', false);
|
||||
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
|
||||
|
||||
$allowedSchemes = UrlFilter::getAllowedSchemes();
|
||||
$allowedSchemesSetting = [];
|
||||
foreach ($allowedSchemes as $scheme) {
|
||||
$allowedSchemesSetting[$scheme] = true;
|
||||
}
|
||||
$defaultScheme = str_starts_with(url('/'), 'http:') ? 'http' : 'https';
|
||||
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
|
||||
$config->set('URI.AllowedSchemes', $allowedSchemesSetting);
|
||||
$config->set('URI.MakeAbsolute', true);
|
||||
$config->set('URI.DefaultScheme', $defaultScheme);
|
||||
$config->set('URI.Base', url('/'));
|
||||
|
||||
// $config->set('Cache.DefinitionImpl', null); // Disable cache during testing
|
||||
}
|
||||
@@ -155,7 +160,8 @@ class ConfiguredHtmlPurifier
|
||||
// Allow mention-ids on links
|
||||
$definition->addAttribute('a', 'data-mention-user-id', 'Number');
|
||||
|
||||
// Set up custom handler for srcset to limit accepted types
|
||||
// Set up custom handler for srcset
|
||||
// To remove once added upstream: https://github.com/xemlock/htmlpurifier-html5/pull/91
|
||||
$definition->addAttribute('img', 'srcset', new SrcsetAttrDef());
|
||||
$definition->addAttribute('source', 'srcset', new SrcsetAttrDef());
|
||||
}
|
||||
@@ -163,6 +169,7 @@ class ConfiguredHtmlPurifier
|
||||
protected function configureUriDefinition(HTMLPurifier_URIDefinition $definition): void
|
||||
{
|
||||
$definition->registerFilter(new UriLimitFileProtocolToAnchors());
|
||||
$definition->registerFilter(new UriEnsureScheme());
|
||||
}
|
||||
|
||||
public function purify(string $html): string
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util\HtmlPurifier\Filters;
|
||||
|
||||
use HTMLPurifier_Config;
|
||||
use HTMLPurifier_Context;
|
||||
use HTMLPurifier_URI;
|
||||
use HTMLPurifier_URIFilter;
|
||||
|
||||
class UriEnsureScheme extends HTMLPurifier_URIFilter
|
||||
{
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
public $name = 'EnsureScheme';
|
||||
|
||||
/**
|
||||
* @type bool
|
||||
*/
|
||||
public $always_load = true;
|
||||
|
||||
/**
|
||||
* @param HTMLPurifier_URI $uri
|
||||
* @param HTMLPurifier_Config $config
|
||||
* @param HTMLPurifier_Context $context
|
||||
* @return bool
|
||||
*/
|
||||
public function filter(&$uri, $config, $context): bool
|
||||
{
|
||||
$def = $config->getDefinition('URI');
|
||||
$defaultScheme = $def->defaultScheme ?? '';
|
||||
|
||||
if (empty($uri->scheme) && $defaultScheme) {
|
||||
$uri->scheme = $defaultScheme;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,209 @@
|
||||
namespace BookStack\Util\HtmlPurifier;
|
||||
|
||||
use HTMLPurifier_AttrDef;
|
||||
use HTMLPurifier_AttrDef_URI;
|
||||
|
||||
/**
|
||||
* Custom attribute definition to filter out potentially dangerous
|
||||
* values from the srcset attribute.
|
||||
* A custom attribute definition for handling Srcset attributes.
|
||||
* Has been provided upstream via:
|
||||
* https://github.com/xemlock/htmlpurifier-html5/pull/91
|
||||
* but awaiting review/merge.
|
||||
*/
|
||||
class SrcsetAttrDef extends HTMLPurifier_AttrDef
|
||||
{
|
||||
public function validate($string, $config, $context)
|
||||
{
|
||||
$lower = strtolower($string);
|
||||
$nonAllowed = ['javascript:', 'vbscript:', 'data:', 'file:'];
|
||||
$sources = $this->parseImageSources($string);
|
||||
if (empty($sources)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($nonAllowed as $nonAllowedString) {
|
||||
if (str_contains($lower, $nonAllowedString)) {
|
||||
return false;
|
||||
$uriFilter = new HTMLPurifier_AttrDef_URI(true);
|
||||
|
||||
$filtered = array();
|
||||
foreach ($sources as $source) {
|
||||
$uri = $source['uri'];
|
||||
$descriptor = $source['descriptor'];
|
||||
$validatedUri = $uriFilter->validate($uri, $config, $context);
|
||||
if (is_string($validatedUri)) {
|
||||
if ($descriptor) {
|
||||
$filtered[] = $validatedUri . ' ' . $source['descriptor'];
|
||||
} else {
|
||||
$filtered[] = $validatedUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $string;
|
||||
if (empty($filtered)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return implode(', ', $filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the image source from srcset attribute text.
|
||||
* Returns false if it's found to be invalid, otherwise
|
||||
* returns an array of uri and descriptor combinations.
|
||||
*
|
||||
* This aims to follow the WHATWG parsing spec as per:
|
||||
* https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute
|
||||
*
|
||||
* @param string $string
|
||||
* @return array{uri: string, descriptor: string}[]|false
|
||||
*/
|
||||
private function parseImageSources($string)
|
||||
{
|
||||
$imageSources = array();
|
||||
$asciiWhitespace = " \n\r\t\f";
|
||||
$asciiWhiteSpaceComma = $asciiWhitespace . ',';
|
||||
$input = trim($string, $asciiWhiteSpaceComma);
|
||||
|
||||
if ($input === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
$position = 0;
|
||||
while ($position < strlen($input)) {
|
||||
$position += strspn($input, $asciiWhitespace, $position);
|
||||
$urlEnd = $position + strcspn($input, $asciiWhitespace, $position);
|
||||
$url = substr($input, $position, $urlEnd - $position);
|
||||
$position = $urlEnd;
|
||||
$descriptors = array();
|
||||
|
||||
if (strpos($url, ',') === strlen($url) - 1) {
|
||||
$url = rtrim($url, ',');
|
||||
} else {
|
||||
$position += strspn($input, $asciiWhitespace, $position);
|
||||
$currentDescriptor = '';
|
||||
$state = 'in_descriptor';
|
||||
while (true) {
|
||||
if ($position < strlen($input)) {
|
||||
$c = $input[$position];
|
||||
} else {
|
||||
$c = null;
|
||||
}
|
||||
|
||||
if ($state === 'in_descriptor') {
|
||||
if ($c !== null && strpos($asciiWhitespace, $c) !== false) {
|
||||
if ($currentDescriptor !== '') {
|
||||
$descriptors[] = $currentDescriptor;
|
||||
}
|
||||
$state = 'after_descriptor';
|
||||
} else if ($c === ',') {
|
||||
$position++;
|
||||
if ($currentDescriptor !== '') {
|
||||
$descriptors[] = $currentDescriptor;
|
||||
}
|
||||
break;
|
||||
} else if ($c === '(') {
|
||||
$currentDescriptor .= $c;
|
||||
$state = 'in_parens';
|
||||
} else if ($c === null) {
|
||||
if ($currentDescriptor !== '') {
|
||||
$descriptors[] = $currentDescriptor;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
$currentDescriptor .= $c;
|
||||
}
|
||||
} else if ($state === 'in_parens') {
|
||||
if ($c === ')') {
|
||||
$currentDescriptor .= $c;
|
||||
$state = 'in_descriptor';
|
||||
} else if ($c === null) {
|
||||
$descriptors[] = $currentDescriptor;
|
||||
break;
|
||||
} else {
|
||||
$currentDescriptor .= $c;
|
||||
}
|
||||
} else if ($state === 'after_descriptor') {
|
||||
if ($c !== null && strpos($asciiWhitespace, $c) !== false) {
|
||||
// Stay in this state
|
||||
} else if ($c === null) {
|
||||
break;
|
||||
} else {
|
||||
$state = 'in_descriptor';
|
||||
$position--;
|
||||
}
|
||||
}
|
||||
|
||||
$position++;
|
||||
}
|
||||
}
|
||||
|
||||
$descriptor = $this->formatDescriptor($descriptors);
|
||||
|
||||
if ($url && $descriptor !== false) {
|
||||
$imageSources[] = array(
|
||||
'uri' => $url,
|
||||
'descriptor' => $descriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $imageSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and format a single descriptor from an array of potential
|
||||
* descriptor strings. Returns empty if valid but no descriptor.
|
||||
* Returns false if invalid.
|
||||
* @param string[] $descriptors
|
||||
* @return false|string
|
||||
*/
|
||||
private function formatDescriptor(array $descriptors)
|
||||
{
|
||||
$error = false;
|
||||
$width = '';
|
||||
$density = '';
|
||||
$futureCompatH = '';
|
||||
|
||||
foreach ($descriptors as $descriptor) {
|
||||
$descriptor = trim($descriptor);
|
||||
if ($descriptor === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unit = $descriptor[strlen($descriptor) - 1];
|
||||
$number = trim(substr($descriptor, 0, -1));
|
||||
|
||||
if ($unit === 'w' && filter_var($number, FILTER_VALIDATE_INT) && intval($number) >= 0) {
|
||||
if (!empty($width) || !empty($density) || intval($number) === 0) {
|
||||
$error = true;
|
||||
}
|
||||
$width = $number;
|
||||
} else if ($unit === 'x' && filter_var($number, FILTER_VALIDATE_FLOAT)) {
|
||||
if (!empty($width) || !empty($density) || !empty($futureCompatH) || floatval($number) < 0) {
|
||||
$error = true;
|
||||
}
|
||||
$density = $number;
|
||||
} else if ($unit === 'h' && filter_var($number, FILTER_VALIDATE_INT) && intval($number) >= 0) {
|
||||
if (!empty($futureCompatH) || !empty($density)) {
|
||||
$error = true;
|
||||
}
|
||||
$futureCompatH = $number;
|
||||
} else {
|
||||
$error = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($futureCompatH) && empty($width)) {
|
||||
$error = true;
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($width) {
|
||||
return $width . 'w';
|
||||
}
|
||||
|
||||
if ($density) {
|
||||
return $density . 'x';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +488,35 @@ HTML;
|
||||
}
|
||||
}
|
||||
|
||||
public function test_media_protocol_relative_urls_are_given_scheme_depending_on_app_url()
|
||||
{
|
||||
$testCasesExpectedByInput = [
|
||||
'<div id="bkmrk-file-img"><img src="//link/to/file" alt="My local image"></div>' => '<div id="bkmrk-file-img"><img src="SCHEME://link/to/file" alt="My local image"></div>',
|
||||
'<iframe src="//link/to/file" id="bkmrk-file-iframe"></iframe>' => '<iframe src="SCHEME://link/to/file" id="bkmrk-file-iframe"></iframe>',
|
||||
'<embed src="//link/to/file" id="bkmrk-file-embed">' => '<embed src="SCHEME://link/to/file" id="bkmrk-file-embed">',
|
||||
'<object data="//link/to/file" id="bkmrk-file-object"></object>' => '<object data="SCHEME://link/to/file" id="bkmrk-file-object"></object>',
|
||||
'<div id="bkmrk-file-img"><img srcset="//link/to/file 2x, //link-to-another 4x" src="//link/to/a/file" alt="My local image"></div>' => '<div id="bkmrk-file-img"><img srcset="SCHEME://link/to/file 2x, SCHEME://link-to-another 4x" src="SCHEME://link/to/a/file" alt="My local image"></div>',
|
||||
];
|
||||
|
||||
$baseUrls = ['https://example.com' => 'https', 'http://example.com' => 'http'];
|
||||
foreach ($baseUrls as $baseUrl => $expectedScheme) {
|
||||
$this->runWithEnv(['APP_URL' => $baseUrl], function () use ($expectedScheme, $baseUrl, $testCasesExpectedByInput) {
|
||||
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(str_replace('SCHEME', $expectedScheme, $expected), false);
|
||||
$resp->assertDontSee($input, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function test_allow_list_does_not_filter_cases()
|
||||
{
|
||||
$testCasesExpectedByInput = [
|
||||
|
||||
Reference in New Issue
Block a user