Skip to content

Commit

Permalink
Dom: New allowHostRelativeUrls option
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasbestle committed Nov 6, 2023
1 parent e821214 commit d4d0d22
Show file tree
Hide file tree
Showing 33 changed files with 270 additions and 57 deletions.
51 changes: 39 additions & 12 deletions src/Sane/DomHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @SuppressWarnings(PHPMD.LongVariable)
*/
class DomHandler extends Handler
{
Expand Down Expand Up @@ -43,6 +45,13 @@ class DomHandler extends Handler
*/
public static array|bool $allowedDomains = true;

/**
* Whether URLs that begin with `/` should be allowed even if the
* site index URL is in a subfolder (useful when using the HTML
* `<base>` element where the sanitized code will be rendered)
*/
public static bool $allowHostRelativeUrls = true;

/**
* Names of allowed XML processing instructions
*/
Expand All @@ -57,25 +66,31 @@ class DomHandler extends Handler
/**
* Sanitizes the given string
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*
* @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
*/
public static function sanitize(string $string): string
public static function sanitize(string $string, bool $isExternal = false): string
{
$dom = static::parse($string);
$dom->sanitize(static::options());
$dom->sanitize(static::options($isExternal));
return $dom->toString();
}

/**
* Validates file contents
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*
* @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
*/
public static function validate(string $string): void
public static function validate(string $string, bool $isExternal = false): void
{
$dom = static::parse($string);
$errors = $dom->sanitize(static::options());
$errors = $dom->sanitize(static::options($isExternal));

// there may be multiple errors, we can only throw one of them at a time
if (count($errors) > 0) {
Expand Down Expand Up @@ -119,17 +134,29 @@ public static function validateDoctype(DOMDocumentType $doctype, array $options)
/**
* Returns the sanitization options for the handler
* (to be extended in child classes)
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*/
protected static function options(): array
protected static function options(bool $isExternal): array
{
return [
'allowedDataUris' => static::$allowedDataUris,
'allowedDomains' => static::$allowedDomains,
'allowedPIs' => static::$allowedPIs,
'attrCallback' => [static::class, 'sanitizeAttr'],
'doctypeCallback' => [static::class, 'validateDoctype'],
'elementCallback' => [static::class, 'sanitizeElement'],
$options = [
'allowedDataUris' => static::$allowedDataUris,
'allowedDomains' => static::$allowedDomains,
'allowHostRelativeUrls' => static::$allowHostRelativeUrls,
'allowedPIs' => static::$allowedPIs,
'attrCallback' => [static::class, 'sanitizeAttr'],
'doctypeCallback' => [static::class, 'validateDoctype'],
'elementCallback' => [static::class, 'sanitizeElement'],
];

// never allow host-relative URLs in external files as we
// cannot set a `<base>` element for them when accessed directly
if ($isExternal === true) {
$options['allowHostRelativeUrls'] = false;
}

return $options;
}

/**
Expand Down
14 changes: 10 additions & 4 deletions src/Sane/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ abstract class Handler
{
/**
* Sanitizes the given string
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*/
abstract public static function sanitize(string $string): string;
abstract public static function sanitize(string $string, bool $isExternal = false): string;

/**
* Sanitizes the contents of a file by overwriting
Expand All @@ -34,17 +37,20 @@ abstract public static function sanitize(string $string): string;
public static function sanitizeFile(string $file): void
{
$content = static::readFile($file);
$sanitized = static::sanitize($content);
$sanitized = static::sanitize($content, isExternal: true);
F::write($file, $sanitized);
}

/**
* Validates file contents
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
* @throws \Kirby\Exception\Exception On other errors
*/
abstract public static function validate(string $string): void;
abstract public static function validate(string $string, bool $isExternal = false): void;

/**
* Validates the contents of a file
Expand All @@ -56,7 +62,7 @@ abstract public static function validate(string $string): void;
public static function validateFile(string $file): void
{
$content = static::readFile($file);
static::validate($content);
static::validate($content, isExternal: true);
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/Sane/Html.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,13 @@ class Html extends DomHandler

/**
* Returns the sanitization options for the handler
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*/
protected static function options(): array
protected static function options(bool $isExternal): array
{
return array_merge(parent::options(), [
return array_merge(parent::options($isExternal), [
'allowedAttrPrefixes' => static::$allowedAttrPrefixes,
'allowedAttrs' => static::$allowedAttrs,
'allowedNamespaces' => [],
Expand Down
14 changes: 10 additions & 4 deletions src/Sane/Sane.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,13 @@ public static function handler(
/**
* Sanitizes the given string with the specified handler
* @since 3.6.0
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*/
public static function sanitize(string $string, string $type): string
public static function sanitize(string $string, string $type, bool $isExternal = false): string
{
return static::handler($type)->sanitize($string);
return static::handler($type)->sanitize($string, $isExternal);
}

/**
Expand Down Expand Up @@ -131,13 +134,16 @@ public static function sanitizeFile(
/**
* Validates file contents with the specified handler
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
* @throws \Kirby\Exception\NotFoundException If the handler was not found
* @throws \Kirby\Exception\Exception On other errors
*/
public static function validate(string $string, string $type): void
public static function validate(string $string, string $type, bool $isExternal = false): void
{
static::handler($type)->validate($string);
static::handler($type)->validate($string, $isExternal);
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/Sane/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -465,10 +465,13 @@ public static function validateDoctype(DOMDocumentType $doctype, array $options)

/**
* Returns the sanitization options for the handler
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*/
protected static function options(): array
protected static function options(bool $isExternal): array
{
return array_merge(parent::options(), [
return array_merge(parent::options($isExternal), [
'allowedAttrPrefixes' => static::$allowedAttrPrefixes,
'allowedAttrs' => static::$allowedAttrs,
'allowedNamespaces' => static::$allowedNamespaces,
Expand Down
14 changes: 10 additions & 4 deletions src/Sane/Svgz.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ class Svgz extends Svg
/**
* Sanitizes the given string
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*
* @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed or recompressed
*/
public static function sanitize(string $string): string
public static function sanitize(string $string, bool $isExternal = false): string
{
$string = static::uncompress($string);
$string = parent::sanitize($string);
$string = parent::sanitize($string, $isExternal);
$string = @gzencode($string);

if (is_string($string) !== true) {
Expand All @@ -37,13 +40,16 @@ public static function sanitize(string $string): string
/**
* Validates file contents
*
* @param bool $isExternal Whether the string is from an external file
* that may be accessed directly
*
* @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
*/
public static function validate(string $string): void
public static function validate(string $string, bool $isExternal = false): void
{
$string = static::uncompress($string);
parent::validate($string);
parent::validate($string, $isExternal);
}

/**
Expand Down
30 changes: 17 additions & 13 deletions src/Toolkit/Dom.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ public static function isAllowedUrl(

// allow site-internal URLs that didn't match the
// protocol-relative check above
if (mb_substr($url, 0, 1) === '/') {
if (mb_substr($url, 0, 1) === '/' && $options['allowHostRelativeUrls'] !== true) {
// if a CMS instance is active, only allow the URL
// if it doesn't point outside of the index URL
if ($kirby = App::instance(null, true)) {
Expand Down Expand Up @@ -525,6 +525,9 @@ public function query(
* or `true` for any
* - `allowedDomains`: Allowed hostnames for HTTP(S) URLs in `urlAttrs`
* and inside `url()` wrappers or `true` for any
* - `allowHostRelativeUrls`: Whether URLs that begin with `/` should be
* allowed even if the site index URL is in a subfolder (useful when using
* the HTML `<base>` element where the sanitized code will be rendered)
* - `allowedNamespaces`: Associative array of all allowed namespace URIs;
* the array keys are reference names that can be referred to from the
* `allowedAttrPrefixes`, `allowedAttrs`, `allowedTags`, `disallowedTags`
Expand Down Expand Up @@ -708,18 +711,19 @@ protected static function normalizeSanitizeOptions(array $options): array
}

$options = array_merge([
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
'allowedDomains' => true,
'allowedNamespaces' => true,
'allowedPIs' => true,
'allowedTags' => true,
'attrCallback' => null,
'disallowedTags' => [],
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
'allowedDomains' => true,
'allowHostRelativeUrls' => true,
'allowedNamespaces' => true,
'allowedPIs' => true,
'allowedTags' => true,
'attrCallback' => null,
'disallowedTags' => [],
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
], $options);

$options['_normalized'] = true;
Expand Down
13 changes: 13 additions & 0 deletions tests/Sane/DomHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@ public function testSanitize()
$fixture = '<?xml version="1.0" standalone="no"?><xml><test>Hello world</test></xml>';
$sanitized = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<xml><test>Hello world</test></xml>";
$this->assertSame($sanitized, DomHandler::sanitize($fixture));

$string = '<xml><a xlink:href="/another-folder">Very malicious</a></xml>';
$this->assertSame($string, DomHandler::sanitize($string)); // not external by default
$this->assertSame('<xml><a>Very malicious</a></xml>', DomHandler::sanitize($string, isExternal: true));
}

public function testValidate()
{
$this->assertNull(DomHandler::validate('<!DOCTYPE xml><xml><test attr="value">Hello world</test></xml>'));
$this->assertNull(DomHandler::validate('<xml><a xlink:href="/another-folder">Very malicious</a></xml>'));
}

public function testValidateException1()
Expand All @@ -47,4 +52,12 @@ public function testValidateException2()

DomHandler::validate("<!DOCTYPE xml SYSTEM \"https://malicious.com/something.dtd\">\n<xml>\n<a href='javascript:alert(1)'></a>\n</xml>");
}

public function testValidateException3()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The URL points outside of the site index URL');

DomHandler::validate('<xml><a xlink:href="/another-folder">Very malicious</a></xml>', isExternal: true);
}
}
17 changes: 17 additions & 0 deletions tests/Sane/HandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public function testSanitizeFile()
$tmp = $this->fixture('external-source-1.svg', true);
$this->assertNull(CustomHandler::sanitizeFile($tmp));
$this->assertFileEquals($expected, $tmp);

$expected = $this->fixture('xlink-subfolder.sanitized.svg');
$tmp = $this->fixture('xlink-subfolder.svg', true);
$this->assertNull(CustomHandler::sanitizeFile($tmp));
$this->assertFileEquals($expected, $tmp);
}

/**
Expand Down Expand Up @@ -65,6 +70,18 @@ public function testValidateFileError()
CustomHandler::validateFile($this->fixture('external-source-1.svg'));
}

/**
* @covers ::validateFile
* @covers ::readFile
*/
public function testValidateFileErrorExternalFile()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The URL points outside of the site index URL');

CustomHandler::validateFile($this->fixture('xlink-subfolder.svg'));
}

/**
* @covers ::validateFile
* @covers ::readFile
Expand Down
15 changes: 15 additions & 0 deletions tests/Sane/HtmlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Kirby\Sane;

use Kirby\Exception\InvalidArgumentException;

/**
* @covers \Kirby\Sane\Html
* @todo Add more tests from DOMPurify and the other test classes
Expand All @@ -27,4 +29,17 @@ public function allowedProvider()
{
return $this->fixtureList('allowed', 'html');
}

public function testDisallowedExternalFile()
{
$fixture = $this->fixture('disallowed/link-subfolder.html');
$sanitized = $this->fixture('sanitized/link-subfolder.html');

$this->assertStringEqualsFile($fixture, Html::sanitize(file_get_contents($fixture)));
$this->assertStringEqualsFile($sanitized, Html::sanitize(file_get_contents($fixture), isExternal: true));

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The URL points outside of the site index URL');
Html::validateFile($fixture);
}
}
Loading

0 comments on commit d4d0d22

Please sign in to comment.