Initial commit

This commit is contained in:
Paul Nicoué 2021-10-29 18:05:46 +02:00
commit 1ff19bf38f
830 changed files with 159212 additions and 0 deletions

View file

@ -0,0 +1,51 @@
<?php
namespace Kirby\Sane;
use Kirby\Exception\Exception;
use Kirby\Toolkit\F;
/**
* Base handler abstract,
* which needs to be extended to
* create valid sane handlers
* @since 3.5.4
*
* @package Kirby Sane
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
abstract class Handler
{
/**
* Validates file contents
*
* @param string $string
* @return void
*
* @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;
/**
* Validates the contents of a file
*
* @param string $file
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
* @throws \Kirby\Exception\Exception On other errors
*/
public static function validateFile(string $file): void
{
$contents = F::read($file);
if ($contents === false) {
throw new Exception('The file "' . $file . '" does not exist');
}
static::validate($contents);
}
}

129
kirby/src/Sane/Sane.php Normal file
View file

@ -0,0 +1,129 @@
<?php
namespace Kirby\Sane;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\F;
/**
* The `Sane` class validates that files
* don't contain potentially harmful contents.
* The class comes with handlers for `svg`, `svgz` and `xml`
* files for now, but can be extended and customized.
* @since 3.5.4
*
* @package Kirby Sane
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Sane
{
/**
* Handler Type Aliases
*
* @var array
*/
public static $aliases = [
'image/svg+xml' => 'svg',
'application/xml' => 'xml',
'text/xml' => 'xml',
];
/**
* All registered handlers
*
* @var array
*/
public static $handlers = [
'svg' => 'Kirby\Sane\Svg',
'svgz' => 'Kirby\Sane\Svgz',
'xml' => 'Kirby\Sane\Xml',
];
/**
* Handler getter
*
* @param string $type
* @param bool $lazy If set to `true`, `null` is returned for undefined handlers
* @return \Kirby\Sane\Handler|null
*
* @throws \Kirby\Exception\NotFoundException If no handler was found and `$lazy` was set to `false`
*/
public static function handler(string $type, bool $lazy = false)
{
// normalize the type
$type = mb_strtolower($type);
// find a handler or alias
$handler = static::$handlers[$type] ??
static::$handlers[static::$aliases[$type] ?? null] ??
null;
if (empty($handler) === false && class_exists($handler) === true) {
return new $handler();
}
if ($lazy === true) {
return null;
}
throw new NotFoundException('Missing handler for type: "' . $type . '"');
}
/**
* Validates file contents with the specified handler
*
* @param mixed $string
* @param string $type
* @return void
*
* @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
{
static::handler($type)->validate($string);
}
/**
* Validates the contents of a file;
* the sane handlers are automatically chosen by
* the extension and MIME type if not specified
*
* @param string $file
* @param string|bool $typeLazy Explicit handler type string,
* `true` for lazy autodetection or
* `false` for normal autodetection
* @return void
*
* @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 validateFile(string $file, $typeLazy = false): void
{
if (is_string($typeLazy) === true) {
static::handler($typeLazy)->validateFile($file);
return;
}
$options = [F::extension($file), F::mime($file)];
// execute all handlers, but each class only once for performance;
// filter out all empty options
$usedHandlers = [];
foreach (array_filter($options) as $option) {
$handler = static::handler($option, $typeLazy === true);
$handlerClass = $handler ? get_class($handler) : null;
if ($handler && in_array($handlerClass, $usedHandlers) === false) {
$handler->validateFile($file);
$usedHandlers[] = $handlerClass;
}
}
}
}

486
kirby/src/Sane/Svg.php Normal file
View file

@ -0,0 +1,486 @@
<?php
namespace Kirby\Sane;
use DOMDocumentType;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
/**
* Sane handler for SVG files
* @since 3.5.4
*
* @package Kirby Sane
* @author Bastian Allgeier <bastian@getkirby.com>,
* Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Svg extends Xml
{
/**
* Allow and block lists are inspired by DOMPurify
*
* @link https://github.com/cure53/DOMPurify
* @copyright 2015 Mario Heiderich
* @license https://www.apache.org/licenses/LICENSE-2.0
*/
public static $allowedAttributes = [
'accent-height',
'accumulate',
'additive',
'alignment-baseline',
'ascent',
'attributeName',
'attributeType',
'azimuth',
'baseFrequency',
'baseline-shift',
'begin',
'bias',
'by',
'class',
'clip',
'clipPathUnits',
'clip-path',
'clip-rule',
'color',
'color-interpolation',
'color-interpolation-filters',
'color-profile',
'color-rendering',
'cx',
'cy',
'd',
'dx',
'dy',
'diffuseConstant',
'direction',
'display',
'divisor',
'dur',
'edgeMode',
'elevation',
'end',
'fill',
'fill-opacity',
'fill-rule',
'filter',
'filterUnits',
'flood-color',
'flood-opacity',
'font-family',
'font-size',
'font-size-adjust',
'font-stretch',
'font-style',
'font-variant',
'font-weight',
'fx',
'fy',
'g1',
'g2',
'glyph-name',
'glyphRef',
'gradientUnits',
'gradientTransform',
'height',
'href',
'id',
'image-rendering',
'in',
'in2',
'k',
'k1',
'k2',
'k3',
'k4',
'kerning',
'keyPoints',
'keySplines',
'keyTimes',
'lang',
'lengthAdjust',
'letter-spacing',
'kernelMatrix',
'kernelUnitLength',
'lighting-color',
'local',
'marker-end',
'marker-mid',
'marker-start',
'markerHeight',
'markerUnits',
'markerWidth',
'maskContentUnits',
'maskUnits',
'max',
'mask',
'media',
'method',
'mode',
'min',
'name',
'numOctaves',
'offset',
'operator',
'opacity',
'order',
'orient',
'orientation',
'origin',
'overflow',
'paint-order',
'path',
'pathLength',
'patternContentUnits',
'patternTransform',
'patternUnits',
'points',
'preserveAlpha',
'preserveAspectRatio',
'primitiveUnits',
'r',
'rx',
'ry',
'radius',
'refX',
'refY',
'repeatCount',
'repeatDur',
'restart',
'result',
'rotate',
'scale',
'seed',
'shape-rendering',
'specularConstant',
'specularExponent',
'spreadMethod',
'startOffset',
'stdDeviation',
'stitchTiles',
'stop-color',
'stop-opacity',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-linecap',
'stroke-linejoin',
'stroke-miterlimit',
'stroke-opacity',
'stroke',
'stroke-width',
'style',
'surfaceScale',
'systemLanguage',
'tabindex',
'targetX',
'targetY',
'transform',
'text-anchor',
'text-decoration',
'text-rendering',
'textLength',
'type',
'u1',
'u2',
'unicode',
'values',
'viewBox',
'visibility',
'version',
'vert-adv-y',
'vert-origin-x',
'vert-origin-y',
'width',
'word-spacing',
'wrap',
'writing-mode',
'xChannelSelector',
'yChannelSelector',
'x',
'x1',
'x2',
'xlink:href',
'y',
'y1',
'y2',
'z',
'zoomAndPan',
];
public static $allowedElements = [
'svg',
'a',
'altGlyph',
'altGlyphDef',
'altGlyphItem',
'animateColor',
'animateMotion',
'animateTransform',
'circle',
'clipPath',
'defs',
'desc',
'ellipse',
'filter',
'font',
'g',
'glyph',
'glyphRef',
'hkern',
'image',
'line',
'linearGradient',
'marker',
'mask',
'metadata',
'mpath',
'path',
'pattern',
'polygon',
'polyline',
'radialGradient',
'rect',
'stop',
'style',
'switch',
'symbol',
'text',
'textPath',
'title',
'tref',
'tspan',
'use',
'view',
'vkern',
];
public static $allowedFilters = [
'feBlend',
'feColorMatrix',
'feComponentTransfer',
'feComposite',
'feConvolveMatrix',
'feDiffuseLighting',
'feDisplacementMap',
'feDistantLight',
'feFlood',
'feFuncA',
'feFuncB',
'feFuncG',
'feFuncR',
'feGaussianBlur',
'feMerge',
'feMergeNode',
'feMorphology',
'feOffset',
'fePointLight',
'feSpecularLighting',
'feSpotLight',
'feTile',
'feTurbulence',
];
public static $allowedNamespaces = [
'xmlns' => 'http://www.w3.org/2000/svg',
'xmlns:svg' => 'http://www.w3.org/2000/svg',
'xmlns:xlink' => 'http://www.w3.org/1999/xlink'
];
/**
* IMPORTANT: Use lower-case names here because
* of the case-insensitive matching
*/
public static $disallowedElements = [
'animate',
'color-profile',
'cursor',
'discard',
'fedropshadow',
'feimage',
'font-face',
'font-face-format',
'font-face-name',
'font-face-src',
'font-face-uri',
'foreignobject',
'hatch',
'hatchpath',
'mesh',
'meshgradient',
'meshpatch',
'meshrow',
'missing-glyph',
'script',
'set',
'solidcolor',
'unknown',
];
/**
* Validates file contents
*
* @param string $string
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
*/
public static function validate(string $string): void
{
$svg = static::parse($string);
$rootName = $svg->documentElement->nodeName;
if ($rootName !== 'svg') {
throw new InvalidArgumentException('The file is not a SVG (got <' . $rootName . '>)');
}
parent::validateDom($svg);
}
/**
* Validates the attributes of an element
*
* @param \DOMXPath $xPath
* @param \DOMNode $element
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If any of the attributes is not valid
*/
protected static function validateAttrs(DOMXPath $xPath, DOMNode $element): void
{
$elementName = $element->nodeName;
foreach ($element->attributes ?? [] as $attr) {
$attrName = $attr->nodeName;
$attrValue = $attr->nodeValue;
// allow all aria and data attributes
$beginning = mb_substr($attrName, 0, 5);
if ($beginning === 'aria-' || $beginning === 'data-') {
continue;
}
if (in_array($attrName, static::$allowedAttributes) !== true) {
throw new InvalidArgumentException(
'The "' . $attrName . '" attribute (line ' .
$attr->getLineNo() . ') is not allowed in SVGs'
);
}
// block nested <use> elements ("Billion Laughs" DoS attack)
if (
$elementName === 'use' &&
Str::contains($attrName, 'href') !== false &&
Str::startsWith($attrValue, '#') === true
) {
// find the target (used element)
$id = str_replace('"', '', mb_substr($attrValue, 1));
$target = $xPath->query('//*[@id="' . $id . '"]')->item(0);
// the target must not contain any other <use> elements
if (
is_a($target, 'DOMElement') === true &&
$target->getElementsByTagName('use')->count() > 0
) {
throw new InvalidArgumentException(
'Nested "use" elements are not allowed in SVGs (used in line ' .
$element->getLineNo() . ')'
);
}
}
}
// validate `xmlns` attributes as well, which can only
// be properly extracted using SimpleXML
if (is_a($element, 'DOMElement') === true) {
$simpleXmlElement = simplexml_import_dom($element);
foreach ($simpleXmlElement->getDocNamespaces(false, false) as $namespace => $value) {
$namespace = 'xmlns' . ($namespace ? ':' . $namespace : '');
// check if the namespace is allowlisted
if (
isset(static::$allowedNamespaces[$namespace]) !== true ||
static::$allowedNamespaces[$namespace] !== $value
) {
throw new InvalidArgumentException(
'The namespace "' . $namespace . '" (around line ' .
$element->getLineNo() . ') is not allowed or has an invalid value'
);
}
}
}
parent::validateAttrs($xPath, $element);
}
/**
* Validates the doctype if present
*
* @param \DOMDocumentType $doctype
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
*/
protected static function validateDoctype(DOMDocumentType $doctype): void
{
if (mb_strtolower($doctype->name) !== 'svg') {
throw new InvalidArgumentException('Invalid doctype');
}
parent::validateDoctype($doctype);
}
/**
* Validates all given DOM elements and their attributes
*
* @param \DOMXPath $xPath
* @param \DOMNodeList $elements
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If any of the elements is not valid
*/
protected static function validateElements(DOMXPath $xPath, DOMNodeList $elements): void
{
$allowedElements = array_merge(static::$allowedElements, static::$allowedFilters);
foreach ($elements as $element) {
$elementName = $element->nodeName;
$elementNameLower = mb_strtolower($elementName);
// check for block-listed elements
if (in_array($elementNameLower, static::$disallowedElements) === true) {
throw new InvalidArgumentException(
'The "' . $elementName . '" element (line ' .
$element->getLineNo() . ') is not allowed in SVGs'
);
}
// check for allow-listed elements
if (in_array($elementName, $allowedElements) === false) {
throw new InvalidArgumentException(
'The "' . $elementName . '" element (line ' .
$element->getLineNo() . ') is not allowed in SVGs'
);
}
// check for URLs inside <style> elements
if ($elementName === 'style') {
foreach (static::extractUrls($element->textContent) as $url) {
if (static::isAllowedUrl($url) !== true) {
throw new InvalidArgumentException(
'The URL is not allowed in the <style> element' .
' (around line ' . $element->getLineNo() . ')'
);
}
}
}
}
parent::validateElements($xPath, $elements);
}
}

