diff --git a/docs/environment.md b/docs/environment.md index 8944515..f36f5d9 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -32,6 +32,16 @@ The generic `.env.local` file is skipped for the raw `test` environment. Disable `env-local-overrides` to use only the base and environment-specific files. `env-dir` and `env-file` change the directory and base filename. +Values the web server derives from the request and places in `$_SERVER` are not +configuration. The CGI meta-variables (`REQUEST_URI`, `REQUEST_METHOD`, +`QUERY_STRING`, `PATH_INFO`, `REMOTE_ADDR`, `SERVER_NAME`, `SCRIPT_FILENAME`, +`DOCUMENT_ROOT`, `CONTENT_TYPE`, `HTTPS` and the other exact names of that set) +plus every `HTTP_*`, `REDIRECT_*` and `PHP_AUTH_*` entry are ignored there, for +direct reads as well as for `${NAME}` interpolation. Such a name is read only +when the real process environment or `$_ENV` supplies it. Operator values with +other names, for example `SERVER_ROLE` passed through `fastcgi_param`, are still +read from `$_SERVER`. + ## Select an environment Native selection checks `WP_ENVIRONMENT_TYPE`, then `WP_ENV`, then diff --git a/src/Env/EnvCacheInterpolation.php b/src/Env/EnvCacheInterpolation.php index a96d835..d1d57e0 100644 --- a/src/Env/EnvCacheInterpolation.php +++ b/src/Env/EnvCacheInterpolation.php @@ -28,14 +28,37 @@ final class EnvCacheInterpolation public function parse(Dotenv $dotenv, string $content, string $path, callable $external): array { $this->dotenv = $dotenv; - ($this->expressions ??= new EnvCacheExpressions())->record($content, $path); + $expressions = $this->expressions ??= new EnvCacheExpressions(); + $expressions->record($content, $path); + // Dotenv falls back to $_SERVER itself; request metadata must not reach ${NAME} references. + $hidden = []; + foreach ($expressions->referencesFor($path) as $name) { + if (!EnvReader::isRequestMetaVariable($name) || !array_key_exists($name, $_SERVER)) { + continue; + } + $hidden[$name] = $_SERVER[$name]; + unset($_SERVER[$name]); + } + try { + return $this->parseReferences($expressions, $dotenv, $content, $path, $external); + } finally { + $_SERVER = $hidden + $_SERVER; + } + } + + /** + * @param callable(string): ?string $external + * @return array + */ + private function parseReferences(EnvCacheExpressions $expressions, Dotenv $dotenv, string $content, string $path, callable $external): array + { // The selected source chain cannot lose its history through later overrides. - $this->uncacheable = $this->uncacheable || $this->expressions->hasDynamicEnvironmentSelection(); + $this->uncacheable = $this->uncacheable || $expressions->hasDynamicEnvironmentSelection(); $replacements = []; $externalReferences = []; $loaded = $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? ''; $filePrecedence = array_fill_keys(is_string($loaded) ? explode(',', $loaded) : [], true); - foreach ($this->expressions->referencesFor($path) as $name) { + foreach ($expressions->referencesFor($path) as $name) { $value = $external($name); $marker = $this->dependencies[$name]['marker'] ?? 'SYMPRESS_INTERPOLATION_' . bin2hex(random_bytes(16)); $this->dependencies[$name] = ['marker' => $marker]; @@ -52,7 +75,7 @@ public function parse(Dotenv $dotenv, string $content, string $path, callable $e } $replacements[$marker] = $value; } - $this->expressions->trackExternalReferences($path, $externalReferences, $filePrecedence); + $expressions->trackExternalReferences($path, $externalReferences, $filePrecedence); // Commands may transform secrets and cannot be represented by substitution templates. if (str_contains($content, '$(')) { $values = self::validateValues($dotenv->parse($content, $path)); diff --git a/src/Env/EnvCacheTemplates.php b/src/Env/EnvCacheTemplates.php index 2c6f136..824da31 100644 --- a/src/Env/EnvCacheTemplates.php +++ b/src/Env/EnvCacheTemplates.php @@ -94,7 +94,7 @@ public static function resolve(mixed $plans): array } $name = $reference['name']; $value = isset($loaded[$name], $values[$name]) ? $values[$name] - : ($_ENV[$name] ?? (!str_starts_with($name, 'HTTP_') ? ($_SERVER[$name] ?? null) : null) ?? $values[$name] ?? getenv($name)); + : ($_ENV[$name] ?? (!EnvReader::isRequestMetaVariable($name) ? ($_SERVER[$name] ?? null) : null) ?? $values[$name] ?? getenv($name)); $value = is_scalar($value) ? (string) $value : ''; if ($value === '' && is_string($reference['default'] ?? null)) { $value = $reference['default']; diff --git a/src/Env/EnvReader.php b/src/Env/EnvReader.php index c793df5..37a5d1f 100644 --- a/src/Env/EnvReader.php +++ b/src/Env/EnvReader.php @@ -31,6 +31,12 @@ final class EnvReader public const array WP_CONSTANTS = ConstantCatalog::TYPES; public const array ENV_TYPES = EnvironmentName::ALIASES; public const array WP_STARTER_ENV_VARS = ['WP_ENV', 'WORDPRESS_ENV', 'WP_ENVIRONMENT_TYPE']; + /** + * Request metadata the web server places in $_SERVER (RFC 3875 meta-variables plus the + * usual SAPI additions); never configuration. Exact names only, so operator values + * passed through fastcgi_param/SetEnv with similar prefixes keep working. + */ + private const string REQUEST_META_VARIABLES = '/^(?:HTTP_|REDIRECT_|PHP_AUTH_|(?:AUTH_TYPE|CONTENT_LENGTH|CONTENT_TYPE|CONTEXT_DOCUMENT_ROOT|CONTEXT_PREFIX|DOCUMENT_ROOT|DOCUMENT_URI|FCGI_ROLE|GATEWAY_INTERFACE|HTTPS|ORIG_PATH_INFO|ORIG_PATH_TRANSLATED|ORIG_SCRIPT_FILENAME|ORIG_SCRIPT_NAME|PATH_INFO|PATH_TRANSLATED|PHP_SELF|QUERY_STRING|REMOTE_ADDR|REMOTE_HOST|REMOTE_IDENT|REMOTE_PORT|REMOTE_USER|REQUEST_METHOD|REQUEST_SCHEME|REQUEST_TIME|REQUEST_TIME_FLOAT|REQUEST_URI|SCRIPT_FILENAME|SCRIPT_NAME|SERVER_ADDR|SERVER_ADMIN|SERVER_NAME|SERVER_PORT|SERVER_PROTOCOL|SERVER_SIGNATURE|SERVER_SOFTWARE|argv|argc)$)/'; public const array WP_STARTER_VARS = [ 'WP_STARTER_ENV_TO_CONST' => 'string', 'SYMPRESS_RUNTIME_ENV_TO_CONST' => 'string', @@ -80,7 +86,8 @@ public function __construct(private ?Dotenv $dotenv = null, private readonly str if (!is_string($name) || !is_scalar($value)) { continue; } - if (str_starts_with($name, 'HTTP_')) { + if (self::isRequestMetaVariable($name)) { + // Request metadata is only accepted from the real environment, never from the SAPI. $value = $_ENV[$name] ?? null; if (!is_scalar($value)) { continue; @@ -101,6 +108,12 @@ public function __construct(private ?Dotenv $dotenv = null, private readonly str } } + /** @internal */ + public static function isRequestMetaVariable(string $name): bool + { + return preg_match(self::REQUEST_META_VARIABLES, $name) === 1; + } + /** * @return array * @internal @@ -680,7 +693,7 @@ private function interpolationExternalValue(string $name): ?string if (isset($this->raw[$name]) && !isset($this->nonPersistentNames[$name]) && ($_ENV[$name] ?? null) === $this->raw[$name]) { return null; } - $value = $_ENV[$name] ?? (!str_starts_with($name, 'HTTP_') ? ($_SERVER[$name] ?? null) : null); + $value = $_ENV[$name] ?? (!self::isRequestMetaVariable($name) ? ($_SERVER[$name] ?? null) : null); if (is_scalar($value)) { return (string) $value; } diff --git a/tests/Unit/EnvReaderServerBoundaryTest.php b/tests/Unit/EnvReaderServerBoundaryTest.php new file mode 100644 index 0000000..f9085ec --- /dev/null +++ b/tests/Unit/EnvReaderServerBoundaryTest.php @@ -0,0 +1,90 @@ + */ + private array $server = []; + /** @var array */ + private array $env = []; + + protected function setUp(): void + { + $this->server = $_SERVER; + $this->env = $_ENV; + } + + protected function tearDown(): void + { + $_SERVER = $this->server; + $_ENV = $this->env; + } + + /** @return iterable */ + public static function requestMetaVariables(): iterable + { + foreach (['REQUEST_URI', 'REQUEST_METHOD', 'QUERY_STRING', 'PATH_INFO', 'REDIRECT_WP_HOME', 'HTTP_X_WP_HOME', 'REMOTE_ADDR', 'SERVER_NAME', 'SCRIPT_FILENAME', 'DOCUMENT_ROOT', 'CONTENT_TYPE', 'HTTPS'] as $name) { + yield $name => [$name]; + } + } + + #[DataProvider('requestMetaVariables')] + public function testServerRequestMetadataIsNotReadAsConfiguration(string $name): void + { + unset($_ENV[$name]); + $_SERVER[$name] = 'from-the-request'; + $_SERVER['RTV_SERVER_CONFIG'] = 'from-server-config'; + + $reader = new EnvReader(); + + self::assertNull($reader->read($name)); + self::assertSame('from-server-config', $reader->read('RTV_SERVER_CONFIG')); + } + + public function testRequestMetadataNamesAreStillAcceptedFromTheRealEnvironment(): void + { + unset($_SERVER['REQUEST_URI']); + $_ENV['REQUEST_URI'] = '/from-the-environment'; + + self::assertSame('/from-the-environment', (new EnvReader())->read('REQUEST_URI')); + } + + public function testOperatorNamesWithCgiLikePrefixesRemainConfiguration(): void + { + foreach (['SERVER_ROLE', 'REQUEST_TIMEOUT', 'CONTENT_BUCKET', 'DOCUMENT_STORE'] as $name) { + unset($_ENV[$name]); + $_SERVER[$name] = 'operator-' . $name; + self::assertFalse(EnvReader::isRequestMetaVariable($name)); + self::assertSame('operator-' . $name, (new EnvReader())->read($name)); + } + self::assertFalse(EnvReader::isRequestMetaVariable('DB_PASSWORD')); + self::assertFalse(EnvReader::isRequestMetaVariable('AUTH_KEY')); + } + + public function testInterpolationDoesNotReadRequestMetadataFromServer(): void + { + unset($_ENV['REQUEST_URI']); + putenv('REQUEST_URI'); + $_SERVER['REQUEST_URI'] = '/from-the-request'; + $root = sys_get_temp_dir() . '/sympress-server-boundary-' . bin2hex(random_bytes(6)); + mkdir($root); + try { + file_put_contents($root . '/.env', 'RTV_BASE=${REQUEST_URI:-fallback}' . "\n"); + $reader = new EnvReader(); + $reader->loadFile($root . '/.env'); + + self::assertSame('fallback', $reader->read('RTV_BASE')); + } finally { + unlink($root . '/.env'); + rmdir($root); + } + } +}