diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a77928 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 0.2.0 + +### Breaking + +- `Serializer::unserialize()` refuses closure payloads (data produced by `Serializer::serialize()` for a value containing a closure) and throws `Utopia\Async\Exception\Serialization`. Decoding a closure payload through `opis/closure` rebuilds the objects inside it by reflection, which the `allowed_classes` option does not govern, so it no longer happens by default. Plain payloads keep the `allowed_classes => false` default. + +### Added + +- `Serializer::unserializeTrusted()` decodes closure payloads for data from a trusted channel (this process or its own workers). The Swoole process pool uses it for its own worker channel. + +## 0.1.1 + +- `Promise::all()` records the error before signalling its channel. +- The parallel pool skips SIGKILL for workers that are already reaped on shutdown. +- PHPStan level-max fixes in the Timer and Promise adapters. + +## 0.1.0 + +- Initial release. diff --git a/README.md b/README.md index 59b11c0..d904017 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,23 @@ try { } ``` +## Serialization + +`Serializer::serialize()` encodes data containing closures with `opis/closure` and everything else with PHP's `serialize()`. Decoding a closure payload can rebuild objects of any class, so it is an explicit opt-in: + +```php +use Utopia\Async\Serializer; + +// Plain data only: objects are restored only for the allowed classes (none by default), +// and a closure payload throws Utopia\Async\Exception\Serialization +$data = Serializer::unserialize($payload); +$data = Serializer::unserialize($payload, ['allowed_classes' => [MyValue::class]]); + +// Closures and the objects they carry, for payloads from a trusted channel only +// (this process or its own workers, never user input, caches or queues) +$task = Serializer::unserializeTrusted($payload); +``` + ## Configuration Both `Parallel` and `Promise` facades expose configurable options via static getter/setter methods. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..97b8009 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,23 @@ +# Upgrade Guide + +## 0.1.x to 0.2.0 + +### Closure payloads need trusted decoding + +`Serializer::unserialize()` no longer decodes closure payloads. It throws `Utopia\Async\Exception\Serialization` for them, because `opis/closure` rebuilds the objects inside such a payload by reflection, outside the control of the `allowed_classes` option. + +`Serialization` extends `Utopia\Async\Exception`, not `\RuntimeException`: code that catches only `\RuntimeException` around `Serializer::unserialize()` must also catch `Serialization` (or `\Exception`) to handle a refused payload. + +If you pass closures between processes you control, switch those call sites to `Serializer::unserializeTrusted()`: + +```php +// 0.1.x +$task = Serializer::unserialize($message); + +// 0.2.0 +$task = Serializer::unserializeTrusted($message); +``` + +Keep `Serializer::unserialize()` for everything that does not need closures, and never pass data from users, caches, queues or other shared storage to `Serializer::unserializeTrusted()`. + +`Serializer::unserializeTrusted()` accepts the same `$options` and still applies `allowed_classes => false` to plain payloads. diff --git a/src/Parallel/Pool/Swoole/Process.php b/src/Parallel/Pool/Swoole/Process.php index bb8a50f..d3b511b 100644 --- a/src/Parallel/Pool/Swoole/Process.php +++ b/src/Parallel/Pool/Swoole/Process.php @@ -76,9 +76,8 @@ private function initializePool(): void break; } - // Deserialize the entire message with Serializer (handles closures automatically) try { - $taskData = Serializer::unserialize(\is_string($message) ? $message : ''); + $taskData = Serializer::unserializeTrusted(\is_string($message) ? $message : ''); } catch (\Throwable $e) { continue; } @@ -224,7 +223,7 @@ public function execute(array $tasks): array } try { - $result = Serializer::unserialize(\is_string($response) ? $response : ''); + $result = Serializer::unserializeTrusted(\is_string($response) ? $response : ''); } catch (\Throwable $e) { continue; } diff --git a/src/Serializer.php b/src/Serializer.php index 2277cc0..65b79d5 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -2,11 +2,11 @@ namespace Utopia\Async; +use Utopia\Async\Exception\Serialization; + /** - * High-performance serializer with igbinary support. - * - * Uses igbinary extension if available for 2-3x faster serialization, - * falls back to standard PHP serialize() when not available. + * Serializer for task payloads: opis/closure for data containing closures, + * standard PHP serialize() for everything else. * * @package Utopia\Async */ @@ -40,26 +40,61 @@ public static function serialize(mixed $data): string } /** - * Unserialize data using opis/closure for Closures and standard unserialization for everything else. + * Unserialize plain data. Objects are restored only for the classes allowed by the + * caller's options (none by default) and closure payloads are refused, because decoding + * them can instantiate any class. Use unserializeTrusted() for data from a trusted channel. * * @param string $data * @param array{allowed_classes?: bool|array} $options Options for unserialize * @return mixed + * @throws Serialization If the data is a closure payload * @throws \RuntimeException If unserialization fails or data is invalid */ public static function unserialize(string $data, array $options = []): mixed { - if (empty($data)) { - throw new \RuntimeException('Cannot unserialize empty data'); + if (\str_starts_with($data, self::OPIS_CLOSURE_PREFIX)) { + throw new Serialization('Refusing to decode a closure payload: use Serializer::unserializeTrusted() for data from a trusted channel'); } - // Fast prefix check - only check first 3 bytes - if (\str_starts_with($data, self::OPIS_CLOSURE_PREFIX)) { - $opisData = \substr($data, 3); - $result = @\Opis\Closure\unserialize($opisData, $options); - if ($result !== false || $opisData === \Opis\Closure\serialize(false)) { - return $result; - } + return self::unserializePlain($data, $options); + } + + /** + * Unserialize data from a trusted channel, restoring closures and the objects they carry. + * Only use this for payloads produced by this process or its own workers: a closure payload + * can rebuild objects that the allowed_classes option does not govern. + * + * @param string $data + * @param array{allowed_classes?: bool|array} $options Options for unserialize + * @return mixed + * @throws \RuntimeException If unserialization fails or data is invalid + */ + public static function unserializeTrusted(string $data, array $options = []): mixed + { + if (!\str_starts_with($data, self::OPIS_CLOSURE_PREFIX)) { + return self::unserializePlain($data, $options); + } + + $closureData = \substr($data, \strlen(self::OPIS_CLOSURE_PREFIX)); + $result = @\Opis\Closure\unserialize($closureData, $options); + + if ($result !== false || $closureData === \Opis\Closure\serialize(false)) { + return $result; + } + + throw new \RuntimeException('Failed to unserialize data'); + } + + /** + * @param string $data + * @param array{allowed_classes?: bool|array} $options + * @return mixed + * @throws \RuntimeException If unserialization fails or data is invalid + */ + private static function unserializePlain(string $data, array $options): mixed + { + if ($data === '') { + throw new \RuntimeException('Cannot unserialize empty data'); } /** @var array{allowed_classes?: bool|array} $mergedOptions */ diff --git a/tests/Unit/SerializerProbe.php b/tests/Unit/SerializerProbe.php new file mode 100644 index 0000000..27a9566 --- /dev/null +++ b/tests/Unit/SerializerProbe.php @@ -0,0 +1,27 @@ + $this->value]; + } + + /** + * @param array{value: string} $data + */ + public function __unserialize(array $data): void + { + self::$restored++; + $this->value = $data['value']; + } +} diff --git a/tests/Unit/SerializerTest.php b/tests/Unit/SerializerTest.php index a5596fa..bbbe704 100644 --- a/tests/Unit/SerializerTest.php +++ b/tests/Unit/SerializerTest.php @@ -3,6 +3,7 @@ namespace Utopia\Tests\Unit; use PHPUnit\Framework\TestCase; +use Utopia\Async\Exception\Serialization; use Utopia\Async\Serializer; class SerializerTest extends TestCase @@ -48,7 +49,7 @@ public function testSerializeClosure(): void }; $serialized = Serializer::serialize($closure); - $unserialized = Serializer::unserialize($serialized); + $unserialized = Serializer::unserializeTrusted($serialized); $this->assertInstanceOf(\Closure::class, $unserialized); /** @var \Closure(int): int $unserialized */ @@ -66,7 +67,7 @@ public function testSerializeArrayWithClosure(): void ]; $serialized = Serializer::serialize($data); - $unserialized = Serializer::unserialize($serialized); + $unserialized = Serializer::unserializeTrusted($serialized); $this->assertIsArray($unserialized); /** @var array{name: string, value: int, callback: callable(int): int} $unserialized */ @@ -88,7 +89,7 @@ public function testSerializeNestedClosures(): void ]; $serialized = Serializer::serialize($data); - $unserialized = Serializer::unserialize($serialized); + $unserialized = Serializer::unserializeTrusted($serialized); $this->assertIsArray($unserialized); /** @var array{level1: array{level2: array{callback: callable(int): int}}} $unserialized */ @@ -119,7 +120,7 @@ public function testSerializeObjectWithClosure(): void }; $serialized = Serializer::serialize($obj); - $unserialized = Serializer::unserialize($serialized, ['allowed_classes' => true]); + $unserialized = Serializer::unserializeTrusted($serialized, ['allowed_classes' => true]); $this->assertInstanceOf(\stdClass::class, $unserialized); /** @var \stdClass&object{name: string, callback: callable} $unserialized */ @@ -184,7 +185,7 @@ function () { ]]]]]; $serialized = Serializer::serialize($data); - $unserialized = Serializer::unserialize($serialized); + $unserialized = Serializer::unserializeTrusted($serialized); $this->assertIsArray($unserialized); // Closure should be found and properly serialized @@ -318,6 +319,58 @@ public function testFastPathForPrimitives(): void } } + public function testUnserializeRefusesClosurePayloadByDefault(): void + { + SerializerProbe::$restored = 0; + $payload = Serializer::serialize([ + 'task' => fn (): int => 1, + 'probe' => new SerializerProbe(), + ]); + + $refused = false; + try { + Serializer::unserialize($payload); + } catch (Serialization) { + $refused = true; + } + + $this->assertTrue($refused, 'A closure payload must not be decoded without opting in to trusted decoding'); + $this->assertSame(0, SerializerProbe::$restored, 'No object inside a refused payload may be instantiated'); + } + + public function testUnserializeRefusesClosurePayloadWithAllowedClasses(): void + { + $payload = Serializer::serialize(fn (): SerializerProbe => new SerializerProbe()); + + $this->expectException(Serialization::class); + + Serializer::unserialize($payload, ['allowed_classes' => true]); + } + + public function testUnserializeTrustedRestoresClosurePayload(): void + { + $payload = Serializer::serialize([ + 'task' => fn (int $x): int => $x * 2, + 'probe' => new SerializerProbe(), + ]); + + $unserialized = Serializer::unserializeTrusted($payload); + + $this->assertIsArray($unserialized); + /** @var array{task: \Closure(int): int, probe: SerializerProbe} $unserialized */ + $this->assertSame(10, $unserialized['task'](5)); + $this->assertInstanceOf(SerializerProbe::class, $unserialized['probe']); + $this->assertSame('probe', $unserialized['probe']->value); + } + + public function testUnserializeTrustedDecodesPlainPayloadWithoutClasses(): void + { + $payload = Serializer::serialize(new SerializerProbe()); + + $this->assertInstanceOf(\__PHP_Incomplete_Class::class, Serializer::unserializeTrusted($payload)); + $this->assertSame(['a' => 1], Serializer::unserializeTrusted(Serializer::serialize(['a' => 1]))); + } + /** * Test fast detection of Opis\Closure serialized data. */ @@ -330,7 +383,7 @@ public function testFastOpisClosureDetection(): void $this->assertStringContainsString('Opis\Closure\\', $serialized); // Should deserialize correctly using fast detection - $unserialized = Serializer::unserialize($serialized); + $unserialized = Serializer::unserializeTrusted($serialized); /** @var callable $unserialized */ $this->assertEquals('test', $unserialized()); }