Skip to content

Commit

Permalink
Add assertion to test XPath filters against an allow-list for axes an…
Browse files Browse the repository at this point in the history
…d functions
  • Loading branch information
tvdijen committed Nov 26, 2024
1 parent 3ace522 commit db309f8
Show file tree
Hide file tree
Showing 3 changed files with 400 additions and 12 deletions.
172 changes: 172 additions & 0 deletions src/Assert/Assert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\XML\Assert;

use BadMethodCallException; // Requires ext-spl
use DateTime; // requires ext-date
use DateTimeImmutable; // requires ext-date
use InvalidArgumentException; // Requires ext-spl
use SimpleSAML\Assert\Assert as BaseAssert;
use SimpleSAML\Assert\AssertionFailedException;
use Throwable;

use function array_pop;
use function array_unshift;
use function call_user_func_array;
use function end;
use function enum_exists;
use function function_exists;
use function get_class;
use function is_object;
use function is_resource;
use function is_string;
use function is_subclass_of;
use function lcfirst;
use function method_exists;
use function preg_match; // Requires ext-pcre
use function strval;

/**
* SimpleSAML\XML\Assert\Assert wrapper class
*
* @package simplesamlphp/xml-common
*
* @method static void allowedXPathFilter(mixed $value, array $allowed_axes, $array $allowed_functions, string $message = '', string $exception = '')
* @method static void nullOrAllowedXPathFilter(mixed $value, array $allowed_axes, $array $allowed_functions, string $message = '', string $exception = '')
* @method static void allAllowedXPathFilter(mixed $value, array $allowed_axes, $array $allowed_functions, string $message = '', string $exception = '')
*/
final class Assert
{
use CustomAssertionTrait;


/**
* @param string $name
* @param array<mixed> $arguments
*/
public static function __callStatic(string $name, array $arguments): void
{
// Handle Exception-parameter
$exception = AssertionFailedException::class;

$last = end($arguments);
if (is_string($last) && class_exists($last) && is_subclass_of($last, Throwable::class)) {
$exception = $last;
array_pop($arguments);
}

try {
if (method_exists(static::class, $name)) {
call_user_func_array([static::class, $name], $arguments);
return;
} elseif (preg_match('/^nullOr(.*)$/i', $name, $matches)) {
$method = lcfirst($matches[1]);
if (method_exists(static::class, $method)) {
call_user_func_array([static::class, 'nullOr'], [[static::class, $method], $arguments]);
} elseif (method_exists(BaseAssert::class, $method)) {
call_user_func_array([static::class, 'nullOr'], [[BaseAssert::class, $method], $arguments]);
} else {
throw new BadMethodCallException(sprintf("Assertion named `%s` does not exists.", $method));
}
} elseif (preg_match('/^all(.*)$/i', $name, $matches)) {
$method = lcfirst($matches[1]);
if (method_exists(static::class, $method)) {
call_user_func_array([static::class, 'all'], [[static::class, $method], $arguments]);
} elseif (method_exists(BaseAssert::class, $method)) {
call_user_func_array([static::class, 'all'], [[BaseAssert::class, $method], $arguments]);
} else {
throw new BadMethodCallException(sprintf("Assertion named `%s` does not exists.", $method));
}
} else {
throw new BadMethodCallException(sprintf("Assertion named `%s` does not exists.", $name));
}
} catch (InvalidArgumentException $e) {
throw new $exception($e->getMessage());
}
}


/**
* Handle nullOr* for either Webmozart or for our custom assertions
*
* @param callable $method
* @param array<mixed> $arguments
* @return void
*/
private static function nullOr(callable $method, array $arguments): void
{
$value = reset($arguments);
($value === null) || call_user_func_array($method, $arguments);
}


/**
* all* for our custom assertions
*
* @param callable $method
* @param array<mixed> $arguments
* @return void
*/
private static function all(callable $method, array $arguments): void
{
$values = array_pop($arguments);
foreach ($values as $value) {
$tmp = $arguments;
array_unshift($tmp, $value);
call_user_func_array($method, $tmp);
}
}


/**
* @param mixed $value
*
* @return string
*/
protected static function valueToString(mixed $value): string
{
if (is_resource($value)) {
return 'resource';
}

if (null === $value) {
return 'null';
}

if (true === $value) {
return 'true';
}

if (false === $value) {
return 'false';
}

if (is_array($value)) {
return 'array';
}

if (is_object($value)) {
if (method_exists($value, '__toString')) {
return $value::class . ': ' . self::valueToString($value->__toString());
}

if ($value instanceof DateTime || $value instanceof DateTimeImmutable) {
return $value::class . ': ' . self::valueToString($value->format('c'));
}

if (function_exists('enum_exists') && enum_exists(get_class($value))) {
return get_class($value) . '::' . $value->name;
}

return $value::class;
}

if (is_string($value)) {
return '"' . $value . '"';
}

return strval($value);
}
}
179 changes: 179 additions & 0 deletions src/Assert/CustomAssertionTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\XML\Assert;

use InvalidArgumentException;
use SimpleSAML\Assert\Assert as BaseAssert;
use SimpleSAML\XML\Constants as C;

use function in_array;
use function preg_match_all;
use function preg_replace;
use function sprintf;
use function strlen;

