Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
1.5.2 (unreleased)
=====

* (improvement) Reduce normalization overhead by tracking the debug stack internally across recursion instead of mutating it in context on each nested value.
* (improvement) Cache Doctrine-normalized class names in `SimpleNormalizer` to avoid repeated metadata lookups for the same object type.
* (improvement) Add test coverage to ensure class-name normalization is cached across repeated normalization calls.
* (improvement) Optimize `ValidJsonVerifier` by reusing a mutable path stack during traversal instead of allocating a new path array for each nested element.
* (improvement) Add dedicated verifier tests for deep-path reporting and first-invalid-element detection.
* (improvement) Optimize empty `stdClass` detection by using an `(array)` cast check instead of `get_object_vars()`.
* (improvement) Add a default max-depth guard (128) for normalization and JSON verification to mitigate deep-nesting DoS risk.


1.5.1
=====

Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"phpunit/phpunit": "^13.0.5",
"roave/security-advisories": "dev-latest"
},
"suggest": {
"doctrine/orm": "Can be used to normalize Doctrine entity class names (for example proxy classes to their mapped entity class)."
},
"autoload": {
"psr-4": {
"Torr\\SimpleNormalizer\\": "src/"
Expand Down
7 changes: 7 additions & 0 deletions src/Exception/InvalidMaxDepthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php declare(strict_types=1);

namespace Torr\SimpleNormalizer\Exception;

final class InvalidMaxDepthException extends \InvalidArgumentException implements NormalizerExceptionInterface
{
}
2 changes: 1 addition & 1 deletion src/Exception/NormalizationFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Torr\SimpleNormalizer\Exception;

/**
* Generic failure exception, for usage inside of custom normalizers.
* Generic failure exception, for usage inside custom normalizers.
*/
class NormalizationFailedException extends \RuntimeException implements NormalizerExceptionInterface
{
Expand Down
146 changes: 100 additions & 46 deletions src/Normalizer/SimpleNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Doctrine\ORM\Mapping\ClassMetadataFactory;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Torr\SimpleNormalizer\Exception\InvalidMaxDepthException;
use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException;
use Torr\SimpleNormalizer\Exception\UnsupportedTypeException;
use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier;
Expand All @@ -18,14 +19,16 @@
* The verifier is done on the top-level of every method (instead of at the point where the invalid values could occur
* = the object normalizers), as this way we can provide a full path to the invalid element in the JSON.
*
* @readonly
*
* @final
*/
class SimpleNormalizer
{
private readonly ?ClassMetadataFactory $doctrineMetadata;
private const string STACK_CONTEXT = "simple-normalizer.debug-stack";
private const int DEFAULT_MAX_DEPTH = 128;

/** @var array<class-string, class-string> */
private array $normalizedClassNames = [];

/**
* @param ServiceLocator<SimpleObjectNormalizerInterface> $objectNormalizers
Expand All @@ -35,20 +38,27 @@ public function __construct (
private readonly bool $isDebug = false,
private readonly ?ValidJsonVerifier $validJsonVerifier = null,
?EntityManagerInterface $entityManager = null,
private readonly int $maxDepth = self::DEFAULT_MAX_DEPTH,
)
{
if ($this->maxDepth < 1)
{
throw new InvalidMaxDepthException("The max depth must be at least 1.");
}

$this->doctrineMetadata = $entityManager?->getMetadataFactory();
}

/**
*/
public function normalize (mixed $value, array $context = []) : mixed
{
$normalizedValue = $this->recursiveNormalize($value, $context);
$stack = $this->extractInitialStack($context);
$normalizedValue = $this->recursiveNormalize($value, $context, $stack);

if ($this->isDebug)
{
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth);
}

return $normalizedValue;
Expand All @@ -58,11 +68,12 @@ public function normalize (mixed $value, array $context = []) : mixed
*/
public function normalizeArray (array $array, array $context = []) : array
{
$normalizedValue = $this->recursiveNormalizeArray($array, $context);
$stack = $this->extractInitialStack($context);
$normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack);

if ($this->isDebug)
{
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth);
}

return $normalizedValue;
Expand All @@ -75,11 +86,12 @@ public function normalizeArray (array $array, array $context = []) : array
public function normalizeMap (array $array, array $context = []) : array|\stdClass
{
// return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON.
$normalizedValue = $this->recursiveNormalizeArray($array, $context) ?: new \stdClass();
$stack = $this->extractInitialStack($context);
$normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack) ?: new \stdClass();

if ($this->isDebug)
{
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth);
}

return $normalizedValue;
Expand All @@ -89,58 +101,74 @@ public function normalizeMap (array $array, array $context = []) : array|\stdCla
* The actual normalize logic, that recursively normalizes the value.
* It must never call one of the public methods above and just normalizes the value.
*/
private function recursiveNormalize (mixed $value, array $context = []) : mixed
private function recursiveNormalize (mixed $value, array $context, array &$stack) : mixed
{
if (null === $value || \is_scalar($value))
{
return $value;
}

if (!isset($context[self::STACK_CONTEXT]) || !\is_array($context[self::STACK_CONTEXT]))
if (\count($stack) >= $this->maxDepth)
{
$context[self::STACK_CONTEXT] = [];
$extendedStack = [...$stack, get_debug_type($value)];

throw new UnsupportedTypeException(\sprintf(
"Maximum normalization depth of %d exceeded when normalizing type %s in stack %s",
$this->maxDepth,
get_debug_type($value),
implode(" > ", array_reverse($extendedStack)),
));
}

$context[self::STACK_CONTEXT][] = get_debug_type($value);

if (\is_array($value))
{
return $this->recursiveNormalizeArray($value, $context);
}
$stack[] = get_debug_type($value);

if (\is_object($value))
try
{
// Allow empty stdClass as a way to force a JSON {} instead of an
// array which would encode to []
if ($value instanceof \stdClass && [] === get_object_vars($value))
if (\is_array($value))
{
return $value;
return $this->recursiveNormalizeArray($value, $context, $stack);
}

try
if (\is_object($value))
{
$className = $this->normalizeClassName($value::class);

$normalizer = $this->objectNormalizers->get($className);
\assert($normalizer instanceof SimpleObjectNormalizerInterface);

return $normalizer->normalize($value, $context, $this);
}
catch (ServiceNotFoundException $exception)
{
throw new ObjectTypeNotSupportedException(\sprintf(
"Can't normalize type '%s' in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($context[self::STACK_CONTEXT])),
), 0, $exception);
// Allow empty stdClass as a way to force a JSON {} instead of an
// array which would encode to []
if ($value instanceof \stdClass && [] === (array) $value)
{
return $value;
}

try
{
$className = $this->normalizeClassName($value::class);
$normalizer = $this->objectNormalizers->get($className);
\assert($normalizer instanceof SimpleObjectNormalizerInterface);

// Preserve debug stack visibility for custom object normalizers.
$context[self::STACK_CONTEXT] = $stack;
Comment thread
apfelbox marked this conversation as resolved.
Outdated

return $normalizer->normalize($value, $context, $this);
}
catch (ServiceNotFoundException $exception)
{
throw new ObjectTypeNotSupportedException(\sprintf(
"Can't normalize type '%s' in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($stack)),
), 0, $exception);
}
}
}

throw new UnsupportedTypeException(\sprintf(
"Can't normalize type %s in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($context[self::STACK_CONTEXT])),
));
throw new UnsupportedTypeException(\sprintf(
"Can't normalize type %s in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($stack)),
));
}
finally
{
array_pop($stack);
}
}

/**
Expand All @@ -158,7 +186,12 @@ private function normalizeClassName (string $className) : string
return $className;
}

return $this->doctrineMetadata->hasMetadataFor($className)
if (isset($this->normalizedClassNames[$className]))
{
return $this->normalizedClassNames[$className];
}

return $this->normalizedClassNames[$className] = $this->doctrineMetadata->hasMetadataFor($className)
? $this->doctrineMetadata->getMetadataFor($className)->getName()
: $className;
}
Expand All @@ -167,14 +200,14 @@ private function normalizeClassName (string $className) : string
* The actual customized normalization logic for arrays, that recursively normalizes the value.
* It must never call one of the public methods above and just normalizes the value.
*/
private function recursiveNormalizeArray (array $array, array $context = []) : array
private function recursiveNormalizeArray (array $array, array $context, array &$stack) : array
{
$result = [];
$isList = array_is_list($array);

foreach ($array as $key => $value)
{
$normalized = $this->recursiveNormalize($value, $context);
$normalized = $this->recursiveNormalize($value, $context, $stack);

// if the array was a list and the normalized value is null, just filter it out
if ($isList && null === $normalized)
Expand All @@ -196,4 +229,25 @@ private function recursiveNormalizeArray (array $array, array $context = []) : a

return $result;
}

/**
* @return list<string>
*/
private function extractInitialStack (array $context) : array
{
if (!isset($context[self::STACK_CONTEXT]) || !\is_array($context[self::STACK_CONTEXT]))
{
return [];
}

$stack = [];

foreach ($context[self::STACK_CONTEXT] as $entry)
{
\assert(\is_string($entry));
$stack[] = $entry;
}

return $stack;
}
}
35 changes: 24 additions & 11 deletions src/Normalizer/Validator/ValidJsonVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
class ValidJsonVerifier
{
/**
* Ensures that only valid JSON types are present in the value, that means scalars, arrays and empty objects.
* Ensures that only valid JSON types are present in the value that means scalars, arrays, and empty objects.
*
* @param positive-int $maxDepth
*/
public function ensureValidOnlyJsonTypes (mixed $value) : void
public function ensureValidOnlyJsonTypes (mixed $value, int $maxDepth) : void
{
$invalidElement = $this->findInvalidJsonElement($value);
$path = ["$"];
$invalidElement = $this->findInvalidJsonElement($value, $path, $maxDepth);

if (null !== $invalidElement)
{
Expand All @@ -34,10 +37,21 @@ public function ensureValidOnlyJsonTypes (mixed $value) : void
* Searches through the value and looks for anything that isn't valid JSON
* (scalars, arrays or empty objects).
*
* @param positive-int $maxDepth
*
* @return InvalidJsonElement|null returns null if everything is valid, otherwise the invalid value
*/
private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?InvalidJsonElement
private function findInvalidJsonElement (mixed $value, array &$path, int $maxDepth) : ?InvalidJsonElement
{
if (\count($path) - 1 > $maxDepth)
{
throw new IncompleteNormalizationException(\sprintf(
"Maximum JSON verification depth of %d exceeded at path '%s'.",
$maxDepth,
implode(".", $path),
));
}

// scalars are always valid
if (null === $value || \is_scalar($value))
{
Expand All @@ -47,19 +61,18 @@ private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?I
// only empty stdClass objects are allowed (as they are used to serialize to `{}`)
if (\is_object($value))
{
return $value instanceof \stdClass && [] === get_object_vars($value)
return $value instanceof \stdClass && [] === (array) $value
? null
: new InvalidJsonElement($value, $path);
: new InvalidJsonElement($value, [...$path]);
}

if (\is_array($value))
{
foreach ($value as $key => $item)
{
$invalidItem = $this->findInvalidJsonElement(
$item,
[...$path, $key],
);
$path[] = $key;
$invalidItem = $this->findInvalidJsonElement($item, $path, $maxDepth);
array_pop($path);

if (null !== $invalidItem)
{
Expand All @@ -70,6 +83,6 @@ private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?I
return null;
}

return new InvalidJsonElement($value, $path);
return new InvalidJsonElement($value, [...$path]);
}
}
Loading
Loading