diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e706a2b..bb678ac 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -147,3 +147,21 @@ jobs: php -l /usr/src/wordpress/wp-content/db.php php -l /usr/src/wordpress/tool-update-site-url.php ' + # PHP variables below are intentionally protected from shell expansion. + # shellcheck disable=SC2016 + docker run --rm sqlite-wordpress:test php -r ' + require "/var/www/html/wp-load.php"; + $driver = $GLOBALS["wpdb"]->get_driver(); + foreach (["beginTransaction", "commit", "rollBack", "inTransaction"] as $method) { + if (!method_exists($driver, $method)) { + fwrite(STDERR, "SQLite driver transaction method unavailable: {$method}\n"); + exit(1); + } + } + $driver->beginTransaction(); + if (!$driver->inTransaction()) { + fwrite(STDERR, "SQLite driver did not enter a transaction.\n"); + exit(1); + } + $driver->rollBack(); + ' diff --git a/CHANGELOG.md b/CHANGELOG.md index 89896dd..46ecfc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ ### Added - Added a disabled-by-default `/tool-update-site-url.php` recovery page that - requires both an explicit environment enable switch and a strong token, then - atomically updates the WordPress `siteurl` and `home` options after a domain, - scheme, port, or path change. + requires both an explicit environment enable switch and one strong credential + supplied through a token file, direct token, or password, then atomically + updates the WordPress `siteurl` and `home` options after a domain, scheme, + port, or path change. ## [7.1.0] - 2026-08-29 diff --git a/README.md b/README.md index 228b252..7988fb1 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,20 @@ these independent conditions are met: 1. `WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED` is set to the exact lowercase value `true`. -2. A strong recovery token is configured. +2. Exactly one strong recovery credential is configured. Values such as `1`, `yes`, or `TRUE` do not enable the tool. A Docker secret is -preferred for the token so it does not appear in `docker inspect` output: +preferred for the token so it does not appear in `docker inspect` output. +Choose exactly one of these credential sources: + +| Variable | Minimum length | Notes | +| --- | ---: | --- | +| `WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE` | 32 characters | Preferred. Reads a Docker secret or mounted file. | +| `WORDPRESS_SITE_URL_UPDATE_TOKEN` | 32 characters | Direct token; visible in container environment metadata. | +| `WORDPRESS_SITE_URL_UPDATE_PASSWORD` | 16 characters | Direct password; visible in container environment metadata. | + +Do not configure more than one source at the same time. To use the preferred +file-based token: ```bash mkdir -p secrets @@ -141,16 +151,19 @@ secrets: ``` Recreate the container, then open -`http://localhost:8080/tool-update-site-url.php`. Enter the generated token and -the two desired addresses. The token is accepted only in the POST body; never -append it to the URL. When the site works at its new address, remove both -`WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED` and the token configuration, then -recreate the container so the endpoint returns 404 again. - -For a short-lived local recovery, the token can instead be passed directly as -`WORDPRESS_SITE_URL_UPDATE_TOKEN`. Generate it with `openssl rand -hex 32` and -set `WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true` at the same time. Use TLS -whenever the endpoint is reachable across an untrusted network. +`http://localhost:8080/tool-update-site-url.php`. Enter the configured token or +password and the two desired addresses. The credential is accepted only in the +POST body; never append it to the URL. When the site works at its new address, +remove both `WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED` and the selected credential +configuration, then recreate the container so the endpoint returns 404 again. + +For a short-lived local recovery, either pass a generated token directly as +`WORDPRESS_SITE_URL_UPDATE_TOKEN`, or set a strong password through +`WORDPRESS_SITE_URL_UPDATE_PASSWORD`. Set +`WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true` at the same time. Direct values +are visible through `docker inspect`, so prefer the file-based token for shared +or long-running hosts. Use TLS whenever the endpoint is reachable across an +untrusted network. The tool intentionally refuses WordPress Multisite installations. It also refuses to write when `WP_HOME` or `WP_SITEURL` is defined in `wp-config.php`, diff --git a/SECURITY.md b/SECURITY.md index 516de55..fa6f995 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -72,6 +72,8 @@ While not vulnerabilities in this image, the following practices reduce your exp - Use strong administrator credentials and limit access to the WordPress dashboard. - Leave the site URL recovery endpoint disabled except during a recovery. Use the exact `WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true` switch together with - `WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE` and a randomly generated token. Send - the token only over TLS on untrusted networks, then remove both environment - settings immediately after the repair. + exactly one credential source. Prefer `WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE` + with a randomly generated token; direct `WORDPRESS_SITE_URL_UPDATE_TOKEN` and + `WORDPRESS_SITE_URL_UPDATE_PASSWORD` values are visible in container + environment metadata. Send credentials only over TLS on untrusted networks, + then remove the enable switch and credential immediately after the repair. diff --git a/tests/test-tool-update-site-url.php b/tests/test-tool-update-site-url.php index fd25ffa..4f29891 100644 --- a/tests/test-tool-update-site-url.php +++ b/tests/test-tool-update-site-url.php @@ -42,6 +42,7 @@ function site_url_tool_assert_throws( $callback, $exception, $label ) { putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE' ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD' ); putenv( 'SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED' ); site_url_tool_assert_same( false, sqlite_wordpress_site_url_tool_is_enabled(), 'tool is disabled without an enable switch' ); @@ -53,12 +54,12 @@ function site_url_tool_assert_throws( $callback, $exception, $label ) { } putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true' ); site_url_tool_assert_same( true, sqlite_wordpress_site_url_tool_is_enabled(), 'exact lowercase true enables the tool' ); -site_url_tool_assert_same( null, sqlite_wordpress_site_url_tool_configured_token(), 'enabled tool still requires a token' ); +site_url_tool_assert_same( null, sqlite_wordpress_site_url_tool_configured_credential(), 'enabled tool still requires a credential' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN=too-short' ); site_url_tool_assert_throws( function () { - sqlite_wordpress_site_url_tool_configured_token(); + sqlite_wordpress_site_url_tool_configured_credential(); }, RuntimeException::class, 'weak direct token is rejected' @@ -66,7 +67,7 @@ function () { $strong_token = str_repeat( 'a', 64 ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN=' . $strong_token ); -site_url_tool_assert_same( $strong_token, sqlite_wordpress_site_url_tool_configured_token(), 'strong direct token is accepted' ); +site_url_tool_assert_same( $strong_token, sqlite_wordpress_site_url_tool_configured_credential(), 'strong direct token is accepted' ); $token_file = tempnam( sys_get_temp_dir(), 'site-url-token-' ); if ( false === $token_file ) { @@ -76,24 +77,57 @@ function () { file_put_contents( $token_file, str_repeat( 'b', 64 ) . "\n" ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE=' . $token_file ); -site_url_tool_assert_same( str_repeat( 'b', 64 ), sqlite_wordpress_site_url_tool_configured_token(), 'Docker secret token is accepted' ); +site_url_tool_assert_same( str_repeat( 'b', 64 ), sqlite_wordpress_site_url_tool_configured_credential(), 'Docker secret token is accepted' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN=' . $strong_token ); site_url_tool_assert_throws( function () { - sqlite_wordpress_site_url_tool_configured_token(); + sqlite_wordpress_site_url_tool_configured_credential(); }, RuntimeException::class, 'ambiguous token sources are rejected' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE' ); -unlink( $token_file ); putenv( 'SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED=' . str_repeat( 'c', 64 ) ); -site_url_tool_assert_same( str_repeat( 'c', 64 ), sqlite_wordpress_site_url_tool_configured_token(), 'entrypoint-resolved secret is accepted' ); +site_url_tool_assert_same( str_repeat( 'c', 64 ), sqlite_wordpress_site_url_tool_configured_credential(), 'entrypoint-resolved secret is accepted' ); putenv( 'SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED' ); + +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD=too-short' ); +site_url_tool_assert_throws( + function () { + sqlite_wordpress_site_url_tool_configured_credential(); + }, + RuntimeException::class, + 'weak password is rejected' +); +$strong_password = str_repeat( 'p', 16 ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD=' . $strong_password ); +site_url_tool_assert_same( $strong_password, sqlite_wordpress_site_url_tool_configured_credential(), 'strong direct password is accepted' ); + +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE=' . $token_file ); +site_url_tool_assert_throws( + function () { + sqlite_wordpress_site_url_tool_configured_credential(); + }, + RuntimeException::class, + 'password and token file cannot be configured together' +); +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE' ); + +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN=' . $strong_token ); +site_url_tool_assert_throws( + function () { + sqlite_wordpress_site_url_tool_configured_credential(); + }, + RuntimeException::class, + 'password and token cannot be configured together' +); +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN' ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD' ); putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED' ); +unlink( $token_file ); $valid_urls = array( 'public URL' => array( 'https://example.com/', 'https://example.com' ), diff --git a/tool-update-site-url.php b/tool-update-site-url.php index 6a54edf..f8d2b1b 100644 --- a/tool-update-site-url.php +++ b/tool-update-site-url.php @@ -1,13 +1,13 @@ 1 ) { + throw new RuntimeException( 'Set only one site URL update credential source.' ); } - if ( $resolved_set && $direct_set ) { - throw new RuntimeException( 'The resolved and direct site URL update tokens must not both be set.' ); + if ( $resolved_set && ( $direct_set || $password_set ) ) { + throw new RuntimeException( 'The resolved token and direct site URL update credentials must not both be set.' ); } - if ( ! $direct_set && ! $file_set && ! $resolved_set ) { + if ( 0 === $source_count && ! $resolved_set ) { return null; } + $minimum_length = 32; + $credential_name = 'token'; if ( $resolved_set ) { $direct_token = $resolved; } elseif ( $file_set ) { @@ -92,17 +98,21 @@ function sqlite_wordpress_site_url_tool_configured_token() { if ( false === $direct_token ) { throw new RuntimeException( 'The configured site URL update token file could not be read.' ); } + } elseif ( $password_set ) { + $direct_token = $password; + $minimum_length = 16; + $credential_name = 'password'; } - $token = trim( (string) $direct_token ); - if ( strlen( $token ) < 32 || strlen( $token ) > 1024 ) { - throw new RuntimeException( 'The site URL update token must contain between 32 and 1024 characters.' ); + $credential = trim( (string) $direct_token ); + if ( strlen( $credential ) < $minimum_length || strlen( $credential ) > 1024 ) { + throw new RuntimeException( sprintf( 'The site URL update %s must contain between %d and 1024 characters.', $credential_name, $minimum_length ) ); } - if ( preg_match( '/[\x00-\x1F\x7F]/', $token ) ) { - throw new RuntimeException( 'The site URL update token must not contain control characters.' ); + if ( preg_match( '/[\x00-\x1F\x7F]/', $credential ) ) { + throw new RuntimeException( 'The site URL update credential must not contain control characters.' ); } - return $token; + return $credential; } /** @@ -217,9 +227,9 @@ function sqlite_wordpress_site_url_tool_render( $title, $message, $status = 'inf