From de09c46b22acc04690237eddc5de70aeb539a89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 3 Oct 2026 14:06:27 +0200 Subject: [PATCH 1/2] Apply explicit WordPress security defaults to Composer-managed production --- .../adr/0005-compatibility-and-differences.md | 1 + docs/adr/0025-production-operations.md | 2 + .../0028-production-wordpress-hardening.md | 49 +++++++++++++++++++ docs/adr/README.md | 1 + docs/configuration.md | 2 +- docs/maintainers/parity.md | 1 + src/Console/ProductionChecks.php | 4 ++ src/Env/EnvReader.php | 1 + templates/wp-config.php | 5 ++ tests/Integration/ProductionDoctorTest.php | 6 +-- tests/Integration/ProductionWpConfigTest.php | 18 +++++-- 11 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0028-production-wordpress-hardening.md diff --git a/docs/adr/0005-compatibility-and-differences.md b/docs/adr/0005-compatibility-and-differences.md index a8142dc..a73c56e 100644 --- a/docs/adr/0005-compatibility-and-differences.md +++ b/docs/adr/0005-compatibility-and-differences.md @@ -33,6 +33,7 @@ The feature union stays available. Differences below are scoped behaviors with s | D27 | Explicit health configuration scans tables on every setup; file-update constants are not managed. | Native health scans use explicit doctor commands. Native staging/production receives editable Composer-managed defaults, with existing constants preserved. Legacy defaults and health execution remain unchanged. ADR 0025. | | D28 | Remote downloads have no persistent per-project content lock. | Native downloads pin SHA-256 content identities; explicit update remains possible, and configured hashes still win. Legacy locking is opt-in. ADR 0025. | | D29 | Request-derived URL defaults and opt-in forwarded scheme can trust an untrusted ingress. | Every profile fails closed without explicit canonical WP_HOME in staging/production. Local/development keeps a validated host fallback. Forwarded HTTPS requires configured IP/CIDR trust and a proxy that replaces forwarded headers. | +| D30 | Unfiltered HTML/uploads and outbound HTTP depend on WordPress defaults or project-specific code. | Native Composer-managed staging/production applies explicit secure defaults and a narrow core-host HTTP allowlist. Existing constants/environment overrides and legacy opt-in remain supported. ADR 0028. | | D30 | Runtime caching can serialize real environment overrides and auto can behave as an unconditional enable flag. | Native payloads retain safe file defaults and non-secret interpolation expressions, excluding process-derived and command-derived result bytes from persisted value maps. Unrelated keys remain cached/dumped; dynamic source-chain selectors decline persistence. Auto checks writable parents before shutdown; explicit native true requires writable new-cache storage. Legacy boolean behavior is retained. | Compatibility is enabled by default for legacy detection and independently switchable off. It is not a dependency on wecodemore. Use native names internally. Migration reads without executing PHP command providers; it writes a new config atomically, retains the original, refuses conflicting targets unless forced and lists exact source locations needing PHP changes. A second migration is a no-op. diff --git a/docs/adr/0025-production-operations.md b/docs/adr/0025-production-operations.md index 0e07da3..e44ffd3 100644 --- a/docs/adr/0025-production-operations.md +++ b/docs/adr/0025-production-operations.md @@ -8,6 +8,8 @@ Native configuration uses automatic writable-only environment caching and Composer-managed staging/production defaults. Legacy profiles retain boolean cache defaults, explicit invalidation and automatic health scans when configured; their file-update defaults are unchanged. ADR 0023 covers environment payloads. +The additional unfiltered-content/upload and outbound HTTP defaults are defined +in [ADR 0028](0028-production-wordpress-hardening.md). Table scanning moves to explicit native `doctor --database-health`, with optional `--quick`. Normal preflight still checks connectivity and installation state. diff --git a/docs/adr/0028-production-wordpress-hardening.md b/docs/adr/0028-production-wordpress-hardening.md new file mode 100644 index 0000000..6e0287e --- /dev/null +++ b/docs/adr/0028-production-wordpress-hardening.md @@ -0,0 +1,49 @@ +# ADR 0028: Composer-managed WordPress security defaults + +Status: implemented; candidate verification is recorded in the change review. + +## Decision + +The generated `COMPOSER_MANAGED` section enables +`SYMPRESS_ENABLE_WORDPRESS_HARDENING`, `DISALLOW_UNFILTERED_HTML` and +`WP_HTTP_BLOCK_EXTERNAL`, and disables `ALLOW_UNFILTERED_UPLOADS`. It uses the +same native staging/production `auto` selection as the existing file update +defaults. Explicit `composer-managed=true` also applies these defaults locally; +`false`, local/development `auto`, and legacy profile defaults preserve their +existing opt-in behavior. Existing constants and explicitly typed environment +values remain authoritative. + +`SYMPRESS_ENABLE_WORDPRESS_HARDENING` is a typed boolean Runtime environment +value and a generated default constant. The switch activates only a consumer +that implements it; Runtime does not import or install a Security package or +register its WordPress hooks. Bootstrap acceptance must verify the consumer's +actual behavior separately. + +`WP_ACCESSIBLE_HOSTS` defaults to `api.wordpress.org,downloads.wordpress.org`. +The API and download hosts are exact approved core hosts, without a wildcard. +An operator-provided constant or environment value replaces the complete list. +Operators must retain required core hosts and append approved Wordfence, +payment, licensing, SMTP API and other integration hosts explicitly. For example: + +```dotenv +WP_ACCESSIBLE_HOSTS=api.wordpress.org,downloads.wordpress.org,api.payments.example +``` + +The WordPress HTTP API permits the local/site host independently. This constant +is an application HTTP API boundary, not a firewall for raw cURL, SMTP or other +network clients. See the official +[WordPress HTTP request policy](https://developer.wordpress.org/reference/classes/wp_http/block_request/). + +Strict `doctor --production` checks the effective four boolean values. Explicit +unsafe overrides remain executable for compatibility but fail production +preflight. Modified/opaque generated PHP still returns unknown instead of +assuming the default is active. Operator-specific external host approval and +actual hardening hook registration require application acceptance evidence. + +## Verification + +`ProductionWpConfigTest` covers native auto/on/off and environment/predefined +constant overrides, an operator HTTP allowlist, and real WordPress cold/warm +production boot. `ProductionDoctorTest` covers generated defaults and explicit +unsafe exceptions. The private `sympress/security` package remains independent; +no Starter or Demo integration follows from this decision. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9ece439..33cac9a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -6,6 +6,7 @@ - [0024: Crash recovery journal and owned artifact pruning](0024-layout-journal.md) - [0025: Read-only operations and native deployment defaults](0025-production-operations.md) - [0027: Protected release orchestration and bootstrap security](0027-release-trust-and-bootstrap-security.md) +- [0028: Composer-managed WordPress security defaults](0028-production-wordpress-hardening.md) - [0026: Console command discovery](0026-console-command-discovery.md) The project owner accepted these ADRs with explicit Phase 0 approval on 2026-09-29. The package name is `sympress/runtime`. diff --git a/docs/configuration.md b/docs/configuration.md index 2ea0e82..e7df9a7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,7 +71,7 @@ Compatibility profiles can use different defaults. | `autoload` | `"sympress-runtime-autoload.php"` | PHP file loaded only when setup runs. | | `cache-env` | `"auto"` | Automatic writable-only caching; explicit native `true` requires writability for new caches. `false` disables cache reads and writes. Native caches invalidate when source paths, sizes or modification times change. | | `generated-file-mode` | `"0600"` | Private generated PHP/cache/dump permissions; `"0640"` permits a shared deployment/PHP group. No world access is accepted. | -| `composer-managed` | `"auto"` | Protect Composer-managed files from dashboard updates in staging/production; `true` enables it everywhere, `false` disables the section. | +| `composer-managed` | `"auto"` | Protect Composer-managed files and apply WordPress hardening/upload/outbound HTTP defaults in staging/production; `true` enables it everywhere, `false` disables the section. | | `required-env` | `{}` | Required nonempty environment names mapped to `string`, `int`, `bool` or `float`, checked by validate, doctor and dump-env. | | `download-lock` | `true` | Pin URL-download SHA-256 hashes in `sympress-runtime.lock`; see [Downloads](downloads.md). | | `wp-cli-version` | `null` | Exact WP-CLI download release (at least 2.5.0); an installed Composer bundle takes priority. | diff --git a/docs/maintainers/parity.md b/docs/maintainers/parity.md index d83250a..1648243 100644 --- a/docs/maintainers/parity.md +++ b/docs/maintainers/parity.md @@ -181,6 +181,7 @@ Native default order is the twelve-step sequence in the work order. Actual upstr | Development defaults | R/D template DEFAULT_ENV | DEBUG=true, DEBUG_DISPLAY=true, DEBUG_LOG=false, SAVEQUERIES=true, SCRIPT_DEBUG=true, DISABLE_FATAL_ERROR_HANDLER=true (WP_ prefix where applicable). | Generation/WpConfig | `PAR-WP-005` | verified | Parity | | Staging defaults | R/D template DEFAULT_ENV | DEBUG=true, DISPLAY=false, LOG=true, SAVEQUERIES=false, SCRIPT_DEBUG=true; do not implicitly set fatal handler flag. | Generation/WpConfig | `PAR-WP-006` | verified | Parity | | Production defaults | R/D template DEFAULT_ENV | DEBUG=false, DISPLAY=false, LOG=false, SAVEQUERIES=false, SCRIPT_DEBUG=false; unknown canonical defaults production. | Generation/WpConfig | `PAR-WP-007` | verified | Parity | +| Composer-managed security defaults | W; template COMPOSER_MANAGED | Hardening switch=true, unfiltered HTML disallowed, unfiltered uploads=false, external HTTP blocked with exact core API/download hosts; preserve explicit environment/constants and local/legacy modes. | ProductionWpConfigTest; ProductionDoctorTest | `PAR-WP-018` | verified | D30; ADR 0028 | | Home/site/content URLs | R/D template URL_CONSTANTS; WpConfigStep::stripDot | Local/development retains validated SERVER_NAME/port fallback; staging/production requires explicit canonical HTTP(S) WP_HOME; preserve valid existing URL constants; relative core/content and trailing slashes. | Generation/WpConfig | `PAR-WP-008` | verified | D29 | | Forwarded SSL | R/D template SSL_FIX | Explicit WP_FORCE_SSL_FORWARDED_PROTO plus configured trusted proxy IP/CIDR and exact case-insensitive https header sets HTTPS=on; untrusted peers and ambiguous chains fail closed. | Generation/WpConfig | `PAR-WP-009` | verified | D29 | | DB defaults | R/D template DB_SETUP | DB_HOST localhost, DB_CHARSET utf8, DB_COLLATE empty; DB_NAME/USER/PASSWORD supplied externally, never invented; prefix via Env reader. | Generation/WpConfig | `PAR-WP-010` | verified | Parity | diff --git a/src/Console/ProductionChecks.php b/src/Console/ProductionChecks.php index aa4cdd7..1fef14a 100644 --- a/src/Console/ProductionChecks.php +++ b/src/Console/ProductionChecks.php @@ -45,6 +45,10 @@ public function inspect(EnvReader $env, bool $dump, ?string $phpUser = null, ?st $record('production.proxy-trust', $this->hasProxyTrust($env), 'A forwarded scheme header or enabled forwarded SSL requires explicit trusted proxy addresses or CIDRs.'); $record('production.file-mods', $this->effectiveBoolean($env, 'DISALLOW_FILE_MODS', true, $managedDefaults, $predefined, $opaque), 'Effective DISALLOW_FILE_MODS must be true, explicitly or through the generated Composer-managed defaults.'); $record('production.auto-updates', $this->both($this->effectiveBoolean($env, 'AUTOMATIC_UPDATER_DISABLED', true, $managedDefaults, $predefined, $opaque), $this->effectiveBoolean($env, 'WP_AUTO_UPDATE_CORE', false, $managedDefaults, $predefined, $opaque)), 'Effective automatic updates must be disabled, explicitly or through Composer-managed defaults.'); + $record('production.wordpress-hardening', $this->effectiveBoolean($env, 'SYMPRESS_ENABLE_WORDPRESS_HARDENING', true, $managedDefaults, $predefined, $opaque), 'The WordPress hardening switch must be enabled; activation of its consumer remains a separate bootstrap acceptance check.'); + $record('production.unfiltered-html', $this->effectiveBoolean($env, 'DISALLOW_UNFILTERED_HTML', true, $managedDefaults, $predefined, $opaque), 'Effective DISALLOW_UNFILTERED_HTML must be true.'); + $record('production.unfiltered-uploads', $this->effectiveBoolean($env, 'ALLOW_UNFILTERED_UPLOADS', false, $managedDefaults, $predefined, $opaque), 'Effective ALLOW_UNFILTERED_UPLOADS must be false.'); + $record('production.external-http', $this->effectiveBoolean($env, 'WP_HTTP_BLOCK_EXTERNAL', true, $managedDefaults, $predefined, $opaque), 'Effective WP_HTTP_BLOCK_EXTERNAL must be true; approve required integration hosts explicitly through WP_ACCESSIBLE_HOSTS.'); $record('production.commands', !(new EnvironmentFiles($this->config, $this->paths))->containsCommands(), 'Environment source files must not contain shell command substitutions.'); $public = Path::canonicalize($webroot === null ? $this->paths->wpParent() : Path::makeAbsolute($webroot, $this->paths->root())); $files = (new EnvironmentFiles($this->config, $this->paths))->activeFiles(); diff --git a/src/Env/EnvReader.php b/src/Env/EnvReader.php index 67683b1..f1b3ffe 100644 --- a/src/Env/EnvReader.php +++ b/src/Env/EnvReader.php @@ -40,6 +40,7 @@ final class EnvReader 'WP_INSTALLED' => 'bool', 'WPDB_ENV_VALID' => 'bool', 'WPDB_EXISTS' => 'bool', + 'SYMPRESS_ENABLE_WORDPRESS_HARDENING' => 'bool', ]; /** @var array */ diff --git a/templates/wp-config.php b/templates/wp-config.php index 8294e53..46342e6 100644 --- a/templates/wp-config.php +++ b/templates/wp-config.php @@ -166,6 +166,11 @@ function wpstarter_getenv(?string $name): bool|int|float|string|object|null defined('DISALLOW_FILE_MODS') || define('DISALLOW_FILE_MODS', true); defined('AUTOMATIC_UPDATER_DISABLED') || define('AUTOMATIC_UPDATER_DISABLED', true); defined('WP_AUTO_UPDATE_CORE') || define('WP_AUTO_UPDATE_CORE', false); + defined('SYMPRESS_ENABLE_WORDPRESS_HARDENING') || define('SYMPRESS_ENABLE_WORDPRESS_HARDENING', $envLoader->read('SYMPRESS_ENABLE_WORDPRESS_HARDENING') ?? true); + defined('DISALLOW_UNFILTERED_HTML') || define('DISALLOW_UNFILTERED_HTML', true); + defined('ALLOW_UNFILTERED_UPLOADS') || define('ALLOW_UNFILTERED_UPLOADS', false); + defined('WP_HTTP_BLOCK_EXTERNAL') || define('WP_HTTP_BLOCK_EXTERNAL', true); + defined('WP_ACCESSIBLE_HOSTS') || define('WP_ACCESSIBLE_HOSTS', 'api.wordpress.org,downloads.wordpress.org'); } if ({{{PROFILE}}} === 'native' && WP_ENVIRONMENT_TYPE === 'production' && str_starts_with(strtolower(WP_HOME), 'https://')) { defined('FORCE_SSL_ADMIN') || define('FORCE_SSL_ADMIN', true); diff --git a/tests/Integration/ProductionDoctorTest.php b/tests/Integration/ProductionDoctorTest.php index c3a2054..68a9b49 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -63,7 +63,7 @@ public function testGeneratedDefaultsPassWithoutExecutingConfigOrLeakingSecrets( chmod($this->root . '/.env.example', 0644); [$exit, $checks, $output] = $this->diagnose(); self::assertSame(0, $exit, $output); - foreach (['production.home', 'production.debug', 'production.debug-display', 'production.file-mods', 'production.auto-updates', 'production.salts'] as $name) { + foreach (['production.home', 'production.debug', 'production.debug-display', 'production.file-mods', 'production.auto-updates', 'production.salts', 'production.wordpress-hardening', 'production.unfiltered-html', 'production.unfiltered-uploads', 'production.external-http'] as $name) { self::assertSame('pass', $checks[$name]); } self::assertFileDoesNotExist($this->root . '/executed'); @@ -73,11 +73,11 @@ public function testGeneratedDefaultsPassWithoutExecutingConfigOrLeakingSecrets( public function testBadHttpsDebugAndExplicitHardeningOverridesFail(): void { $this->fixture(); - file_put_contents($this->root . '/.env', "WP_HOME=http://example.test\nWP_DEBUG=true\nWP_DEBUG_DISPLAY=true\nDISALLOW_FILE_MODS=false\n", FILE_APPEND); + file_put_contents($this->root . '/.env', "WP_HOME=http://example.test\nWP_DEBUG=true\nWP_DEBUG_DISPLAY=true\nDISALLOW_FILE_MODS=false\nSYMPRESS_ENABLE_WORDPRESS_HARDENING=false\nDISALLOW_UNFILTERED_HTML=false\nALLOW_UNFILTERED_UPLOADS=true\nWP_HTTP_BLOCK_EXTERNAL=false\n", FILE_APPEND); $this->dump(); [$exit, $checks] = $this->diagnose(); self::assertSame(1, $exit); - foreach (['production.home', 'production.debug', 'production.debug-display', 'production.file-mods'] as $name) { + foreach (['production.home', 'production.debug', 'production.debug-display', 'production.file-mods', 'production.wordpress-hardening', 'production.unfiltered-html', 'production.unfiltered-uploads', 'production.external-http'] as $name) { self::assertSame('fail', $checks[$name]); } } diff --git a/tests/Integration/ProductionWpConfigTest.php b/tests/Integration/ProductionWpConfigTest.php index 2dc2ae3..67853d8 100644 --- a/tests/Integration/ProductionWpConfigTest.php +++ b/tests/Integration/ProductionWpConfigTest.php @@ -46,7 +46,7 @@ function apply_filters($name, $value, ...$args) { $items = $GLOBALS['filters'][$ /** @return array */ private function environment(): array { - return array_fill_keys(['WP_ENV', 'WP_ENVIRONMENT_TYPE', 'WORDPRESS_ENV', 'SYMFONY_DOTENV_VARS', 'WPSTARTER_ENV_LOADED', 'SYMPRESS_RUNTIME_ENV_LOADED', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'WP_HOME', 'WP_DEBUG', 'WP_DEVELOPMENT_MODE', 'DISALLOW_FILE_MODS', 'AUTOMATIC_UPDATER_DISABLED', 'WP_AUTO_UPDATE_CORE', 'FORCE_SSL_ADMIN'], false); + return array_fill_keys(['WP_ENV', 'WP_ENVIRONMENT_TYPE', 'WORDPRESS_ENV', 'SYMFONY_DOTENV_VARS', 'WPSTARTER_ENV_LOADED', 'SYMPRESS_RUNTIME_ENV_LOADED', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'WP_HOME', 'WP_DEBUG', 'WP_DEVELOPMENT_MODE', 'DISALLOW_FILE_MODS', 'AUTOMATIC_UPDATER_DISABLED', 'WP_AUTO_UPDATE_CORE', 'FORCE_SSL_ADMIN', 'SYMPRESS_ENABLE_WORDPRESS_HARDENING', 'DISALLOW_UNFILTERED_HTML', 'ALLOW_UNFILTERED_UPLOADS', 'WP_HTTP_BLOCK_EXTERNAL', 'WP_ACCESSIBLE_HOSTS'], false); } /** @return array */ @@ -74,11 +74,13 @@ public static function managedModes(): iterable } #[DataProvider('managedModes')] + #[Group('PAR-WP-018')] public function testComposerManagedModesSetGeneratedBootstrapDefaults(bool|string $mode, string $environment, bool $protected): void { $this->fixture(['composer-managed' => $mode], $environment); $result = $this->boot('echo json_encode([defined("DISALLOW_FILE_MODS") ? DISALLOW_FILE_MODS : null, defined("AUTOMATIC_UPDATER_DISABLED") ? AUTOMATIC_UPDATER_DISABLED : null, defined("WP_AUTO_UPDATE_CORE") ? WP_AUTO_UPDATE_CORE : null, defined("FORCE_SSL_ADMIN") ? FORCE_SSL_ADMIN : null, $GLOBALS["settings_loaded"]]);'); self::assertSame([$protected ? true : null, $protected ? true : null, $protected ? false : null, $environment === 'production' ? true : null, true], $result); + self::assertSame([$protected ? true : null, $protected ? true : null, $protected ? false : null, $protected ? true : null, $protected ? 'api.wordpress.org,downloads.wordpress.org' : null], $this->boot('echo json_encode([defined("SYMPRESS_ENABLE_WORDPRESS_HARDENING") ? SYMPRESS_ENABLE_WORDPRESS_HARDENING : null, defined("DISALLOW_UNFILTERED_HTML") ? DISALLOW_UNFILTERED_HTML : null, defined("ALLOW_UNFILTERED_UPLOADS") ? ALLOW_UNFILTERED_UPLOADS : null, defined("WP_HTTP_BLOCK_EXTERNAL") ? WP_HTTP_BLOCK_EXTERNAL : null, defined("WP_ACCESSIBLE_HOSTS") ? WP_ACCESSIBLE_HOSTS : null]);')); } public function testExplicitEnvironmentAndPredefinedConstantsOverrideManagedDefaults(): void @@ -88,6 +90,15 @@ public function testExplicitEnvironmentAndPredefinedConstantsOverrideManagedDefa self::assertSame([false, false, true, false], $this->boot('echo json_encode([DISALLOW_FILE_MODS, AUTOMATIC_UPDATER_DISABLED, WP_AUTO_UPDATE_CORE, FORCE_SSL_ADMIN]);', 'define("DISALLOW_FILE_MODS", false); define("AUTOMATIC_UPDATER_DISABLED", false); define("WP_AUTO_UPDATE_CORE", true); define("FORCE_SSL_ADMIN", false);')); } + #[Group('PAR-WP-018')] + public function testHardeningOverridesAndOperatorOutboundHostsSurviveGeneratedDefaults(): void + { + $this->fixture(['composer-managed' => true], extraEnv: "SYMPRESS_ENABLE_WORDPRESS_HARDENING=false\nDISALLOW_UNFILTERED_HTML=false\nALLOW_UNFILTERED_UPLOADS=true\nWP_HTTP_BLOCK_EXTERNAL=false\nWP_ACCESSIBLE_HOSTS=api.wordpress.org,api.payments.example\n"); + $report = 'echo json_encode([SYMPRESS_ENABLE_WORDPRESS_HARDENING, DISALLOW_UNFILTERED_HTML, ALLOW_UNFILTERED_UPLOADS, WP_HTTP_BLOCK_EXTERNAL, WP_ACCESSIBLE_HOSTS]);'; + self::assertSame([false, false, true, false, 'api.wordpress.org,api.payments.example'], $this->boot($report)); + self::assertSame([true, true, false, true, 'api.operator.example'], $this->boot($report, "define('SYMPRESS_ENABLE_WORDPRESS_HARDENING', true); define('DISALLOW_UNFILTERED_HTML', true); define('ALLOW_UNFILTERED_UPLOADS', false); define('WP_HTTP_BLOCK_EXTERNAL', true); define('WP_ACCESSIBLE_HOSTS', 'api.operator.example')")); + } + public function testComposerManagedSectionCanBeRemovedThroughEditor(): void { $this->fixture(['composer-managed' => true]); @@ -123,6 +134,7 @@ public function testBundledProductionBootCreatesGroupReadableCacheAndWarmBootAvo #[Group('wordpress')] #[Group('database')] + #[Group('PAR-WP-018')] public function testRealWordPressShortInitBootsColdAndWarmFromBundledProductionCache(): void { $core = getenv('RUNTIME_TEST_WORDPRESS_DIR'); @@ -156,14 +168,14 @@ public function testRealWordPressShortInitBootsColdAndWarmFromBundledProductionC require __DIR__ . '/wp-config.php'; $debug = apply_filters('debug_information', []); $parsed = count(array_filter(get_included_files(), static fn (string $file): bool => str_ends_with($file, '/Dotenv/Dotenv.php'))) > 0; -echo json_encode([$debug['sympress-runtime']['fields']['cached-env']['debug'], $parsed, (string) $wpdb->get_var('SELECT 1'), WP_ENVIRONMENT_TYPE, DISALLOW_FILE_MODS, FORCE_SSL_ADMIN, class_exists('Composer\Autoload\ClassLoader', false), sympress_runtime_getenv('RTV_VALUE')]); +echo json_encode([$debug['sympress-runtime']['fields']['cached-env']['debug'], $parsed, (string) $wpdb->get_var('SELECT 1'), WP_ENVIRONMENT_TYPE, DISALLOW_FILE_MODS, FORCE_SSL_ADMIN, class_exists('Composer\Autoload\ClassLoader', false), sympress_runtime_getenv('RTV_VALUE'), SYMPRESS_ENABLE_WORDPRESS_HARDENING, DISALLOW_UNFILTERED_HTML, ALLOW_UNFILTERED_UPLOADS, WP_HTTP_BLOCK_EXTERNAL, WP_ACCESSIBLE_HOSTS]); PHP); $environment = array_replace($this->environment(), ['DB_HOST' => $host, 'DB_USER' => $user, 'DB_PASSWORD' => $password, 'DB_NAME' => $database, 'WP_HOME' => 'https://fixture.invalid']); foreach ([false, true] as $warm) { $probe = new Process([PHP_BINARY, $this->root . '/real-probe.php'], $this->root, $environment); $probe->run(); self::assertSame(0, $probe->getExitCode(), $probe->getErrorOutput()); - self::assertSame([$warm, !$warm, '1', 'production', true, true, false, 'real-wordpress'], json_decode($probe->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + self::assertSame([$warm, !$warm, '1', 'production', true, true, false, 'real-wordpress', true, true, false, true, 'api.wordpress.org,downloads.wordpress.org'], json_decode($probe->getOutput(), true, flags: JSON_THROW_ON_ERROR)); self::assertFileExists($this->root . '/.env.cached.php'); self::assertSame(0640, fileperms($this->root . '/.env.cached.php') & 0777); } From 289cdc1901ef4b33ef29827d295f395647a0a64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 3 Oct 2026 14:15:32 +0200 Subject: [PATCH 2/2] Document production hardening upgrade in 1.2.0 release notes --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3502777..6cf9bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.2.0 — 2026-10-03 + +- Enable WordPress hardening, disallow unfiltered HTML/uploads and block outbound WordPress HTTP by default in Composer-managed production. Only `api.wordpress.org,downloads.wordpress.org` are admitted until an operator supplies a complete project-specific allowlist. +- Preserve explicit supported overrides and the native profile; production doctor rejects unsafe hardening, upload, HTML and outbound-HTTP overrides. +- Includes the previously unreleased 1.1.4 interpolation-cache, offline Roots installation and production diagnostics fixes listed below. +- Upgrade note: review API-host needs before deploying. Starter now separates its optional legacy content cleanup from hardening; templates that copy older MU hooks must make the equivalent split before enabling production hardening. + ## 1.1.4 — 2026-10-02 - Cache native process interpolation as Dotenv-produced templates with validated dependency fingerprints, keeping derived process secrets out of persisted values.