From 22ec09040f8db591f8f5d8c47bfe1fe7d4266b85 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Fri, 9 Oct 2026 09:58:34 +0200 Subject: [PATCH] fix #135 Reject a JSON context that does not decode to an array in the HTTP API Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++ docs/reference/06-http_api.md | 4 +-- src/Http/Model/HttpProcessExecution.php | 26 ++++++++++++++++ tests/Functional/HttpProcessExecuteTest.php | 30 +++++++++++++++++++ tests/Http/Model/HttpProcessExecutionTest.php | 8 +++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3a02d..82bdd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Latest ## Changes * [#18](https://github.com/cleverage/ui-process-bundle/issues/18) PHPStan level 10 (was 8), on `src` and `tests`: `LogProcessFilter::new()` `$label` typed as `TranslatableInterface|string|false|null` (the type of `setLabel()`), `LogRecord::$context` documented as `array` (the Monolog context), conditional return type of `ProcessExecution::getReport()`. +## Fixes +* [#135](https://github.com/cleverage/ui-process-bundle/issues/135) HTTP API: a `context` given as a JSON string that does not decode to an array (`"1"`, `"true"`, `"\"abc\""`, `"null"`, `""`) is rejected with a `422` (`Context must be a JSON object or array.`); it gave a 500 (`TypeError`), whose message exposed the server paths with a synchronous execution. Add tests. + v3.1 ------ diff --git a/docs/reference/06-http_api.md b/docs/reference/06-http_api.md index 414abfb..de6f79d 100644 --- a/docs/reference/06-http_api.md +++ b/docs/reference/06-http_api.md @@ -32,7 +32,7 @@ Parameters can be sent either as a JSON body, or as form data (`application/x-ww |-----------|---------------------------|:--------:|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `code` | `string` | **X** | | Code of the process. It must exist and be public. | | `input` | `string` or file | | `null` | Process input. With form data, it can be an uploaded file: the file is saved in the `upload_directory` directory (see [bundle configuration](01-bundle_configuration.md#container-parameters)) and its path is passed as input. | -| `context` | `object` or JSON `string` | | `{}` | Process context, as key/value pairs. With form data, send one field per value: `context[key]=value`. | +| `context` | `object` or JSON `string` | | `{}` | Process context, as key/value pairs. A JSON string must decode to an object or an array (`422` otherwise). With form data, send one field per value: `context[key]=value`. | | `queue` | `bool` | | `true` | `true`: the process is queued to the `execute_process` transport (see [messenger](08-messenger.md)). `false`: the process is executed during the HTTP request. | `code`, `input`, `context` and `queue` can also be passed in the query string, with form data or without body (the form @@ -46,7 +46,7 @@ Responses | `200` | `"Process has been added to queue. It will start as soon as possible."` | `queue` is `true`. | | `200` | `"Process has been proceed well."` | `queue` is `false` and the process succeeded. | | `500` | The exception message and `(process execution: )`, as a JSON string | `queue` is `false` and the process failed. | -| `422` | Violation messages, e.g. `Process code is required.`, `The process "foo" does not exist.`, `The process "foo" is not public.` | Invalid parameters. A request body that cannot be parsed is handled as an empty request. | +| `422` | Violation messages, e.g. `Process code is required.`, `The process "foo" does not exist.`, `The process "foo" is not public.`, `Context must be a JSON object or array.` | Invalid parameters. A request body that cannot be parsed is handled as an empty request. | Once the process has started, its execution is recorded in the [executions list](04-process_executions_and_logs.md), like any other execution. diff --git a/src/Http/Model/HttpProcessExecution.php b/src/Http/Model/HttpProcessExecution.php index 3c78a4a..3e8d8ed 100644 --- a/src/Http/Model/HttpProcessExecution.php +++ b/src/Http/Model/HttpProcessExecution.php @@ -15,10 +15,12 @@ use CleverAge\UiProcessBundle\Validator\IsValidProcessCode; use Symfony\Component\Validator\Constraints\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Constraints\Json; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\Sequentially; use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Context\ExecutionContextInterface; final readonly class HttpProcessExecution { @@ -34,4 +36,28 @@ public function __construct( public bool $queue = true, ) { } + + /** + * A JSON context must decode to an array: the Json constraint also accepts scalars ("1", "null"...) and "". + */ + #[Callback] + public function validateContext(ExecutionContextInterface $context): void + { + if (!\is_string($this->context)) { + return; + } + try { + $decoded = json_decode($this->context, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + if ('' !== $this->context) { + return; // Reported by the Json constraint + } + $decoded = null; + } + if (!\is_array($decoded)) { + $context->buildViolation('Context must be a JSON object or array.') + ->atPath('context') + ->addViolation(); + } + } } diff --git a/tests/Functional/HttpProcessExecuteTest.php b/tests/Functional/HttpProcessExecuteTest.php index 2f1d83a..1e0cbe0 100644 --- a/tests/Functional/HttpProcessExecuteTest.php +++ b/tests/Functional/HttpProcessExecuteTest.php @@ -43,6 +43,7 @@ use CleverAge\UiProcessBundle\Twig\Extension\ProcessExtension; use CleverAge\UiProcessBundle\Validator\IsValidProcessCodeValidator; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher; @@ -154,6 +155,35 @@ public function testJsonBody(): void self::assertSame(['key' => 'value'], $messages[0]->context); } + /** + * A JSON context that does not decode to an array is rejected (it gave a 500: TypeError). + */ + #[DataProvider('provideScalarJsonContext')] + public function testScalarJsonContext(string $context, bool $queue): void + { + $this->client->request( + 'POST', + '/http/process/execute', + server: ['HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN, 'CONTENT_TYPE' => 'application/json'], + content: json_encode(['code' => 'test.process', 'context' => $context, 'queue' => $queue], \JSON_THROW_ON_ERROR) + ); + + self::assertResponseStatusCodeSame(422); + self::assertSame([], $this->getDispatchedMessages()); + self::assertSame([], $this->getEntityManager()->getRepository(ProcessExecution::class)->findAll()); + } + + /** + * @return iterable + */ + public static function provideScalarJsonContext(): iterable + { + foreach (['1', 'true', '"abc"', 'null', ''] as $context) { + yield $context.' queued' => [$context, true]; + yield $context.' synchronous' => [$context, false]; + } + } + public function testUnknownProcess(): void { $this->execute(['code' => 'unknown']); diff --git a/tests/Http/Model/HttpProcessExecutionTest.php b/tests/Http/Model/HttpProcessExecutionTest.php index 640aed7..12377bc 100644 --- a/tests/Http/Model/HttpProcessExecutionTest.php +++ b/tests/Http/Model/HttpProcessExecutionTest.php @@ -84,6 +84,14 @@ public static function provideValidation(): iterable { yield 'array context' => ['demo.process', ['key' => 'value'], []]; yield 'json context' => ['demo.process', '{"key":"value"}', []]; + yield 'json list context' => ['demo.process', '["value"]', []]; + yield 'empty json object context' => ['demo.process', '{}', []]; + // Valid JSON accepted by the Json constraint, but not decoded to an array + yield 'json integer context' => ['demo.process', '1', ['context: Context must be a JSON object or array.']]; + yield 'json boolean context' => ['demo.process', 'true', ['context: Context must be a JSON object or array.']]; + yield 'json string context' => ['demo.process', '"abc"', ['context: Context must be a JSON object or array.']]; + yield 'json null context' => ['demo.process', 'null', ['context: Context must be a JSON object or array.']]; + yield 'empty string context' => ['demo.process', '', ['context: Context must be a JSON object or array.']]; yield 'missing code' => [null, [], ['code: Process code is required.']]; yield 'unknown code' => ['demo.unknown', [], ['code: The process "demo.unknown" does not exist.']]; }