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:
Dan Brown
2026-07-27 12:12:07 +01:00
parent 0ada5d2dc9
commit cb195999eb
4 changed files with 270 additions and 10 deletions
@@ -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;
}
}
+193 -8
View File
@@ -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 '';
}
}
+29
View File
@@ -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 = [