Skip to content
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
10 changes: 10 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions src/Env/EnvCacheInterpolation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
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];
Expand All @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion src/Env/EnvCacheTemplates.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
17 changes: 15 additions & 2 deletions src/Env/EnvReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, true>
* @internal
Expand Down Expand Up @@ -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;
}
Expand Down
90 changes: 90 additions & 0 deletions tests/Unit/EnvReaderServerBoundaryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace SymPress\Runtime\Tests\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use SymPress\Runtime\Env\EnvReader;

// phpcs:disable SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable -- The reader owns the superglobal boundary under test.
final class EnvReaderServerBoundaryTest extends TestCase
{
/** @var array<string, mixed> */
private array $server = [];
/** @var array<string, mixed> */
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<string, array{string}> */
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);
}
}
}
Loading