diff --git a/CHANGELOG.md b/CHANGELOG.md index 68dd3a3..f9460fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.2.3 — 2026-10-05 + +- Preserve existing outbound WordPress HTTP integrations in 1.x: production and staging no longer enable `WP_HTTP_BLOCK_EXTERNAL` automatically. Explicit environment/constant opt-in retains operator host overrides and the Composer-managed core-host fallback. +- Validate a non-empty outbound host list without a global wildcard when blocking is enabled. Doctor reports compatibility-preserving disabled blocking separately from host restrictions. +- Report the WordPress hardening configuration switch separately from unverified consumer hook activation. Runtime preflight does not require or install the private Security package. +- Align discovery paths and read-only cache selection with Kernel 1.1.5's `discovery-packages.json` and `meta.json`; legacy PHP metadata is never executed or used to select an unwritable cache. +- Retire safe stale default JSON and legacy PHP discovery files after a configured cache override, while preserving unsafe generations that selected the private fallback and validating all targets before deletion. +- Require Kernel 1.1.5 or newer only when the optional kernel is installed. Update the Runtime/Kernel pair together and warm the selected JSON cache before read-only deployment. +- Match Kernel's private temporary fallback for classic document roots and cold read-only projects, including process ownership without ext-posix. Doctor selects it without creating cache directories; maintenance validates its owner and protected ancestors and preserves legacy default generations. +- Upgrade note: regenerate the managed configuration section and inspect preserved customized sections; an unchanged 1.2.0–1.2.2 generated file still contains its earlier HTTP default. + ## 1.2.2 — 2026-10-03 - Align read-only Doctor and explicit cache maintenance with Kernel 1.1.4's selected private fallback, configured cache/build roots and discovery metadata. diff --git a/README.md b/README.md index d567721..3e761e6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ install or update. The same setup is available as a standalone command. Runtime does not require the SymPress kernel. If your application uses it, the [kernel integration](docs/kernel-integration.md) adds diagnostics, environment dumps -and explicit cache maintenance to `wp console`. +and explicit cache maintenance to `wp console`. Runtime 1.2.3 requires Kernel +1.1.5 or newer when that optional package is installed; update both packages +together as described in the [1.2.3 upgrade notes](docs/releases/1.2.3.md). ## Requirements diff --git a/composer.json b/composer.json index 7b45983..3897cc2 100644 --- a/composer.json +++ b/composer.json @@ -55,11 +55,11 @@ }, "require-dev": { "phpunit/phpunit": "^13.0", - "sympress/kernel": "^1.1.4", + "sympress/kernel": "^1.1.5", "sympress/qa": "^0.1.2" }, "conflict": { - "sympress/kernel": "<1.1.4", + "sympress/kernel": "<1.1.5", "symfony/event-dispatcher-contracts": "<3.4.2" }, "autoload": { diff --git a/docs/adr/0005-compatibility-and-differences.md b/docs/adr/0005-compatibility-and-differences.md index a73c56e..7c0fac9 100644 --- a/docs/adr/0005-compatibility-and-differences.md +++ b/docs/adr/0005-compatibility-and-differences.md @@ -33,7 +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 | Unfiltered HTML/uploads and outbound HTTP depend on WordPress defaults or project-specific code. | Native Composer-managed staging/production applies unfiltered-content/upload defaults. Outbound HTTP blocking remains explicit opt-in throughout 1.x, with a narrow core-host fallback when enabled. Existing constants/environment overrides 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/0015-kernel-operations-and-diagnostics.md b/docs/adr/0015-kernel-operations-and-diagnostics.md index 7c1d92d..32d9d5d 100644 --- a/docs/adr/0015-kernel-operations-and-diagnostics.md +++ b/docs/adr/0015-kernel-operations-and-diagnostics.md @@ -6,9 +6,11 @@ Status: implementation decision, 2026-09-29. Implements ADR 0003 and native acce `kernel-cache` is an explicitly selected maintenance step. It resolves the canonical environment through the same WordPress environment mapping used by generated configuration. Default paths are `var/cache/{environment}/kernel`; APP_CACHE_DIR and APP_BUILD_DIR overrides resolve relative to the project or retain their absolute directory. The actual SiteKernel path API is compared in integration tests. If an active App kernel was explicitly constructed for a different environment, cache operations fail rather than selecting another environment silently. -Runtime 1.2.2 uses a pure counterpart of Kernel 1.1.4's location policy, because calling Kernel's resolver can create and log a private fallback. Its implicit selection is `var/cache-private-{effective-user}/{environment}/kernel` when the normal location is public, group/world writable or unavailable. Tests compare selected paths and real compiled warmup with the installed Kernel. Doctor reports fallback selection without creating it and fails unsafe explicit roots. +Runtime 1.2.3 uses a read-only counterpart of Kernel 1.1.5's location policy, because calling Kernel's resolver can create and log a private fallback. Its first implicit choice is `var/cache-private-{effective-user}/{environment}/kernel` when the normal location is public, group/world writable or unavailable. If that fallback is public or unwritable, the selected root is `{canonical-system-temp}/sympress-kernel-{effective-user}-{24-character-canonical-project-hash}`. Process ownership without ext-posix is verified with the UID of a temporary-file probe, matching Kernel; no cache directory is created by inspection. Existing read-only generations are selected by the presence of `meta.json`; legacy `meta.php` cannot select an unwritable location. Tests compare selected paths, JSON discovery writes and real compiled warmup with the installed Kernel, including classic document roots, cold read-only projects and ext-posix absence. Doctor reports fallback selection without creating it and fails unsafe explicit roots. -All targets are checked before deletion. Cache/build directories are cleared without following child symlinks. In-project targets retain the generic project boundary. Explicit external cache/build roots have a separate boundary: the root must already exist with mode 0700, its owner must match the current PHP identity, and root/ancestor symlinks, parent traversal, foreign descendant owners and group/world writable directories fail. A safe default `discovery-packages.php` is removed when overrides leave legacy metadata there; an unsafe implicit generation that caused fallback is never adopted or deleted. Other environments and sibling asset caches remain intact. This does not change the twelve upstream default steps or run a kernel inside Composer. +All targets are checked before deletion. Cache/build directories are cleared without following child symlinks. In-project targets retain the generic project boundary. Explicit external cache/build roots have a separate boundary: the root must already exist with mode 0700, its owner must match the current PHP identity, and root/ancestor symlinks, parent traversal, foreign descendant owners and group/world writable directories fail. Discovery follows the selected cache as `discovery-packages.json`. Safe default `discovery-packages.json` and legacy `discovery-packages.php` files are removed when overrides leave stale metadata there; an unsafe implicit generation that caused fallback is never adopted or deleted. No legacy PHP metadata is executed. Other environments and sibling asset caches remain intact. This does not change the twelve upstream default steps or run a kernel inside Composer. + +Implicit temporary roots may be absent during diagnostics. Existing roots and selected path components must be owned by the calling identity and must not be symlinked or group/world writable. External ancestors must be protected and owned by the caller or system root; shared writable ancestors require the sticky bit. Maintenance uses that selected temporary root, never the project-local default as a deletion boundary. Explicit external overrides retain their existing mode-0700 requirement. Build IDs remain unchanged unless `kernel-build-id` is configured or `--generate-build-id` is used with explicit `kernel-cache` selection. IDs allow 1-128 letters, digits, dots, underscores and hyphens. They are stored atomically in a private `var/runtime/{environment}/kernel-build-id.json`. Generated configuration exposes the ID to the kernel's constant-based reader before boot. Precedence is an existing constant, environment value, configured fallback, persisted ID. Invalid persisted content fails without echoing its value. @@ -20,7 +22,7 @@ An existing boot provider suppresses generation and removes a previous runtime-o ## Console registration -RuntimeBundle is optional. Kernel is a development dependency for real integration tests and a suggested production package, not a runtime requirement for plain WordPress. The bundle registers doctor, check, validate and dump-env in the kernel console without sharing the setup container. Definitions and execution are delegated to the native RuntimeCommand. +RuntimeBundle is optional. Kernel is a development dependency for real integration tests and a suggested production package, not a runtime requirement for plain WordPress. Runtime 1.2.3 conflicts with Kernel versions below 1.1.5 so Composer resolves the pair using the JSON metadata contract. The bundle registers doctor, check, validate and dump-env in the kernel console without sharing the setup container. Definitions and execution are delegated to the native RuntimeCommand. The pinned kernel discovers library metadata but does not automatically activate composer-plugin bundles. Therefore generated configuration registers RuntimeBundle through the kernel's existing public `symfony_register_bundles` filter, after project autoloading is available. The callback checks both optional classes before registration. A project with a custom configuration template can register the bundle in `config/bundles.php` instead. Tests exercise actual WordPress hook functions, actual kernel discovery, a compiled SiteKernel container and native command execution. No kernel source modification is required. diff --git a/docs/adr/0025-production-operations.md b/docs/adr/0025-production-operations.md index e44ffd3..73b4d1d 100644 --- a/docs/adr/0025-production-operations.md +++ b/docs/adr/0025-production-operations.md @@ -8,7 +8,7 @@ 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 +The additional unfiltered-content/upload defaults and explicit outbound HTTP policy are defined in [ADR 0028](0028-production-wordpress-hardening.md). Table scanning moves to explicit native `doctor --database-health`, with optional diff --git a/docs/adr/0028-production-wordpress-hardening.md b/docs/adr/0028-production-wordpress-hardening.md index 6e0287e..a8cd6a7 100644 --- a/docs/adr/0028-production-wordpress-hardening.md +++ b/docs/adr/0028-production-wordpress-hardening.md @@ -1,12 +1,13 @@ # ADR 0028: Composer-managed WordPress security defaults -Status: implemented; candidate verification is recorded in the change review. +Status: implemented; outbound HTTP decision corrected for 1.x compatibility on +2026-10-05. 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 +`SYMPRESS_ENABLE_WORDPRESS_HARDENING` and `DISALLOW_UNFILTERED_HTML`, 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 @@ -19,13 +20,23 @@ 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`. +Outbound WordPress HTTP blocking remains explicit opt-in throughout Runtime 1.x. +The automatic block introduced in 1.2.0–1.2.2 breaks existing payment, licensing +and API integrations on an otherwise ordinary production/staging update. +`composer-managed` and the hardening switch alone therefore do not enable it. +Existing explicit `WP_HTTP_BLOCK_EXTERNAL` environment values and constants +remain authoritative. Stock managed configuration must be regenerated when +upgrading; preserved customized sections require operator review. + +When blocking is explicitly enabled, `WP_ACCESSIBLE_HOSTS` in a generated +Composer-managed section 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_HTTP_BLOCK_EXTERNAL=true WP_ACCESSIBLE_HOSTS=api.wordpress.org,downloads.wordpress.org,api.payments.example ``` @@ -34,16 +45,25 @@ 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. +Strict `doctor --production` checks the effective hardening switch and the two +unfiltered-content/upload boolean values. Its `wordpress-hardening-switch` result +establishes only configuration. A separate `wordpress-hardening-activation` +result is `unverified`: Runtime neither installs the private Security package +nor evaluates consumer hook activation. This advisory result does not fail a +Runtime-owned preflight, and exit zero is not application security acceptance. + +The outbound policy check accepts the compatibility-preserving disabled state. +When explicitly enabled, `external-http-hosts` rejects empty host lists, empty +entries and a global `*` entry. Disabled blocking makes the host restriction +`not-applicable`. Opaque generated PHP returns `unknown`. Operator-specific host +approval and actual 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; +production boot, and the real WordPress HTTP policy for production and staging. +`ProductionDoctorTest` covers generated defaults, explicit unsafe exceptions, +empty/global host lists and the unverified hook activation result. +The private `sympress/security` package remains independent; no Starter or Demo integration follows from this decision. diff --git a/docs/cli.md b/docs/cli.md index 13a1357..e0304ec 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -42,7 +42,7 @@ The standalone runner uses the current project directory and its Composer vendor | `flush-env-cache` | Remove only the runtime cache in the configured environment directory; WordPress installation is not required | | `dump-env ` | Resolve an explicit raw environment into a private `.env.dump.php`; does not execute steps or run-only autoload | | `doctor`, `check` | Read-only project, environment, permission, symlink, database and kernel diagnostics | -| `doctor --json`, `check --json` | Structured redacted results; exit 0 pass, 1 fail, 2 unknown without failures | +| `doctor --json`, `check --json` | Structured redacted results; exit 0 for passed Runtime checks, 1 fail, 2 unknown without failures. Advisory `unverified` consumer activation and `not-applicable` restrictions do not alter this exit status. | | `kernel-cache` | Explicitly clear only the selected environment's kernel cache/build and discovery metadata | | `kernel-cache --generate-build-id` | Explicitly generate and persist a new kernel deployment ID | | `kernel-boot` | Reconcile optional boot ownership; generation requires kernel-boot configuration | diff --git a/docs/compatibility.md b/docs/compatibility.md index 5ad1686..35a7fab 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -23,6 +23,13 @@ The command reads inline `extra.wpstarter`, referenced JSON and root `wpstarter. ## Choose a behavior profile +Throughout Runtime 1.x, outbound WordPress HTTP blocking is explicit opt-in: +set `WP_HTTP_BLOCK_EXTERNAL=true` and review `WP_ACCESSIBLE_HOSTS` only when the +project requires that restriction. Production/staging and `composer-managed` +alone do not block payment, API or licensing integrations. When upgrading from +1.2.0–1.2.2, regenerate stock managed sections; customized sections are preserved +and must be inspected for the earlier automatic blocking constant. + Keep `release-3.0.1` or `upstream-dev` while preserving the corresponding WP Starter behavior. Selecting `native` is a separate behavior change: explicit step selections override configured skips, `WP_ENVIRONMENT_TYPE` has first selector priority, local dotenv overrides are enabled, and VCS/step ordering differs. Renaming configuration keys alone does not require switching profiles. Review [ADR 0005](adr/0005-compatibility-and-differences.md) before making that decision. Legacy profiles default `wp-config-autoload` to true; migration records that inherited value explicitly. It loads the actual configured vendor autoloader after WordPress's hook API and before environment PHP and `early-hook-file`, preserving Composer `autoload.files` callbacks and vendor classes at that stage. Retain it if any early code/plugin relies on this behavior. Native defaults false; the environment parser itself remains independent of Composer either way. Existing edited `AUTOLOAD` sections are preserved, so inspect regeneration diagnostics and custom templates rather than assuming an option change rewrote hand-maintained PHP. diff --git a/docs/configuration.md b/docs/configuration.md index e7df9a7..32932e2 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 and apply WordPress hardening/upload/outbound HTTP defaults in staging/production; `true` enables it everywhere, `false` disables the section. | +| `composer-managed` | `"auto"` | Protect Composer-managed files and apply WordPress hardening/upload defaults in staging/production; outbound HTTP blocking remains explicit opt-in. `true` enables the section everywhere, `false` disables it. | | `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/kernel-integration.md b/docs/kernel-integration.md index 32c1f1d..2dbd9ce 100644 --- a/docs/kernel-integration.md +++ b/docs/kernel-integration.md @@ -1,6 +1,6 @@ # Kernel operations -The package works without sympress/kernel. When the kernel is installed, generated wp-config registers the optional RuntimeBundle through the kernel's public bundle filter. This makes `wp console doctor`, `wp console check`, `wp console validate` and `wp console dump-env production` available. If you override wp-config.php, register `SymPress\Runtime\Bridge\Kernel\RuntimeBundle::class => ['all' => true]` in `config/bundles.php` or preserve the generated filter. +The package works without sympress/kernel. Runtime 1.2.3 requires Kernel 1.1.5 or newer when the optional kernel is installed. When the kernel is installed, generated wp-config registers the optional RuntimeBundle through the kernel's public bundle filter. This makes `wp console doctor`, `wp console check`, `wp console validate` and `wp console dump-env production` available. If you override wp-config.php, register `SymPress\Runtime\Bridge\Kernel\RuntimeBundle::class => ['all' => true]` in `config/bundles.php` or preserve the generated filter. Standalone commands: @@ -17,7 +17,7 @@ Composer exposes `sympress-runtime:doctor`, `sympress-runtime:check`, `sympress- Doctor exits with 0 when applicable checks pass, 1 on a failure, or 2 when a check remains unknown. `--json` includes each check's ID, status and redacted detail. Database-disabled status is unknown; it is never reported as a successful connection. A missing optional kernel is not-applicable. No setup autoload file or custom step executes during diagnosis. -`kernel-cache` preserves other environments and asset directories. It honors APP_CACHE_DIR and APP_BUILD_DIR and clears discovery metadata in the selected cache, including Kernel 1.1.4's private fallback. A safe legacy default discovery file is also retired after an override. The unsafe old generation that triggered fallback stays untouched. Align explicit SiteKernel environment overrides before running maintenance. +`kernel-cache` preserves other environments and asset directories. It honors APP_CACHE_DIR and APP_BUILD_DIR and clears discovery metadata in the selected cache, including Kernel 1.1.5's private fallback. The selected discovery path is `discovery-packages.json`; read-only cache selection probes `meta.json` without reading or executing metadata. Safe stale default JSON and legacy PHP discovery files are also retired after an override. The unsafe old generation that triggered fallback stays untouched. Align explicit SiteKernel environment overrides before running maintenance. Explicit cache/build roots outside the project must already exist, belong to the user running PHP and have mode 0700. Runtime rejects root or ancestor symlinks, @@ -33,12 +33,23 @@ Doctor computes the same cache location without booting Kernel, creating a fallback or changing permissions. A missing selected directory remains unknown; an unsafe explicit root fails. `kernel.cache-migration` warns when Kernel would select its private per-user fallback. Warmup must run as the PHP-FPM identity. +If that project-local fallback is public or unwritable, Kernel 1.1.5 selects +`sympress-kernel-{user}-{project-hash}` under the canonical system temporary +directory. Runtime validates that selected root and its protected ancestors; +shared writable temporary parents require the sticky bit. A missing root remains +unknown in Doctor and is created only by Kernel warmup. Existing symlinked, +foreign-owned or group/world writable roots fail. Cache maintenance stays inside +the selected root and preserves the old default generation. Configure a durable +APP_CACHE_DIR when deployment caches must survive temporary-directory cleanup. +Without ext-posix, process ownership is verified with a transient `tmpfile()` / +`fstat()` probe that is closed immediately; cache directories and configuration +remain unchanged during inspection. The guard also treats the known WordPress core/content paths and conventional `public` directory as public without booting WordPress. Supply `doctor --webroot` for a different server document root; diagnostics cannot discover web server configuration from PHP files. Doctor observes the calling identity; a CLI check cannot prove the web process's -UID or its filesystem namespace. See the [1.2.2 upgrade notes](releases/1.2.2.md). +UID or its filesystem namespace. See the [1.2.3 upgrade notes](releases/1.2.3.md). This is an explicit command-only step: Composer update does not automatically run it. A deployment requiring a deliberate clear or new build ID must invoke it and warm the selected environment before traffic switches. diff --git a/docs/maintainers/parity.md b/docs/maintainers/parity.md index 03b39f3..5af684e 100644 --- a/docs/maintainers/parity.md +++ b/docs/maintainers/parity.md @@ -181,7 +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 | +| Composer-managed security defaults | W; template COMPOSER_MANAGED | Hardening switch=true, unfiltered HTML disallowed, unfiltered uploads=false; external HTTP blocking is explicit opt-in throughout 1.x with exact core API/download host fallback when enabled. Preserve explicit environment/constants and local/legacy modes. Doctor establishes switch configuration and reports consumer hook activation unverified. | 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 | @@ -266,7 +266,7 @@ Native default order is the twelve-step sequence in the work order. Actual upstr | --- | --- | --- | --- | --- | --- | --- | | doctor/check | W; starter/bin/console | Check paths/env completeness/write permissions/symlinks/DB/kernel cache; JSON or text results with secrets redacted; explicit unknown/unavailable vs pass. | Console/DoctorCommand | `PAR-SYM-001` | verified | Parity | | validate | W; kernel schema style | Draft 2020-12 root/extra/extension schemas, validation every run, standalone command; no writes or execution of PHP providers during static validation. | Console/ValidateCommand | `PAR-SYM-002` | verified | D01 | -| Kernel cache | W; kernel KernelConfigurationResolver | Canonical env and Kernel 1.1.4 private fallback; honor private APP_CACHE_DIR/build overrides including explicit external roots; discovery follows selected cache, no symlinks/foreign owners/traversal; explicit ID generation. | Steps/KernelCacheStep; KernelCacheLocationTest | `PAR-SYM-003` | verified | ADR 0003; ADR 0015 | +| Kernel cache | W; kernel KernelConfigurationResolver | Canonical env and Kernel 1.1.5 private project/temporary fallback including classic roots, cold read-only projects and no ext-posix; honor private APP_CACHE_DIR/build overrides including explicit external roots; JSON discovery follows selected cache and read-only selection probes JSON metadata; safely retire stale default JSON/PHP discovery without execution; no symlinks/foreign owners/traversal; explicit ID generation. | Steps/KernelCacheStep; KernelCacheLocationTest; KernelTemporaryCacheTest; KernelCacheTest; ArchitectureTest | `PAR-SYM-003` | verified | ADR 0003; ADR 0015 | | Boot ownership | W; consumers app-starter.php; kernel App | Installed boot-providing package suppresses generated boot; opt-in boot when absent; detect known existing entry and metadata; ambiguous ownership diagnosed; exactly one boot in copy and symlink fixtures. | Steps/KernelBootStep | `PAR-SYM-004` | verified | Parity | | Kernel console integration | W; kernel Console and bundle discovery | When installed, expose doctor/validate/dump-env via wp console using optional bundle and native command services; no hard kernel dependency for plain WordPress. | Bridge/Kernel/RuntimeBundle | `PAR-SYM-005` | verified | Parity | | Env container visibility | W; kernel EnvConfig/EnvironmentParameterLoader | Fresh files/cache/dump all hydrate safe ENV/SERVER before kernel boot; %env()% works; build ID also bridged to constant/getenv consumer. | Bridge/Kernel/Environment | `PAR-SYM-006` | verified | D22 | diff --git a/docs/releases/1.2.3.md b/docs/releases/1.2.3.md new file mode 100644 index 0000000..747c6cc --- /dev/null +++ b/docs/releases/1.2.3.md @@ -0,0 +1,94 @@ +# Runtime 1.2.3 upgrade + +For projects that already use the optional Kernel, update Runtime and Kernel +together. Runtime 1.2.3 requires Kernel 1.1.5 or newer because cache metadata and +package discovery now use JSON. Ensure project constraints permit Kernel 1.1.5: + +```sh +composer update sympress/runtime sympress/kernel --with-all-dependencies +``` + +Commit the resolved lockfile and deploy the pair together. Plain WordPress +projects can update Runtime alone; Kernel remains optional. + +Runtime now selects warmed read-only caches by `meta.json` and reports discovery +at `discovery-packages.json`. Doctor does not execute metadata or create a fallback. +Legacy PHP metadata cannot select an unwritable cache. Explicit `kernel-cache` +maintenance removes the selected environment's cache/build directories and safe +stale default JSON/PHP discovery after an override. It preserves the unsafe old +generation when Kernel selects its private fallback and validates every target +before deleting any cache. + +Classic WordPress document roots and cold read-only releases can select Kernel's +private per-user, per-project cache under the system temporary directory when +the project-local fallback is public or unwritable. Doctor reports that selection +without creating cache directories. Existing roots must belong to the calling +identity and must not be symlinked or group/world writable; external ancestors +must be protected, with the sticky bit on shared writable parents. Maintenance +uses the selected temporary root and preserves the old default cache. Configure +a durable APP_CACHE_DIR when caches must survive temporary-directory cleanup. +Without ext-posix, ownership verification uses a transient `tmpfile()` / `fstat()` +probe that is closed immediately; inspection does not persist cache or +configuration writes. + +Warm the selected Kernel cache through the application's normal entrypoint as +the PHP-FPM identity before making production files read-only. Composer update +does not run `kernel-cache` automatically. If the deployment requires a deliberate +clear, run it before warmup: + +```sh +vendor/bin/runtime kernel-cache +``` + +Outbound WordPress HTTP blocking remains explicit opt-in throughout Runtime 1.x. +Production, staging and `composer-managed` no longer enable +`WP_HTTP_BLOCK_EXTERNAL` automatically. Existing payment, API and licensing +integrations keep the WordPress default HTTP policy. + +Regenerate stock managed sections when upgrading from 1.2.0–1.2.2: + +```sh +vendor/bin/runtime --no-interaction wpconfig +vendor/bin/runtime doctor --production --webroot=public --json +``` + +Customized sections remain preserved. Inspect them for the earlier automatic +`WP_HTTP_BLOCK_EXTERNAL=true` definition; upgrading the dependency alone does +not rewrite a preserved application configuration. + +If caches or deployment dumps from 1.2.0 are still retained, rebuild them to +remove that release's persisted process-value fingerprints. The compiled +fingerprint-free format was introduced in 1.2.1; updating package code alone +does not erase historical artifact bytes. + +```sh +vendor/bin/runtime flush-env-cache +vendor/bin/runtime dump-env production +``` + +Projects that require outbound blocking can enable it explicitly through an +environment value or a predefined constant. Review the complete host list: + +```dotenv +WP_HTTP_BLOCK_EXTERNAL=true +WP_ACCESSIBLE_HOSTS=api.wordpress.org,downloads.wordpress.org,api.payments.example +``` + +When explicitly enabled, a generated Composer-managed section supplies the exact +core API/download hosts only if the operator has not supplied an override. +Doctor rejects an empty host list, empty entries and global wildcard entries +such as `*`, `**` and `*.*`. Required integration hosts still need application +acceptance. This policy controls the WordPress HTTP API; other network clients +have their own configuration. + +Doctor now names the configuration result +`production.wordpress-hardening-switch`. A separate +`production.wordpress-hardening-activation` result is `unverified`, because +Runtime does not evaluate consumer hook activation. This advisory result does +not alter Runtime preflight's exit status. Exit zero establishes the checked +Runtime configuration; it does not establish application security acceptance. +Runtime does not install or require the private Security package. + +The disabled outbound default passes the selected-policy check and marks +`production.external-http-hosts` as `not-applicable`. Existing opaque or +customized PHP still requires runtime evidence and yields `unknown`. diff --git a/src/Console/ProductionChecks.php b/src/Console/ProductionChecks.php index c3e4e6c..c3310b7 100644 --- a/src/Console/ProductionChecks.php +++ b/src/Console/ProductionChecks.php @@ -47,10 +47,19 @@ 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.wordpress-hardening-switch', $this->effectiveBoolean($env, 'SYMPRESS_ENABLE_WORDPRESS_HARDENING', true, $managedDefaults, $predefined, $opaque), 'The WordPress hardening configuration switch must be enabled; this check establishes only its effective value.'); + $checks[] = ['id' => 'production.wordpress-hardening-activation', 'status' => 'unverified', 'detail' => 'Runtime does not register or execute the consumer WordPress hardening hooks. Verify their activation in the deployed application separately.']; $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.'); + $blocking = $predefined['WP_HTTP_BLOCK_EXTERNAL'] ?? $env->read('WP_HTTP_BLOCK_EXTERNAL') ?? false; + $record('production.external-http', $opaque ? null : is_bool($blocking), 'Outbound WordPress HTTP blocking is opt-in through WP_HTTP_BLOCK_EXTERNAL=true; the default preserves existing integrations. This check validates the selected policy, not network isolation.'); + if ($blocking === true) { + $hosts = $predefined['WP_ACCESSIBLE_HOSTS'] ?? $env->read('WP_ACCESSIBLE_HOSTS') ?? ($managedDefaults ? 'api.wordpress.org,downloads.wordpress.org' : ''); + $record('production.external-http-hosts', $opaque ? null : $this->hasApprovedHostList($hosts), 'Explicit outbound blocking requires a non-empty WP_ACCESSIBLE_HOSTS list without a global wildcard. Verify the project-specific integration hosts separately.'); + } + if ($blocking !== true) { + $checks[] = ['id' => 'production.external-http-hosts', 'status' => $opaque ? 'unknown' : 'not-applicable', 'detail' => 'WP_ACCESSIBLE_HOSTS restricts requests only when outbound WordPress HTTP blocking is enabled.']; + } $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(); @@ -147,6 +156,16 @@ private function both(?bool $first, ?bool $second): ?bool return $first === null || $second === null ? null : true; } + private function hasApprovedHostList(mixed $hosts): bool + { + if (!is_string($hosts) || trim($hosts) === '') { + return false; + } + $entries = array_map(trim(...), explode(',', $hosts)); + + return !in_array('', $entries, true) && !array_any($entries, static fn (string $entry): bool => preg_match('/^[*.]+$/D', $entry) === 1); + } + /** @return array{array, bool} */ private function bootstrapEvidence(string $content, EnvReader $env): array { diff --git a/src/Kernel/CacheLocation.php b/src/Kernel/CacheLocation.php index 1c08663..591f530 100644 --- a/src/Kernel/CacheLocation.php +++ b/src/Kernel/CacheLocation.php @@ -9,7 +9,7 @@ // phpcs:disable SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable -- Match Kernel's public directory boundary without executing or booting it. -/** Read-only counterpart of Kernel 1.1.4's cache selection. @internal */ +/** Read-only counterpart of Kernel 1.1.5's cache selection. @internal */ final class CacheLocation { /** @param list $publicRoots */ @@ -26,11 +26,11 @@ public static function resolve(string $project, string $environment, ?string $co return $path; } - if (!$public && !$unsafe && (is_file($path . '/meta.php') || self::writableAncestor($path))) { + if (!$public && !$unsafe && (is_file($path . '/meta.json') || self::writableAncestor($path))) { return $path; } - $root = self::fallbackRoot($project); - if (self::isPublic($root, $publicRoots) || (!is_dir($root) && !self::writableAncestor($root))) { + $root = self::fallbackRoot($project, $publicRoots); + if (self::isPublic($root, $publicRoots)) { throw new RuntimeException('No private kernel cache is available. Configure APP_CACHE_DIR outside the webroot, create it with mode 0700 as the PHP-FPM user, and run cache warmup as that user.'); } self::assertDirectory($root); @@ -38,11 +38,21 @@ public static function resolve(string $project, string $environment, ?string $co return $root . '/' . $environment . '/kernel'; } - public static function fallbackRoot(string $project): string + /** @param list $publicRoots */ + public static function fallbackRoot(string $project, array $publicRoots = []): string { - $user = function_exists('posix_geteuid') ? (string) posix_geteuid() : hash('sha256', get_current_user()); + $user = (string) self::currentOwner(); + $root = $project . '/var/cache-private-' . $user; + if (!self::isPublic($root, $publicRoots) && self::writableAncestor($root)) { + return $root; + } + $temporary = self::canonical(sys_get_temp_dir()); + $permissions = fileperms($temporary); + if ($permissions === false || (($permissions & 0022) !== 0 && ($permissions & 01000) === 0)) { + throw new RuntimeException('A private kernel cache requires a trusted temporary parent or APP_CACHE_DIR; shared temporary directories must have the sticky bit.'); + } - return $project . '/var/cache-private-' . $user; + return rtrim($temporary, '/') . '/sympress-kernel-' . $user . '-' . substr(hash('sha256', self::canonical($project)), 0, 24); } /** @@ -50,7 +60,7 @@ public static function fallbackRoot(string $project): string * * @param list $publicRoots */ - public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = []): void + public static function assertTarget(string $path, string $root, bool $external, array $publicRoots = [], bool $implicit = false): void { if (!Path::isBasePath($root, $path) || self::isPublic($path, $publicRoots)) { throw new RuntimeException('Kernel cache target must remain inside its private cache root and outside the webroot.'); @@ -66,7 +76,7 @@ public static function assertTarget(string $path, string $root, bool $external, if (!$external) { return; } - if (!is_dir($root) || (fileperms($root) & 0777) !== 0700) { + if (!$implicit && (!is_dir($root) || (fileperms($root) & 0777) !== 0700)) { throw new RuntimeException('An external APP_CACHE_DIR or APP_BUILD_DIR root must exist with mode 0700 and belong to the PHP-FPM user. Warm the cache as that same user.'); } // Even an ancestor above the explicitly trusted root must not redirect it. @@ -78,7 +88,7 @@ public static function assertTarget(string $path, string $root, bool $external, if ($mode === false || (($mode & 0022) !== 0 && ($mode & 01000) === 0)) { throw new RuntimeException('External kernel cache roots require protected ancestors; shared writable ancestors must have the sticky bit.'); } - if (function_exists('posix_geteuid') && !in_array(fileowner($ancestor), [0, posix_geteuid()], true)) { + if (!in_array(fileowner($ancestor), [0, self::currentOwner()], true)) { throw new RuntimeException('An external kernel cache ancestor belongs to another user. Configure a protected root for the PHP-FPM identity.'); } } @@ -95,7 +105,7 @@ private static function assertDirectory(string $directory): void if (!is_dir($directory) || (fileperms($directory) & 0022) !== 0) { throw new RuntimeException('Kernel cache directories must be private and not group/world writable; create them as the PHP-FPM user and warm the cache as that user.'); } - if (function_exists('posix_geteuid') && fileowner($directory) !== posix_geteuid()) { + if (fileowner($directory) !== self::currentOwner()) { throw new RuntimeException('The kernel cache directory belongs to another user. Configure APP_CACHE_DIR for the PHP-FPM identity and warm it as that user.'); } } @@ -130,6 +140,27 @@ private static function canonical(string $path): string return rtrim($real, '/') . $suffix; } + private static function currentOwner(): int + { + if (function_exists('posix_geteuid')) { + return posix_geteuid(); + } + $probe = tmpfile(); + if (!is_resource($probe)) { + throw new RuntimeException('Unable to verify private cache ownership; configure APP_CACHE_DIR.'); + } + try { + $status = fstat($probe); + if ($status === false) { + throw new RuntimeException('Unable to verify private cache ownership; configure APP_CACHE_DIR.'); + } + + return $status['uid']; + } finally { + fclose($probe); + } + } + private static function writableAncestor(string $path): bool { while (!file_exists($path)) { diff --git a/src/Kernel/KernelPaths.php b/src/Kernel/KernelPaths.php index bf0d822..94f8b90 100644 --- a/src/Kernel/KernelPaths.php +++ b/src/Kernel/KernelPaths.php @@ -41,12 +41,12 @@ public function build(): string public function discovery(): string { - return $this->cache() . '/discovery-packages.php'; + return $this->cache() . '/discovery-packages.json'; } public function usesFallback(): bool { - return $this->cache() === CacheLocation::fallbackRoot($this->paths->root()) . '/' . $this->environment() . '/kernel'; + return $this->configured('APP_CACHE_DIR') === null && $this->cache() !== $this->paths->root('var/cache/' . $this->environment() . '/kernel'); } public function assertSafe(string $path, string $kind): void @@ -55,12 +55,13 @@ public function assertSafe(string $path, string $kind): void if ($kind === 'build' && $configured === null) { $configured = $this->configured('APP_CACHE_DIR'); } - $root = $configured === null ? ($this->usesFallback() ? CacheLocation::fallbackRoot($this->paths->root()) : $this->paths->root('var/cache')) : Path::makeAbsolute($configured, $this->paths->root()); + $implicit = $configured === null && $this->usesFallback(); + $root = $configured === null ? ($implicit ? CacheLocation::fallbackRoot($this->paths->root(), $this->publicRoots()) : $this->paths->root('var/cache')) : Path::makeAbsolute($configured, $this->paths->root()); $external = !Path::isBasePath($this->paths->root(), $root); if (!$external) { (new ProjectBoundary($this->paths))->assertWritablePath($path); } - CacheLocation::assertTarget($path, $root, $external, $this->publicRoots()); + CacheLocation::assertTarget($path, $root, $external, $this->publicRoots(), $implicit); } public function buildIdFile(): string @@ -72,13 +73,18 @@ public function buildIdFile(): string public function clearTargets(): array { $targets = array_values(array_unique([$this->cache(), $this->build()])); - // Retire only the known legacy discovery artifact; never adopt or delete + // Retire only known default discovery artifacts; never adopt or delete // the unsafe implicit generation that caused Kernel's private fallback. - $legacy = $this->paths->root('var/cache/' . $this->environment() . '/kernel/discovery-packages.php'); - if (!$this->usesFallback() && is_file($legacy) && !array_any($targets, static fn (string $target): bool => Path::isBasePath($target, $legacy))) { - (new ProjectBoundary($this->paths))->assertWritablePath($legacy); - CacheLocation::assertTarget(dirname($legacy), $this->paths->root('var/cache'), false, $this->publicRoots()); - $targets[] = $legacy; + if (!$this->usesFallback()) { + foreach (['discovery-packages.json', 'discovery-packages.php'] as $file) { + $legacy = $this->paths->root('var/cache/' . $this->environment() . '/kernel/' . $file); + if (!is_file($legacy) || array_any($targets, static fn (string $target): bool => Path::isBasePath($target, $legacy))) { + continue; + } + (new ProjectBoundary($this->paths))->assertWritablePath($legacy); + CacheLocation::assertTarget(dirname($legacy), $this->paths->root('var/cache'), false, $this->publicRoots()); + $targets[] = $legacy; + } } return $targets; diff --git a/templates/wp-config.php b/templates/wp-config.php index 46342e6..ae1928a 100644 --- a/templates/wp-config.php +++ b/templates/wp-config.php @@ -169,8 +169,9 @@ function wpstarter_getenv(?string $name): bool|int|float|string|object|null 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 (defined('WP_HTTP_BLOCK_EXTERNAL') && 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/Contract/ArchitectureTest.php b/tests/Contract/ArchitectureTest.php index a56e9f5..6064168 100644 --- a/tests/Contract/ArchitectureTest.php +++ b/tests/Contract/ArchitectureTest.php @@ -4,11 +4,25 @@ namespace SymPress\Runtime\Tests\Contract; +use Composer\Semver\Semver; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; final class ArchitectureTest extends TestCase { + #[Group('PAR-SYM-003')] + public function testKernelRemainsOptionalAndRequiresTheJsonMetadataContractWhenInstalled(): void + { + $manifest = json_decode((string) file_get_contents(dirname(__DIR__, 2) . '/composer.json'), true, flags: JSON_THROW_ON_ERROR); + self::assertArrayNotHasKey('sympress/kernel', $manifest['require']); + self::assertArrayHasKey('sympress/kernel', $manifest['suggest']); + self::assertTrue(Semver::satisfies('1.1.4', $manifest['conflict']['sympress/kernel'])); + foreach (['1.1.5', '1.2.0'] as $version) { + self::assertFalse(Semver::satisfies($version, $manifest['conflict']['sympress/kernel'])); + self::assertTrue(Semver::satisfies($version, $manifest['require-dev']['sympress/kernel'])); + } + } + #[Group('PAR-QA-001')] #[Group('PAR-QA-003')] public function testInstalledGraphExcludesWpStarterAndUsesTheRequiredStack(): void diff --git a/tests/Integration/EnvironmentInspectionTest.php b/tests/Integration/EnvironmentInspectionTest.php index 75c5761..a4d9bef 100644 --- a/tests/Integration/EnvironmentInspectionTest.php +++ b/tests/Integration/EnvironmentInspectionTest.php @@ -55,7 +55,7 @@ public function testDumpAndCacheRefreshInterpolationWithoutParserOrFingerprintOr $payload = EnvCacheFormat::read($this->root . '/' . $artifact, dataOnly: true); foreach ($payload['interpolation']['dependencies'] as $dependency) { self::assertSame(['marker'], array_keys($dependency)); - foreach (['dictionary-word', 'password', 'secret'] as $guess) { + foreach (['dictionary-word', 'password', 'secret', 'letmein', 'admin'] as $guess) { self::assertStringNotContainsString(hash('sha256', $dependency['marker'] . "\0" . $guess), $bytes); } } diff --git a/tests/Integration/KernelCacheLocationTest.php b/tests/Integration/KernelCacheLocationTest.php index c008ee5..154cc29 100644 --- a/tests/Integration/KernelCacheLocationTest.php +++ b/tests/Integration/KernelCacheLocationTest.php @@ -8,8 +8,11 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RuntimeException; use SymPress\Kernel\Bundle\BundleRegistry; +use SymPress\Kernel\Discovery\KernelPackageManifestCache; use SymPress\Kernel\Kernel\SiteKernel; use SymPress\Runtime\Config\Config; use SymPress\Runtime\Config\Validator; @@ -48,7 +51,7 @@ protected function tearDown(): void private function fixture(string $extra = ''): KernelPaths { $this->write('vendor/autoload.php', 'write('vendor/composer/installed.json', '{"packages":[{"name":"sympress/kernel","version":"1.1.4","install-path":"../sympress/kernel"}]}'); + $this->write('vendor/composer/installed.json', '{"packages":[{"name":"sympress/kernel","version":"1.1.5","install-path":"../sympress/kernel"}]}'); $this->write('composer.json', '{"extra":{"sympress-runtime":{"db-check":false,"require-wp":false}}}'); $this->write('wordpress/wp-load.php', 'write('wp-content/mu-plugins/sympress-runtime-kernel.php', 'builder()->register('fixture.warmed', \ArrayObject::class)->setPublic(true); $kernel->createRuntimeContainer($container, $registry, $files); self::assertInstanceOf(\ArrayObject::class, $container->get('fixture.warmed')); - self::assertFileExists($kernel->getCacheDir() . '/meta.php'); + self::assertFileExists($kernel->getCacheDir() . '/meta.json'); + self::assertFileDoesNotExist($kernel->getCacheDir() . '/meta.php'); + $manifest = new KernelPackageManifestCache($this->root, 'production', []); + $manifest->write(['fixture/package']); + self::assertSame(['fixture/package'], $manifest->read()); + self::assertFileExists($runtime->discovery()); + self::assertJson((string) file_get_contents($runtime->discovery())); return $kernel; } @@ -102,10 +111,11 @@ public function testImplicitMigrationIsReadOnlyInDoctorAndClearsTheActuallyWarme { $runtime = $this->fixture(); $this->write('var/cache/production/kernel/meta.php', 'write('var/cache/production/kernel/discovery-packages.json', '{"unsafe":"old generation"}'); chmod($this->root . '/var/cache/production/kernel', 0770); $selected = CacheLocation::fallbackRoot($this->root) . '/production/kernel'; self::assertSame($selected, $runtime->cache()); - self::assertSame($selected . '/discovery-packages.php', $runtime->discovery()); + self::assertSame($selected . '/discovery-packages.json', $runtime->discovery()); $report = $this->diagnose(); $checks = array_column($report['checks'], 'status', 'id'); self::assertSame('unknown', $checks['kernel.cache']); @@ -120,6 +130,8 @@ public function testImplicitMigrationIsReadOnlyInDoctorAndClearsTheActuallyWarme $this->clear($runtime); self::assertDirectoryDoesNotExist($kernel->getCacheDir()); self::assertFileExists($this->root . '/var/cache/production/kernel/meta.php'); + self::assertFileExists($this->root . '/var/cache/production/kernel/discovery-packages.json'); + self::assertFileDoesNotExist($this->root . '/var/cache/production/kernel/executed'); self::assertFileExists($this->root . '/var/cache/production/assets/keep.css'); self::assertFileExists($this->root . '/var/cache/staging/kernel/keep.php'); } @@ -165,7 +177,7 @@ public function testPrivateAbsoluteCacheAndBuildOutsideTheReleaseAreWarmedDiagno self::assertSame('pass', $checks['kernel.cache']); self::assertSame('pass', $checks['kernel.build']); self::assertArrayNotHasKey('kernel.cache-migration', $checks); - self::assertSame($kernel->getCacheDir() . '/discovery-packages.php', $runtime->discovery()); + self::assertSame($kernel->getCacheDir() . '/discovery-packages.json', $runtime->discovery()); $this->clear($runtime); self::assertDirectoryDoesNotExist($kernel->getCacheDir()); self::assertDirectoryDoesNotExist($kernel->getBuildDir()); @@ -190,6 +202,60 @@ public function testPublicImplicitCacheSelectsTheSamePrivateLocationAsKernelWith self::assertSame(CacheLocation::fallbackRoot($this->root) . '/production/kernel', $selected); } + /** @return iterable */ + public static function readOnlyMetadata(): iterable + { + yield 'JSON metadata retains the warmed location' => ['meta.json', false]; + yield 'legacy PHP metadata cannot select an unwritable location' => ['meta.php', true]; + } + + #[DataProvider('readOnlyMetadata')] + public function testReadOnlyCacheSelectionMatchesKernelWithoutExecutingMetadata(string $file, bool $fallback): void + { + $this->fixture(); + $directory = $this->root . '/var/cache/production/kernel'; + $contents = $file === 'meta.json' ? '{}' : 'write('var/cache/production/kernel/' . $file, $contents); + $this->write('readonly-probe.php', <<<'PHP' +loadFile(__DIR__ . '/.env'); +$runtime = new SymPress\Runtime\Kernel\KernelPaths($reader, new SymPress\Runtime\Filesystem\Paths(__DIR__)); +$selected = $runtime->cache(); +$fallback = SymPress\Runtime\Kernel\CacheLocation::fallbackRoot(__DIR__); +$before = is_dir($fallback); +$doctor = new Symfony\Component\Process\Process([PHP_BINARY, $argv[1], '-n', 'doctor', '--json'], __DIR__); +$doctor->run(); +$checks = array_column(json_decode($doctor->getOutput(), true, flags: JSON_THROW_ON_ERROR)['checks'], 'status', 'id'); +$after = is_dir($fallback); +ini_set('error_log', __DIR__ . '/kernel-warnings.log'); +$kernel = new SymPress\Kernel\Kernel\SiteKernel(__DIR__, 'production'); +echo json_encode([is_writable(__DIR__ . '/var/cache/production/kernel'), $selected, $before, $checks['kernel.cache'], $after, $kernel->getCacheDir(), $fallback]); +PHP); + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $entry) { + self::assertTrue(chown($entry->getPathname(), 65534)); + } + self::assertTrue(chown($this->root, 65534)); + } + chmod($directory, 0500); + try { + $probe = new Process([PHP_BINARY, 'readonly-probe.php', dirname(__DIR__, 2) . '/bin/runtime'], $this->root, ['COMPOSER' => false, 'COMPOSER_VENDOR_DIR' => false, 'APP_CACHE_DIR' => false, 'APP_BUILD_DIR' => false]); + $probe->mustRun(); + $result = json_decode($probe->getOutput(), true, flags: JSON_THROW_ON_ERROR); + $expected = $fallback ? $result[6] . '/production/kernel' : $directory; + self::assertSame([false, $expected, false, $fallback ? 'unknown' : 'pass', false, $expected, $result[6]], $result); + self::assertSame($contents, file_get_contents($directory . '/' . $file)); + self::assertFileDoesNotExist($directory . '/executed'); + } finally { + chmod($directory, 0700); + } + } + /** @return iterable */ public static function unsafeExternalRoots(): iterable { diff --git a/tests/Integration/KernelCacheTest.php b/tests/Integration/KernelCacheTest.php index 5b43501..b70fb97 100644 --- a/tests/Integration/KernelCacheTest.php +++ b/tests/Integration/KernelCacheTest.php @@ -50,7 +50,8 @@ public function testCanonicalEnvironmentOverridesAndDiscoveryAreClearedWithoutOt self::assertSame('staging', $kernel->environment()); $this->write('custom/cache/staging/kernel/container.php', 'cache'); $this->write('custom/build/staging/kernel/container.php', 'build'); - $this->write('var/cache/staging/kernel/discovery-packages.php', 'discovery'); + $this->write('var/cache/staging/kernel/discovery-packages.json', '{"packages":["stale/default"]}'); + $this->write('var/cache/staging/kernel/discovery-packages.php', 'write('var/cache/staging/kernel/keep.php', 'unrelated default-cache entry'); $this->write('custom/cache/production/kernel/container.php', 'other environment'); $this->write('custom/cache/staging/assets/asset.css', 'asset compiler'); @@ -58,11 +59,36 @@ public function testCanonicalEnvironmentOverridesAndDiscoveryAreClearedWithoutOt self::assertDirectoryDoesNotExist($kernel->cache()); self::assertDirectoryDoesNotExist($kernel->build()); self::assertFileDoesNotExist($kernel->discovery()); + self::assertFileDoesNotExist($this->root . '/var/cache/staging/kernel/discovery-packages.json'); + self::assertFileDoesNotExist($this->root . '/var/cache/staging/kernel/discovery-packages.php'); + self::assertFileDoesNotExist($this->root . '/var/cache/staging/kernel/executed'); foreach (['var/cache/staging/kernel/keep.php', 'custom/cache/production/kernel/container.php', 'custom/cache/staging/assets/asset.css'] as $file) { self::assertFileExists($this->root . '/' . $file); } } + #[Group('PAR-SYM-003')] + public function testUnsafeDefaultDiscoveryPreventsDeletionOfSeparateConfiguredCaches(): void + { + [$step, $kernel, $paths] = $this->fixture(); + $this->write('custom/cache/staging/kernel/keep.php', 'configured cache'); + $this->write('custom/build/staging/kernel/keep.php', 'configured build'); + $this->write('var/cache/staging/kernel/discovery-packages.json', '{}'); + $this->write('var/cache/staging/kernel/discovery-packages.php', 'root . '/var/cache/staging/kernel', 0770); + try { + $step->run(new Config([], new Validator($paths)), $paths); + self::fail('Unsafe default discovery must fail before clearing configured caches.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('private', $error->getMessage()); + } + self::assertFileExists($kernel->cache() . '/keep.php'); + self::assertFileExists($kernel->build() . '/keep.php'); + self::assertFileExists($this->root . '/var/cache/staging/kernel/discovery-packages.json'); + self::assertFileExists($this->root . '/var/cache/staging/kernel/discovery-packages.php'); + self::assertFileDoesNotExist($this->root . '/var/cache/staging/kernel/executed'); + } + #[Group('PAR-SYM-003')] public function testEscapingAncestorPreventsAllDeletionAndChildSymlinksAreNotFollowed(): void { diff --git a/tests/Integration/KernelTemporaryCacheTest.php b/tests/Integration/KernelTemporaryCacheTest.php new file mode 100644 index 0000000..c93a9d0 --- /dev/null +++ b/tests/Integration/KernelTemporaryCacheTest.php @@ -0,0 +1,231 @@ + */ + private array $temporaryRoots = []; + + protected function tearDown(): void + { + chmod($this->root, 0755); + (new Filesystem())->remove($this->temporaryRoots); + parent::tearDown(); + } + + private function fixture(): KernelPaths + { + $this->write('vendor/autoload.php', 'write('vendor/composer/installed.json', '{"packages":[{"name":"sympress/kernel","version":"1.1.5","install-path":"../sympress/kernel"}]}'); + $this->write('composer.json', '{"extra":{"sympress-runtime":{"db-check":false,"require-wp":false}}}'); + $this->write('wordpress/wp-load.php', 'write('wp-content/mu-plugins/sympress-runtime-kernel.php', 'write('.env', "WP_ENVIRONMENT_TYPE=production\nWP_HOME=https://fixture.test\nDB_NAME=fixture\nDB_USER=fixture\nDB_PASSWORD=fixture\nDB_HOST=localhost\n"); + $reader = new EnvReader(); + $reader->loadFile($this->root . '/.env'); + + return new KernelPaths($reader, new Paths($this->root), $this->root); + } + + /** @return iterable */ + public static function temporaryFallbacks(): iterable + { + yield 'classic document root' => ['classic', false]; + yield 'cold read-only project' => ['readonly', false]; + yield 'classic document root without ext-posix' => ['classic', true]; + } + + #[DataProvider('temporaryFallbacks')] + public function testTemporaryFallbackMatchesPublishedKernelAndIsReadOnlyUntilWarmup(string $case, bool $withoutPosix): void + { + $this->fixture(); + if ($case === 'classic') { + $this->write('var/cache/production/kernel/discovery-packages.php', 'write('var/cache/production/kernel/discovery-packages.json', '{"packages":["old/default"]}'); + } + $this->write('temporary-probe.php', <<<'PHP' +loadFile(__DIR__ . '/.env'); +$paths = new SymPress\Runtime\Filesystem\Paths(__DIR__); +$runtime = new SymPress\Runtime\Kernel\KernelPaths($reader, $paths, $classic ? __DIR__ : null); +$selected = $runtime->cache(); +$root = dirname($selected, 2); +$before = is_dir($root); +$runtime->assertSafe($selected, 'cache'); +$targets = $runtime->clearTargets(); +$command = [PHP_BINARY]; +if (!function_exists('posix_geteuid')) { array_push($command, '-d', 'disable_functions=posix_geteuid'); } +array_push($command, $argv[2], '-n', 'doctor', '--json'); +if ($classic) { array_push($command, '--webroot', __DIR__); } +$doctor = new Symfony\Component\Process\Process($command, __DIR__); +$doctor->run(); +$checks = array_column(json_decode($doctor->getOutput(), true, flags: JSON_THROW_ON_ERROR)['checks'], 'status', 'id'); +$after = is_dir($root); +ini_set('error_log', $root . '/kernel-warnings.log'); +$kernel = new SymPress\Kernel\Kernel\SiteKernel(__DIR__, 'production'); +$actual = $kernel->getCacheDir(); +$container = $kernel->createContainer(); +$registry = new SymPress\Kernel\Bundle\BundleRegistry(); +$files = $kernel->configureContainer($container->builder(), $container, $registry); +$container->builder()->register('fixture.warmed', ArrayObject::class)->setPublic(true); +$kernel->createRuntimeContainer($container, $registry, $files); +$manifest = new SymPress\Kernel\Discovery\KernelPackageManifestCache(__DIR__, 'production', []); +$manifest->write(['fixture/package']); +$metadata = is_file($selected . '/meta.json') && is_file($runtime->discovery()) && $manifest->read() === ['fixture/package']; +$filesystem = new Symfony\Component\Filesystem\Filesystem(); +$filesystem->dumpFile($root . '/production/assets/keep.css', 'asset'); +$filesystem->dumpFile($root . '/staging/kernel/keep.php', 'other environment'); +$boundary = new SymPress\Runtime\Filesystem\ProjectBoundary($paths); +$step = new SymPress\Runtime\Step\Builtin\KernelCacheStep($runtime, $boundary, new SymPress\Runtime\Generation\ArtifactWriter($boundary), new SymPress\Runtime\Console\Io(new Symfony\Component\Console\Input\ArrayInput([]), new Symfony\Component\Console\Output\BufferedOutput())); +$status = $step->run(new SymPress\Runtime\Config\Config([], new SymPress\Runtime\Config\Validator($paths)), $paths); +echo json_encode([$selected, $actual, $before, $after, $checks['kernel.cache'], $checks['permissions.kernel-cache'], $checks['kernel.cache-migration'], $runtime->usesFallback(), $targets, $metadata, $status, is_dir($selected), is_file($root . '/production/assets/keep.css'), is_file($root . '/staging/kernel/keep.php')]); +PHP); + $owner = posix_geteuid(); + if ($case === 'readonly' && $owner === 0) { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $entry) { + self::assertTrue(chown($entry->getPathname(), 65534)); + } + self::assertTrue(chown($this->root, 65534)); + $owner = 65534; + } + $temporary = (string) realpath(sys_get_temp_dir()) . '/sympress-kernel-' . $owner . '-' . substr(hash('sha256', (string) realpath($this->root)), 0, 24); + $this->temporaryRoots[] = $temporary; + if ($case === 'readonly') { + chmod($this->root, 0555); + } + $command = [PHP_BINARY]; + if ($withoutPosix) { + array_push($command, '-d', 'disable_functions=posix_geteuid'); + } + array_push($command, 'temporary-probe.php', $case, dirname(__DIR__, 2) . '/bin/runtime'); + $probe = new Process($command, $this->root, ['COMPOSER' => false, 'COMPOSER_VENDOR_DIR' => false, 'APP_CACHE_DIR' => false, 'APP_BUILD_DIR' => false]); + $probe->mustRun(); + $selected = $temporary . '/production/kernel'; + self::assertSame([$selected, $selected, false, false, 'unknown', 'pass', 'warning', true, [$selected], true, StepInterface::SUCCESS, false, true, true], json_decode($probe->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + self::assertFileDoesNotExist($this->root . '/var/cache/production/kernel/executed'); + if ($case !== 'classic') { + return; + } + self::assertFileExists($this->root . '/var/cache/production/kernel/discovery-packages.php'); + self::assertFileExists($this->root . '/var/cache/production/kernel/discovery-packages.json'); + } + + /** @return iterable */ + public static function unsafeTemporaryRoots(): iterable + { + yield 'symlink root' => ['symlink']; + yield 'shared writable root' => ['mode']; + } + + #[DataProvider('unsafeTemporaryRoots')] + public function testPrecreatedUnsafeTemporaryRootsAreNeverAdoptedOrCleared(string $case): void + { + $runtime = $this->fixture(); + $_SERVER['DOCUMENT_ROOT'] = $this->root; + $temporary = (string) realpath(sys_get_temp_dir()) . '/sympress-kernel-' . posix_geteuid() . '-' . substr(hash('sha256', (string) realpath($this->root)), 0, 24); + $this->temporaryRoots[] = $temporary; + $this->write('target/keep.php', 'protected'); + if ($case === 'symlink') { + symlink($this->root . '/target', $temporary); + } + if ($case === 'mode') { + mkdir($temporary, 0770); + chmod($temporary, 0770); + } + foreach ([$runtime->cache(...), (new SiteKernel($this->root, 'production'))->getCacheDir(...)] as $resolve) { + try { + $resolve(); + self::fail('An unsafe temporary root must fail without adoption.'); + } catch (RuntimeException) { + self::assertSame('protected', file_get_contents($this->root . '/target/keep.php')); + } + } + self::assertFileDoesNotExist($temporary . '/production/kernel/meta.json'); + } + + public function testTemporaryRootMustBelongToTheCallingIdentity(): void + { + $runtime = $this->fixture(); + $_SERVER['DOCUMENT_ROOT'] = $this->root; + $temporary = (string) realpath(sys_get_temp_dir()) . '/sympress-kernel-' . posix_geteuid() . '-' . substr(hash('sha256', (string) realpath($this->root)), 0, 24); + $this->temporaryRoots[] = $temporary; + mkdir($temporary, 0700); + (new Filesystem())->dumpFile($temporary . '/production/kernel/keep.php', 'protected owner fixture'); + if (posix_geteuid() === 0) { + // A root test runner can also exercise a genuinely foreign owner. + self::assertTrue(chown($temporary, 65534)); + } + foreach ([$runtime->cache(...), (new SiteKernel($this->root, 'production'))->getCacheDir(...)] as $resolve) { + if (posix_geteuid() !== 0) { + self::assertSame($temporary . '/production/kernel', $resolve()); + continue; + } + try { + $resolve(); + self::fail('A foreign-owned root must fail without adoption.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('another user', $error->getMessage()); + } + } + self::assertSame('protected owner fixture', file_get_contents($temporary . '/production/kernel/keep.php')); + } + + public function testSharedTemporaryParentWithoutStickyBitCannotCreateACacheRoot(): void + { + $this->fixture(); + $parent = $this->root . '-unsafe-temp'; + $this->temporaryRoots[] = $parent; + mkdir($parent, 0770); + chmod($parent, 0770); + $this->write('unsafe-temp-probe.php', <<<'PHP' +loadFile(__DIR__ . '/.env'); +$runtime = new SymPress\Runtime\Kernel\KernelPaths($reader, new SymPress\Runtime\Filesystem\Paths(__DIR__)); +$results = []; +foreach ([$runtime->cache(...), (new SymPress\Kernel\Kernel\SiteKernel(__DIR__, 'production'))->getCacheDir(...)] as $resolve) { + try { $resolve(); $results[] = false; } + catch (RuntimeException $error) { $results[] = str_contains($error->getMessage(), 'sticky bit'); } +} +echo json_encode($results); +PHP); + $probe = new Process([PHP_BINARY, '-d', 'sys_temp_dir=' . $parent, 'unsafe-temp-probe.php'], $this->root, ['APP_CACHE_DIR' => false, 'APP_BUILD_DIR' => false]); + $probe->mustRun(); + self::assertSame([true, true], json_decode($probe->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + self::assertDirectoryExists($parent); + self::assertSame([], glob($parent . '/*') ?: []); + } +} diff --git a/tests/Integration/ProductionDoctorTest.php b/tests/Integration/ProductionDoctorTest.php index 4fac9e9..e46e599 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -75,7 +75,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', 'production.wordpress-hardening', 'production.unfiltered-html', 'production.unfiltered-uploads', 'production.external-http'] as $name) { + foreach (['production.home', 'production.debug', 'production.debug-display', 'production.file-mods', 'production.auto-updates', 'production.salts', 'production.wordpress-hardening-switch', 'production.unfiltered-html', 'production.unfiltered-uploads', 'production.external-http'] as $name) { self::assertSame('pass', $checks[$name]); } self::assertFileDoesNotExist($this->root . '/executed'); @@ -89,9 +89,11 @@ public function testBadHttpsDebugAndExplicitHardeningOverridesFail(): void $this->dump(); [$exit, $checks] = $this->diagnose(); self::assertSame(1, $exit); - 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) { + foreach (['production.home', 'production.debug', 'production.debug-display', 'production.file-mods', 'production.wordpress-hardening-switch', 'production.unfiltered-html', 'production.unfiltered-uploads'] as $name) { self::assertSame('fail', $checks[$name]); } + self::assertSame('pass', $checks['production.external-http']); + self::assertSame('not-applicable', $checks['production.external-http-hosts']); } public function testExplicitlyDisabledEnvironmentCacheFailsProductionCheck(): void @@ -103,6 +105,39 @@ public function testExplicitlyDisabledEnvironmentCacheFailsProductionCheck(): vo self::assertSame('fail', $checks['production.env-cache']); } + /** @return iterable */ + public static function outboundHostLists(): iterable + { + yield 'empty' => ['', 'fail']; + yield 'global wildcard' => ['*', 'fail']; + yield 'repeated global wildcard' => ['**', 'fail']; + yield 'all domain wildcard' => ['*.*', 'fail']; + yield 'global wildcard beside approved host' => ['api.wordpress.org,*', 'fail']; + yield 'empty list entry' => ['api.wordpress.org, ', 'fail']; + yield 'approved integration' => ['api.wordpress.org,downloads.wordpress.org,api.payments.example', 'pass']; + } + + #[DataProvider('outboundHostLists')] + public function testProductionDoctorValidatesOptInHttpHosts(string $hosts, string $expected): void + { + $this->fixture(); + file_put_contents($this->root . '/.env', "WP_HTTP_BLOCK_EXTERNAL=true\nWP_ACCESSIBLE_HOSTS=" . $hosts . "\n", FILE_APPEND); + $this->dump(); + [$exit, $checks] = $this->diagnose(); + self::assertSame($expected === 'pass' ? 0 : 1, $exit); + self::assertSame($expected, $checks['production.external-http-hosts']); + } + + public function testHardeningSwitchDoesNotClaimHookActivation(): void + { + $this->fixture(); + [$exit, $checks] = $this->diagnose(); + self::assertSame(0, $exit); + self::assertArrayNotHasKey('production.wordpress-hardening', $checks); + self::assertSame('pass', $checks['production.wordpress-hardening-switch']); + self::assertSame('unverified', $checks['production.wordpress-hardening-activation']); + } + public function testConfiguredForwardedSslRequiresValidTrustedProxyConfiguration(): void { $this->fixture(); diff --git a/tests/Integration/ProductionWpConfigTest.php b/tests/Integration/ProductionWpConfigTest.php index 67853d8..6680ff1 100644 --- a/tests/Integration/ProductionWpConfigTest.php +++ b/tests/Integration/ProductionWpConfigTest.php @@ -80,7 +80,7 @@ public function testComposerManagedModesSetGeneratedBootstrapDefaults(bool|strin $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]);')); + self::assertSame([$protected ? true : null, $protected ? true : null, $protected ? false : null, null, 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 @@ -90,6 +90,43 @@ 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);')); } + /** @return iterable */ + public static function productionStages(): iterable + { + yield 'production' => ['production']; + yield 'staging' => ['staging']; + } + + #[DataProvider('productionStages')] + public function testOutboundHttpRemainsOptInForManagedProductionAndStaging(string $environment): void + { + $this->fixture(['composer-managed' => 'auto'], $environment); + self::assertSame([false, null], $this->boot('echo json_encode([defined("WP_HTTP_BLOCK_EXTERNAL") && WP_HTTP_BLOCK_EXTERNAL, defined("WP_ACCESSIBLE_HOSTS") ? WP_ACCESSIBLE_HOSTS : null]);')); + } + + #[DataProvider('productionStages')] + public function testExplicitOutboundBlockingRetainsApprovedHosts(string $environment): void + { + $this->fixture(['composer-managed' => 'auto'], $environment, "WP_HTTP_BLOCK_EXTERNAL=true\n"); + $report = 'echo json_encode([WP_HTTP_BLOCK_EXTERNAL, WP_ACCESSIBLE_HOSTS]);'; + self::assertSame([true, 'api.wordpress.org,downloads.wordpress.org'], $this->boot($report)); + self::assertSame([true, 'api.payments.example'], $this->boot($report, "define('WP_HTTP_BLOCK_EXTERNAL', true); define('WP_ACCESSIBLE_HOSTS', 'api.payments.example')")); + } + + #[DataProvider('productionStages')] + #[Group('wordpress')] + public function testRealWordPressHttpPolicyPreservesIntegrationsUntilExplicitBlocking(string $environment): void + { + $core = getenv('RUNTIME_TEST_WORDPRESS_DIR'); + if (!$core || !is_file($core . '/wp-includes/class-wp-http.php')) { + self::markTestSkipped('Set the WordPress integration fixture.'); + } + $this->fixture(['composer-managed' => 'auto'], $environment); + $policy = 'require ' . var_export($core . '/wp-includes/Requests/src/Autoload.php', true) . '; WpOrg\\Requests\\Autoload::register(); require ' . var_export($core . '/wp-includes/class-wp-http.php', true) . '; function get_option($name) { return "https://fixture.invalid"; } $http = new WP_Http(); echo json_encode([$http->block_request("https://api.payments.example/v1/checkout"), $http->block_request("https://api.wordpress.org/core/version-check/1.7/"), $http->block_request("https://other.invalid/license")]);'; + self::assertSame([false, false, false], $this->boot($policy)); + self::assertSame([false, false, true], $this->boot($policy, 'define("WP_HTTP_BLOCK_EXTERNAL", true); define("WP_ACCESSIBLE_HOSTS", "api.wordpress.org,downloads.wordpress.org,api.payments.example")')); + } + #[Group('PAR-WP-018')] public function testHardeningOverridesAndOperatorOutboundHostsSurviveGeneratedDefaults(): void { @@ -168,14 +205,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'), SYMPRESS_ENABLE_WORDPRESS_HARDENING, DISALLOW_UNFILTERED_HTML, ALLOW_UNFILTERED_UPLOADS, WP_HTTP_BLOCK_EXTERNAL, WP_ACCESSIBLE_HOSTS]); +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, defined('WP_HTTP_BLOCK_EXTERNAL') && WP_HTTP_BLOCK_EXTERNAL, defined('WP_ACCESSIBLE_HOSTS') ? WP_ACCESSIBLE_HOSTS : null]); 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', true, true, false, true, 'api.wordpress.org,downloads.wordpress.org'], json_decode($probe->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + self::assertSame([$warm, !$warm, '1', 'production', true, true, false, 'real-wordpress', true, true, false, false, null], 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); }