39
kirby/src/Sane/Svgz.php Normal file
View file

@ -0,0 +1,39 @@
<?php
namespace Kirby\Sane;
use Kirby\Exception\InvalidArgumentException;
/**
* Sane handler for gzip-compressed SVGZ files
* @since 3.5.4
*
* @package Kirby Sane
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Svgz extends Svg
{
/**
* Validates file contents
*
* @param string $string
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
*/
public static function validate(string $string): void
{
// only support uncompressed files up to 10 MB to
// prevent gzip bombs from crashing the process
$uncompressed = @gzdecode($string, 10000000);
if (is_string($uncompressed) !== true) {
throw new InvalidArgumentException('Could not uncompress gzip data');
}
parent::validate($uncompressed);
}
}

339
kirby/src/Sane/Xml.php Normal file
View file

@ -0,0 +1,339 @@
<?php
namespace Kirby\Sane;
use DOMDocument;
use DOMDocumentType;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
/**
* Sane handler for XML files
* @since 3.5.4
*
* @package Kirby Sane
* @author Bastian Allgeier <bastian@getkirby.com>,
* Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Xml extends Handler
{
public static $allowedDataAttrs = [
'data:image/png',
'data:image/gif',
'data:image/jpg',
'data:image/jpe',
'data:image/pjp',
'data:img/png',
'data:img/gif',
'data:img/jpg',
'data:img/jpe',
'data:img/pjp',
];
public static $allowedDomains = [];
public static $allowedPIs = [];
/**
* Validates file contents
*
* @param string $string
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
*/
public static function validate(string $string): void
{
$xml = static::parse($string);
static::validateDom($xml);
}
/**
* Extracts all URLs wrapped in a url() wrapper. E.g. for style attributes.
*
* @param string $value
* @return array
*/
protected static function extractUrls(string $value): array
{
$count = preg_match_all(
'!url\(\s*[\'"]?(.*?)[\'"]?\s*\)!i',
static::trim($value),
$matches,
PREG_PATTERN_ORDER
);
if (is_int($count) === true && $count > 0) {
return $matches[1];
}
return [];
}
/**
* Checks if the URL is acceptable for href attributes
*
* @param string $url
* @return bool
*/
protected static function isAllowedUrl(string $url): bool
{
$url = mb_strtolower($url);
// allow empty URL values
if (empty($url) === true) {
return true;
}
// allow URLs that point to fragments inside the file
// as well as site-internal URLs
if (in_array(mb_substr($url, 0, 1), ['#', '/']) === true) {
return true;
}
// allow specific HTTP(S) URLs
if (
Str::startsWith($url, 'http://') === true ||
Str::startsWith($url, 'https://') === true
) {
$hostname = parse_url($url, PHP_URL_HOST);
if (in_array($hostname, static::$allowedDomains) === true) {
return true;
}
}
// allow listed data URIs
foreach (static::$allowedDataAttrs as $dataAttr) {
if (Str::startsWith($url, $dataAttr) === true) {
return true;
}
}
return false;
}
/**
* Tries to parse an XML string
*
* @param string $string
* @return \DOMDocument
*
* @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
*/
protected static function parse(string $string)
{
$xml = new DOMDocument();
$xml->preserveWhiteSpace = false;
$xml->strictErrorChecking = false;
$loaderSetting = null;
if (\PHP_VERSION_ID < 80000) {
// prevent loading external entities to protect against XXE attacks;
// only needed for PHP versions before 8.0 (the function was deprecated
// as the disabled state is the new default in PHP 8.0+)
$loaderSetting = libxml_disable_entity_loader(true);
}
// switch to "user error handling"
$intErrorsSetting = libxml_use_internal_errors(true);
$load = $xml->loadXML($string);
if (\PHP_VERSION_ID < 80000) {
// ensure that we don't alter global state by
// resetting the original value
libxml_disable_entity_loader($loaderSetting);
}
// get one error for use below and reset the global state
$error = libxml_get_last_error();
libxml_clear_errors();
libxml_use_internal_errors($intErrorsSetting);
if ($load !== true) {
$message = 'The file could not be parsed';
if ($error !== false) {
$message .= ': ' . $error->message;
}
throw new InvalidArgumentException([
'fallback' => $message,
'details' => compact('error')
]);
}
return $xml;
}
/**
* Removes invisible ASCII characters from the value
*
* @param string $value
* @return string
*/
protected static function trim(string $value): string
{
return trim(preg_replace('/[^ -~]/u', '', $value));
}
/**
* Validates the attributes of an element
*
* @param \DOMXPath $xPath
* @param \DOMNode $element
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If any of the attributes is not valid
*/
protected static function validateAttrs(DOMXPath $xPath, DOMNode $element): void
{
$elementName = $element->nodeName;
foreach ($element->attributes ?? [] as $attr) {
$attrName = $attr->nodeName;
$attrValue = $attr->nodeValue;
if (Str::contains($attrName, 'href') !== false) {
if (static::isAllowedUrl($attrValue) !== true) {
throw new InvalidArgumentException(
'The URL is not allowed in attribute: ' . $attrName .
' (line ' . $attr->getLineNo() . ')'
);
}
} else {
// check for unwanted URLs in other attributes
foreach (static::extractUrls($attrValue) as $url) {
if (static::isAllowedUrl($url) !== true) {
throw new InvalidArgumentException(
'The URL is not allowed in attribute: ' . $attrName .
' (line ' . $attr->getLineNo() . ')'
);
}
}
}
}
// if we are validating an XML file, block
// all SVG and HTML namespaces
if (static::class === self::class && is_a($element, 'DOMElement') === true) {
$simpleXmlElement = simplexml_import_dom($element);
foreach ($simpleXmlElement->getDocNamespaces(false, false) as $namespace => $value) {
if (
Str::contains($value, 'html', true) === true ||
Str::contains($value, 'svg', true) === true
) {
throw new InvalidArgumentException(
'The namespace is not allowed in XML files' .
' (around line ' . $element->getLineNo() . ')'
);
}
}
}
}
/**
* Validates the doctype if present
*
* @param \DOMDocumentType $doctype
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
*/
protected static function validateDoctype(DOMDocumentType $doctype): void
{
// if we are validating an XML file, block
// all SVG and HTML doctypes
if (
static::class === self::class &&
(
Str::contains($doctype->name, 'html', true) === true ||
Str::contains($doctype->name, 'svg', true) === true
)
) {
throw new InvalidArgumentException('The doctype is not allowed in XML files');
}
if (empty($doctype->publicId) === false || empty($doctype->systemId) === false) {
throw new InvalidArgumentException('The doctype must not reference external files');
}
if (empty($doctype->internalSubset) === false) {
throw new InvalidArgumentException('The doctype must not define a subset');
}
}
/**
* Validates a DOMDocument tree
*
* @param \DOMDocument $string
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the document didn't pass validation
*/
protected static function validateDom(DOMDocument $xml): void
{
foreach ($xml->childNodes as $child) {
if (is_a($child, 'DOMDocumentType') === true) {
static::validateDoctype($child);
}
}
// validate all processing instructions like <?xml-stylesheet
$xPath = new DOMXPath($xml);
$pis = $xPath->query('//processing-instruction()');
static::validateProcessingInstructions($pis);
// validate all elements in the document tree
$elements = $xml->getElementsByTagName('*');
static::validateElements($xPath, $elements);
}
/**
* Validates all given DOM elements and their attributes
*
* @param \DOMXPath $xPath
* @param \DOMNodeList $elements
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If any of the elements is not valid
*/
protected static function validateElements(DOMXPath $xPath, DOMNodeList $elements): void
{
foreach ($elements as $element) {
// check for allow-listed attributes
static::validateAttrs($xPath, $element);
}
}
/**
* Validates the values of all given processing instructions
*
* @param \DOMNodeList $elements
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If any of the processing instructions is not valid
*/
protected static function validateProcessingInstructions(DOMNodeList $elements): void
{
foreach ($elements as $element) {
$elementName = $element->nodeName;
// check for allow-listed processing instructions
if (in_array($elementName, static::$allowedPIs) === false) {
throw new InvalidArgumentException(
'The "' . $elementName . '" processing instruction (line ' .
$element->getLineNo() . ') is not allowed'
);
}
}
}
}