/**
* @package simplesamlphp/xml-common
*/
trait CustomAssertionTrait
{
/**
* Remove the content from all single or double-quoted strings in $input, leaving only quotes.
* Use possessive quantifiers (i.e. *+ and ++ instead of * and + respectively) to prevent backtracking.
*
* '/(["\'])(?:(?!\1).)*+\1/'
* (["\']) # Match a single or double quote and capture it in group 1
* (?: # Start a non-capturing group
* (?! # Negative lookahead
* \1 # Match the same quote as in group 1
* ) # End of negative lookahead
* . # Match any character (that is not a quote, because of the negative lookahead)
* )*+ # Repeat the non-capturing group zero or more times, possessively
* \1 # Match the same quote as in group 1
*/
private static string $regex_xpfilter_remove_strings = '/(["\'])(?:(?!\1).)*+\1/';

/**
* Function names are lower-case alpha (i.e. [a-z]) and can contain one or more hyphens,
* but cannot start or end with a hyphen. To match this, we start with matching one or more
* lower-case alpha characters, followed by zero or more atomic groups that start with a hyphen
* and then match one or more lower-case alpha characters. This ensures that the function name
* cannot start or end with a hyphen, but can contain one or more hyphens.
* More than one consecutive hyphen does not match.
*
* '/([a-z]++(?>-[a-z]++)*+)\s*+\(/'
* ( # Start a capturing group
* [a-z]++ # Match one or more lower-case alpha characters
* (?> # Start an atomic group (no capturing)
* - # Match a hyphen
* [a-z]++ # Match one or more lower-case alpha characters, possessively
* )*+ # Repeat the atomic group zero or more times,
* ) # End of the capturing group
* \s*+ # Match zero or more whitespace characters, possessively
* \( # Match an opening parenthesis
*/
private static string $regex_xpfilter_functions = '/([a-z]++(?>-[a-z]++)*+)\\s*+\\(/';

/**
* We use the same rules for matching Axis names as we do for function names.
* The only difference is that we match the '::' instead of the '('
* so everything that was said about the regular expression for function names
* applies here as well.
*
* '/([a-z]++(?>-[a-z]++)*+)\s*+::'
* ( # Start a capturing group
* [a-z]++ # Match one or more lower-case alpha characters
* (?> # Start an atomic group (no capturing)
* - # Match a hyphen
* [a-z]++ # Match one or more lower-case alpha characters, possessively
* )*+ # Repeat the atomic group zero or more times,
* ) # End of the capturing group
* \s*+ # Match zero or more whitespace characters, possessively
* \( # Match an opening parenthesis
*/
private static string $regex_xpfilter_axes = '/([a-z]++(?>-[a-z]++)*+)\\s*+::/';


/***********************************************************************************
* NOTE: Custom assertions may be added below this line. *
* They SHOULD be marked as `private` to ensure the call is forced *
* through __callStatic(). *
* Assertions marked `public` are called directly and will *
* not handle any custom exception passed to it. *
***********************************************************************************/

/**
* Check an XPath expression for allowed axes and functions
* The goal is preventing DoS attacks by limiting the complexity of the XPath expression by only allowing
* a select subset of functions and axes.
* The check uses a list of allowed functions and axes, and throws an exception when an unknown function
* or axis is found in the $xpath_expression.
*
* Limitations:
* - The implementation is based on regular expressions, and does not employ an XPath 1.0 parser. It may not
* evaluate all possible valid XPath expressions correctly and cause either false positives for valid
* expressions or false negatives for invalid expressions.
* - The check may still allow expressions that are not safe, I.e. expressions that consist of only
* functions and axes that are deemed "save", but that are still slow to evaluate. The time it takes to
* evaluate an XPath expression depends on the complexity of both the XPath expression and the XML document.
* This check, however, does not take the XML document into account, nor is it aware of the internals of the
* XPath processor that will evaluate the expression.
* - The check was written with the XPath 1.0 syntax in mind, but should work equally well for XPath 2.0 and 3.0.
*
* @param string $value
* @param array $allowed_axes
* @param array $allowed_functions
* @param string $message
*/
private static function allowedXPathFilter(
string $value,
array $allowed_axes = C::DEFAULT_ALLOWED_AXES,
array $allowed_functions = C::DEFAULT_ALLOWED_FUNCTIONS,
string $message = '',
): void {
BaseAssert::maxLength(
$value,
C::XPATH_FILTER_MAX_LENGTH,
sprintf('XPath Filter exceeds the limit of 100 characters.'),
);

$strippedValue = preg_replace(
self::$regex_xpfilter_remove_strings,
// Replace the content with two of the quotes that were matched
"\\1\\1",
$value,
);

if ($strippedValue === null) {
throw new Exception("Error in preg_replace.");
}

/**
* Check if the $xpath_expression uses an XPath function that is not in the list of allowed functions
*
* Look for the function specifier '(' and look for a function name before it.
* Ignoring whitespace before the '(' and the function name.
* All functions must match a string on a list of allowed function names
*/
$matches = [];
$res = preg_match_all(self::$regex_xpfilter_functions, $strippedValue, $matches);
if ($res === null) {
throw new Exception("Error in preg_match_all.");
}

// Check that all the function names we found are in the list of allowed function names
foreach ($matches[1] as $match) {
if (!in_array($match, $allowed_functions)) {
throw new InvalidArgumentException(sprintf(
$message ?: '\'%s\' is not an allowed XPath function.',
$match,
));
}
}

/**
* Check if the $xpath_expression uses an XPath axis that is not in the list of allowed axes
*
* Look for the axis specifier '::' and look for a function name before it.
* Ignoring whitespace before the '::' and the axis name.
* All axes must match a string on a list of allowed axis names
*/
$matches = [];
$res = preg_match_all(self::$regex_xpfilter_axes, $strippedValue, $matches);
if ($res === null) {
throw new Exception("Error in preg_match_all.");
}

// Check that all the axes names we found are in the list of allowed axes names
foreach ($matches[1] as $match) {
if (!in_array($match, $allowed_axes)) {
throw new InvalidArgumentException(sprintf(
$message ?: '\'%s\' is not an allowed XPath axis.',
$match,
));
}
}
}
}
Loading

0 comments on commit db309f8

Please sign in to comment.