Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
Show file tree
Hide file tree
Changes from all 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
[Dotenv] Don't truncate external env vars containing $ when reference…
…d via ${...} indirection
  • Loading branch information
nicolas-grekas committed May 28, 2026
commit c1ba946a66665df4874f824c832da4b43b0a71a1
137 changes: 72 additions & 65 deletions 137 src/Symfony/Component/Dotenv/Dotenv.php
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,10 @@ private function resolveVariables(string $value, array $loadedVars): string
$value = (string) getenv($name);
}

if ('' !== $value && !isset($loadedVars[$name])) {
$value = str_replace('$', "\x00", $value);
}

if ('' === $value && isset($matches['default_value']) && '' !== $matches['default_value']) {
$unsupportedChars = strpbrk($matches['default_value'], '\'"{$');
if (false !== $unsupportedChars) {
Expand Down Expand Up @@ -746,92 +750,95 @@ private function resolveLoadedVars(): void
$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 ($rawVars as $name => $_) {
$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 = [];
try {
// 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 ($rawVars as $name => $_) {
if (!str_contains($value = $_ENV[$name] ?? '', '$')) {
continue;
$value = $_ENV[$name] ?? '';
if (str_contains($value, '$') && preg_match('/\$\{?'.preg_quote($name, '/').'(?![A-Za-z0-9_])/', $value)) {
$selfReferencingVars[$name] = true;
}
}

if (isset($selfReferencingVars[$name])) {
$envBackup = $_ENV[$name] ?? null;
$serverBackup = $_SERVER[$name] ?? null;
if (isset($this->overriddenValues[$name])) {
$_ENV[$name] = $this->overriddenValues[$name];
$_SERVER[$name] = $this->overriddenValues[$name];
} else {
unset($_ENV[$name], $_SERVER[$name]);
for ($pass = 0; $pass < 5; ++$pass) {
$resolved = [];
foreach ($rawVars as $name => $_) {
if (!str_contains($value = $_ENV[$name] ?? '', '$')) {
continue;
}
if ($this->usePutenv) {
$getenvBackup = (string) getenv($name);

if (isset($selfReferencingVars[$name])) {
$envBackup = $_ENV[$name] ?? null;
$serverBackup = $_SERVER[$name] ?? null;
if (isset($this->overriddenValues[$name])) {
putenv("$name={$this->overriddenValues[$name]}");
$_ENV[$name] = str_replace('$', "\x00", $this->overriddenValues[$name]);
$_SERVER[$name] = $_ENV[$name];
} else {
putenv($name);
unset($_ENV[$name], $_SERVER[$name]);
}
if ($this->usePutenv) {
$getenvBackup = (string) getenv($name);
if (isset($this->overriddenValues[$name])) {
putenv("$name={$this->overriddenValues[$name]}");
} else {
putenv($name);
}
}
}
}

$resolvedValue = $this->resolveCommands($value, $loadedVars);
$resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars);
$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 (isset($selfReferencingVars[$name])) {
if (null !== $envBackup) {
$_ENV[$name] = $envBackup;
}
if (null !== $serverBackup) {
$_SERVER[$name] = $serverBackup;
}
if ($this->usePutenv) {
putenv("$name=$getenvBackup");
}
}
if ($this->usePutenv) {
putenv("$name=$getenvBackup");

if ($value !== $resolvedValue) {
$resolved[$name] = $resolvedValue;
}
}

if ($value !== $resolvedValue) {
$resolved[$name] = $resolvedValue;
if (!$resolved) {
break;
}
$this->populate($resolved, true);
}
if (!$resolved) {
break;
if (5 === $pass && $resolved) {
throw new VariableCircularReferenceException('Too many levels of variable indirection in env vars: '.implode(', ', array_keys($resolved)).'.');
}
$this->populate($resolved, true);
}
if (5 === $pass && $resolved) {
throw new VariableCircularReferenceException('Too many levels of variable indirection in env vars: '.implode(', ', array_keys($resolved)).'.');
}

// Restore literal $ signs and unescape backslashes
$restored = [];
foreach ($rawVars as $name => $_) {
$value = $_ENV[$name] ?? '';
if ($value !== $newValue = str_replace(["\x00", '\\\\'], ['$', '\\'], $value)) {
$restored[$name] = $newValue;
// Restore literal $ signs and unescape backslashes
$restored = [];
foreach ($rawVars as $name => $_) {
$value = $_ENV[$name] ?? '';
if ($value !== $newValue = str_replace(["\x00", '\\\\'], ['$', '\\'], $value)) {
$restored[$name] = $newValue;
}
}
if ($restored) {
$this->populate($restored, true);
}
}
if ($restored) {
$this->populate($restored, true);
}

if ($this->usePutenv && $this->pendingPutenv) {
foreach ($this->pendingPutenv as $name => $_) {
putenv($name.'='.($_ENV[$name] ?? ''));
if ($this->usePutenv && $this->pendingPutenv) {
foreach ($this->pendingPutenv as $name => $_) {
putenv($name.'='.($_ENV[$name] ?? ''));
}
$this->pendingPutenv = [];
}
} finally {
$this->values = [];
$this->overriddenValues = [];
$this->pendingPutenv = [];
unset($this->path, $this->data, $this->lineno, $this->cursor, $this->end);
}

$this->values = [];
$this->overriddenValues = [];
unset($this->path, $this->data, $this->lineno, $this->cursor, $this->end);
}
}
106 changes: 101 additions & 5 deletions 106 src/Symfony/Component/Dotenv/Tests/DotenvTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,6 @@ public function testLoadDoesNotReResolveAlreadyLoadedVars()

public function testLoadDoesNotResolveExternalEnvVarsOnlyPresentInServer()
{
// Mimics PHP's default `variables_order = "GPCS"` (no `E`) where
// OS-provided environment variables (e.g. from Kubernetes envFrom or
// Docker) are placed in $_SERVER but not in $_ENV when PHP starts.
// Such values must be left untouched by Dotenv even when the same key
// has a default value in the loaded .env file.
unset($_ENV['FOO'], $_SERVER['FOO'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('FOO');
putenv('SYMFONY_DOTENV_VARS');
Expand All @@ -303,6 +298,107 @@ public function testLoadDoesNotResolveExternalEnvVarsOnlyPresentInServer()
}
}

public function testLoadDoesNotTruncateExternalEnvVarReferencedFromDotenv()
{
foreach ([['env' => true, 'server' => true], ['env' => false, 'server' => true]] as $where) {
unset($_ENV['EXT_VAR'], $_SERVER['EXT_VAR'], $_ENV['INDIRECT'], $_SERVER['INDIRECT'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('EXT_VAR');
putenv('INDIRECT');
putenv('SYMFONY_DOTENV_VARS');

if ($where['env']) {
$_ENV['EXT_VAR'] = 'secret$word';
}
if ($where['server']) {
$_SERVER['EXT_VAR'] = 'secret$word';
}

@mkdir($tmpdir = sys_get_temp_dir().'/dotenv');
$path = tempnam($tmpdir, 'sf-');
file_put_contents($path, "INDIRECT=\${EXT_VAR}\n");

try {
(new Dotenv())->load($path);
$this->assertSame('secret$word', $_ENV['INDIRECT']);
$this->assertSame('secret$word', $_SERVER['INDIRECT']);
} finally {
unset($_ENV['EXT_VAR'], $_SERVER['EXT_VAR'], $_ENV['INDIRECT'], $_SERVER['INDIRECT'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('EXT_VAR');
putenv('INDIRECT');
putenv('SYMFONY_DOTENV_VARS');
unlink($path);
@rmdir($tmpdir);
}
}
}

public function testOverloadDoesNotExecuteShellSyntaxFromExternalEnvOnSelfReference()
{
unset($_ENV['FOO'], $_SERVER['FOO'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('FOO');
putenv('SYMFONY_DOTENV_VARS');

$_ENV['FOO'] = $_SERVER['FOO'] = 'value$(id)';

@mkdir($tmpdir = sys_get_temp_dir().'/dotenv');
$path = tempnam($tmpdir, 'sf-');
file_put_contents($path, "FOO=\${FOO:-default}\n");

try {
(new Dotenv())->overload($path);
$this->assertSame('value$(id)', $_ENV['FOO']);
$this->assertSame('value$(id)', $_SERVER['FOO']);
} finally {
unset($_ENV['FOO'], $_SERVER['FOO'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('FOO');
putenv('SYMFONY_DOTENV_VARS');
unlink($path);
@rmdir($tmpdir);
}
}

public function testResolveLoadedVarsClearsStateOnCircularReferenceException()
{
unset($_ENV['A'], $_SERVER['A'], $_ENV['B'], $_SERVER['B'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('A');
putenv('B');
putenv('SYMFONY_DOTENV_VARS');

$_ENV['A'] = $_SERVER['A'] = 'external';

$dotenv = new Dotenv();

@mkdir($tmpdir = sys_get_temp_dir().'/dotenv');
$circular = tempnam($tmpdir, 'sf-');
file_put_contents($circular, "A=\${B}\nB=\${A}x\n");
$selfRef = tempnam($tmpdir, 'sf-');
file_put_contents($selfRef, "A=\${A:-default}\n");

try {
try {
$dotenv->overload($circular);
$this->fail('A VariableCircularReferenceException should have been thrown.');
} catch (VariableCircularReferenceException) {
}

unset($_ENV['A'], $_SERVER['A'], $_ENV['B'], $_SERVER['B'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('A');
putenv('B');
putenv('SYMFONY_DOTENV_VARS');

$dotenv->load($selfRef);
$this->assertSame('default', $_ENV['A']);
} finally {
unset($_ENV['A'], $_SERVER['A'], $_ENV['B'], $_SERVER['B'], $_ENV['SYMFONY_DOTENV_VARS'], $_SERVER['SYMFONY_DOTENV_VARS']);
putenv('A');
putenv('B');
putenv('SYMFONY_DOTENV_VARS');
unlink($circular);
unlink($selfRef);
@rmdir($tmpdir);
}
}

public function testLoadEnv()
{
$resetContext = static function (): void {
Expand Down
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.