diff --git a/system/Common.php b/system/Common.php index ea0c476423a0..f3f20a7a0db5 100644 --- a/system/Common.php +++ b/system/Common.php @@ -416,6 +416,12 @@ function env(string $key, $default = null) return $default; } + // Non-string values (e.g. $_SERVER['argc'] is int, $_SERVER['argv'] is array in CLI) + // must be returned as-is to avoid TypeError from strtolower(). + if (! is_string($value)) { + return $value; + } + // Handle any boolean values return match (strtolower($value)) { 'true' => true, @@ -459,8 +465,10 @@ function esc($data, string $context = 'html', ?string $encoding = null) if (is_array($data)) { foreach ($data as &$value) { - $value = esc($value, $context); + $value = esc($value, $context, $encoding); } + + return $data; } if (is_string($data)) { @@ -470,16 +478,14 @@ function esc($data, string $context = 'html', ?string $encoding = null) $method = $context === 'attr' ? 'escapeHtmlAttr' : 'escape' . ucfirst($context); - static $escaper; - if (! $escaper) { - $escaper = new Escaper($encoding); - } + static $escapers = []; + $cacheKey = strtolower($encoding ?? 'utf-8'); - if ($encoding !== null && $escaper->getEncoding() !== $encoding) { - $escaper = new Escaper($encoding); + if (! isset($escapers[$cacheKey])) { + $escapers[$cacheKey] = new Escaper($encoding); } - $data = $escaper->{$method}($data); + $data = $escapers[$cacheKey]->{$method}($data); } return $data; diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 7f79b07381fd..30f99d79a515 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -42,6 +42,7 @@ use Config\Services; use Config\Session as SessionConfig; use Exception; +use InvalidArgumentException; use Kint; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; @@ -131,6 +132,42 @@ public function testEnvBooleans(): void $this->assertNull(env('p4')); } + #[DataProvider('provideEnvReturnsCorrectTypesWithoutTypeError')] + public function testEnvReturnsCorrectTypesWithoutTypeError(string $source, mixed $value): void + { + $key = 'ci_test_var'; + + if ($source === 'SERVER' || $source === 'BOTH') { + service('superglobals')->setServer($key, $value); + } + + if ($source === 'ENV' || $source === 'BOTH') { + $_ENV[$key] = $value; + } + + $this->assertSame($value, env($key)); + } + + /** + * @return iterable + */ + public static function provideEnvReturnsCorrectTypesWithoutTypeError(): iterable + { + yield 'integer from SERVER' => ['SERVER', 2]; + + yield 'array from SERVER' => ['SERVER', ['spark', 'migrate']]; + + yield 'int 1 is not true' => ['SERVER', 1]; + + yield 'int 0 is not false' => ['SERVER', 0]; + + yield 'float from SERVER' => ['SERVER', 3.14]; + + yield 'integer from ENV' => ['ENV', 42]; + + yield 'CLI simulation BOTH' => ['BOTH', 3]; + } + private function createRouteCollection(): RouteCollection { return new RouteCollection(Services::locator(), new Modules(), new Routing()); @@ -276,6 +313,22 @@ public function testEscapeRecursiveArrayRaw(): void $this->assertSame($data, esc($data, 'raw')); } + public function testEscapeArrayPropagatesEncoding(): void + { + $this->expectException(InvalidArgumentException::class); + // If encoding is not propagated, it would not instantiate the Escaper with the invalid encoding and wouldn't throw. + esc(['test'], 'html', 'invalid-encoding'); + } + + public function testEscapeWithChangingArrayEncoding(): void + { + $data = [hex2bin('E9')]; + + $this->assertSame(['é'], esc($data, 'attr', 'iso-8859-1')); + $this->assertSame(['й'], esc($data, 'attr', 'windows-1251')); + $this->assertSame(['é'], esc($data, 'attr', 'iso-8859-1')); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] #[WithoutErrorHandler] diff --git a/user_guide_src/source/changelogs/v4.7.4.rst b/user_guide_src/source/changelogs/v4.7.4.rst index b0145d2d17af..1373f9fa1720 100644 --- a/user_guide_src/source/changelogs/v4.7.4.rst +++ b/user_guide_src/source/changelogs/v4.7.4.rst @@ -32,6 +32,8 @@ Bugs Fixed - **API:** Fixed a bug in Transformers where the root request's ``fields`` and ``include`` query parameters leaked into nested transformers created inside ``include*()`` methods, causing incorrect field filtering, unexpected includes, or infinite recursion. - **Commands:** Fixed a bug where ``make:model --return entity`` did not preserve sub-namespaces when generating the related Entity class. +- **Common:** Fixed a bug in ``env()`` where a ``TypeError`` could be thrown when non-string values were passed. +- **Common:** Fixed ``esc()`` to propagate encoding correctly and prevent reference leaks. - **Commands:** Fixed a bug where ``spark lang:find`` treated translation keys already provided by the framework or another namespace (such as ``Errors.*`` in ``system/Language``) as new, listing them under ``--show-new`` and writing untranslated placeholders into ``app/Language`` that overrode the existing translations. - **Database:** Fixed a bug where ``updateBatch()`` could be called after Query Builder ``where()`` conditions, even though it's not supported. In this situation, now the ``DatabaseException`` is thrown. - **Filters:** Fixed a bug in ``InvalidChars`` filter where invalid UTF-8 or control characters in array keys were not checked.