From a451c42d55243c9794e638f9ce698522031109bc Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 16 Mar 2026 15:58:12 +0100 Subject: [PATCH] [Dotenv] Fix self-referencing variables with defaults and env key resolution during deferred expansion --- Dotenv.php | 87 ++++++++++++++++++++++++++++++++++++++++++++ Tests/DotenvTest.php | 84 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/Dotenv.php b/Dotenv.php index 42fe27d..091ce01 100644 --- a/Dotenv.php +++ b/Dotenv.php @@ -113,11 +113,16 @@ final class Dotenv if (null === $env = $_SERVER[$k] ?? $_ENV[$k] ?? null) { $this->populate([$k => $env = $defaultEnv], $overrideExistingVars); + } elseif (str_contains($env, '$') || str_contains($env, "\x00") || str_contains($env, '\\')) { + $env = $this->resolveEnvKey($env, $k); } if (!\in_array($env, $testEnvs, true) && is_file($p = "$path.local")) { $this->doLoad($overrideExistingVars, [$p]); $env = $_SERVER[$k] ?? $_ENV[$k] ?? $env; + if (str_contains($env, '$') || str_contains($env, "\x00") || str_contains($env, '\\')) { + $env = $this->resolveEnvKey($env, $k); + } } if ('local' === $env) { @@ -643,6 +648,50 @@ final class Dotenv } } + /** + * Eagerly resolves a raw env key value so that loadEnv() can determine + * which additional .env files to load before full deferred resolution. + */ + private function resolveEnvKey(string $value, string $name): string + { + $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); + unset($loadedVars['']); + + // Save and clear own value so self-referencing defaults work + $envBackup = $_ENV[$name] ?? null; + $serverBackup = $_SERVER[$name] ?? null; + unset($_ENV[$name], $_SERVER[$name]); + if ($this->usePutenv) { + $getenvBackup = (string) getenv($name); + putenv($name); + } + + $this->values = []; + $this->path = ''; + $this->data = ''; + $this->lineno = 0; + $this->cursor = 0; + $this->end = 0; + + $resolved = $this->resolveCommands($value, $loadedVars); + $resolved = $this->resolveVariables($resolved, $loadedVars); + $resolved = str_replace(["\x00", '\\\\'], ['$', '\\'], $resolved); + + if (null !== $envBackup) { + $_ENV[$name] = $envBackup; + } + if (null !== $serverBackup) { + $_SERVER[$name] = $serverBackup; + } + if ($this->usePutenv) { + putenv("$name=$getenvBackup"); + } + + $this->values = []; + + return $resolved; + } + private function resolveLoadedVars(): void { $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); @@ -655,6 +704,20 @@ final class Dotenv $this->cursor = 0; $this->end = 0; + // Detect variables that were originally defined as self-referencing + // (e.g. MY_VAR="${MY_VAR:-default}") so their own raw value is hidden + // during resolution, allowing the default to trigger correctly. + $selfReferencingVars = []; + foreach ($loadedVars as $name => $_) { + if ('SYMFONY_DOTENV_VARS' === $name) { + continue; + } + $value = $_ENV[$name] ?? ''; + if (str_contains($value, '$') && preg_match('/\$\{?'.preg_quote($name, '/').'(?![A-Za-z0-9_])/', $value)) { + $selfReferencingVars[$name] = true; + } + } + for ($pass = 0; $pass < 5; ++$pass) { $resolved = []; foreach ($loadedVars as $name => $_) { @@ -664,8 +727,32 @@ final class Dotenv if (!str_contains($value = $_ENV[$name] ?? '', '$')) { continue; } + + if (isset($selfReferencingVars[$name])) { + $envBackup = $_ENV[$name] ?? null; + $serverBackup = $_SERVER[$name] ?? null; + unset($_ENV[$name], $_SERVER[$name]); + if ($this->usePutenv) { + $getenvBackup = $this->usePutenv ? (string) getenv($name) : null; + putenv($name); + } + } + $resolvedValue = $this->resolveCommands($value, $loadedVars); $resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars); + + if (isset($selfReferencingVars[$name])) { + if (null !== $envBackup) { + $_ENV[$name] = $envBackup; + } + if (null !== $serverBackup) { + $_SERVER[$name] = $serverBackup; + } + if ($this->usePutenv) { + putenv("$name=$getenvBackup"); + } + } + if ($value !== $resolvedValue) { $resolved[$name] = $resolvedValue; } diff --git a/Tests/DotenvTest.php b/Tests/DotenvTest.php index 881f680..0184335 100644 --- a/Tests/DotenvTest.php +++ b/Tests/DotenvTest.php @@ -545,6 +545,90 @@ class DotenvTest extends TestCase rmdir($tmpdir); } + public function testLoadEnvSelfReferencingVariableWithDefault() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['MY_VAR'], $_ENV['TEST_APP_ENV']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['MY_VAR'], $_SERVER['TEST_APP_ENV']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('MY_VAR'); + putenv('TEST_APP_ENV'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path = tempnam($tmpdir, 'sf-'); + + // Self-referencing variable with default value + file_put_contents($path, 'MY_VAR="${MY_VAR:-default_value}"'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('default_value', getenv('MY_VAR')); + + // When host env is set, it should take precedence + $resetContext(); + putenv('MY_VAR=host_value'); + $_ENV['MY_VAR'] = 'host_value'; + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('host_value', getenv('MY_VAR')); + + // Self-referencing variable with := (assign default) + file_put_contents($path, 'MY_VAR="${MY_VAR:=fallback}"'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('fallback', getenv('MY_VAR')); + + $resetContext(); + putenv('MY_VAR'); + unlink($path); + @rmdir($tmpdir); + } + + public function testLoadEnvSelfReferencingEnvKeyControlsFileLoading() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['TEST_APP_ENV'], $_ENV['FOO']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['TEST_APP_ENV'], $_SERVER['FOO']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('TEST_APP_ENV'); + putenv('FOO'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path = tempnam($tmpdir, 'sf-'); + + // APP_ENV with self-referencing default must control which .env files are loaded + file_put_contents($path, 'TEST_APP_ENV="${TEST_APP_ENV:-dev}"'."\nFOO=bar"); + file_put_contents("$path.dev", 'FOO=devbar'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('dev', getenv('TEST_APP_ENV')); + $this->assertSame('devbar', getenv('FOO')); + + // Host env should override the default and control file loading + $resetContext(); + file_put_contents("$path.prod", 'FOO=prodbar'); + putenv('TEST_APP_ENV=prod'); + $_ENV['TEST_APP_ENV'] = 'prod'; + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('prod', getenv('TEST_APP_ENV')); + $this->assertSame('prodbar', getenv('FOO')); + + $resetContext(); + putenv('TEST_APP_ENV'); + @unlink("$path.dev"); + @unlink("$path.prod"); + unlink($path); + @rmdir($tmpdir); + } + public function testLoadEnvThrowsOnCircularVariableReferences() { $resetContext = static function (): void {