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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0005-compatibility-and-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0025-production-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions docs/adr/0028-production-wordpress-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs/maintainers/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions src/Console/ProductionChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/Env/EnvReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class EnvReader
'WP_INSTALLED' => 'bool',
'WPDB_ENV_VALID' => 'bool',
'WPDB_EXISTS' => 'bool',
'SYMPRESS_ENABLE_WORDPRESS_HARDENING' => 'bool',
];

/** @var array<string, string> */
Expand Down
5 changes: 5 additions & 0 deletions templates/wp-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions tests/Integration/ProductionDoctorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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]);
}
}
Expand Down
Loading
Loading