Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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();
'
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 25 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down
8 changes: 5 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 41 additions & 7 deletions tests/test-tool-update-site-url.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand All @@ -53,20 +54,20 @@ 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'
);

$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 ) {
Expand All @@ -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' ),
Expand Down
70 changes: 40 additions & 30 deletions tool-update-site-url.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<?php
/**
* Emergency, token-protected updater for the WordPress site URLs.
* Emergency, credential-protected updater for the WordPress site URLs.
*
* This file intentionally lives in the document root instead of inside
* WordPress. It remains reachable when an incorrect `home` or `siteurl` option
* makes the normal site and wp-admin inaccessible. The endpoint is disabled
* unless WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED is exactly `true` and
* WORDPRESS_SITE_URL_UPDATE_TOKEN or WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE
* supplies a strong token.
* WORDPRESS_SITE_URL_UPDATE_TOKEN, WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE, or
* WORDPRESS_SITE_URL_UPDATE_PASSWORD supplies one strong credential.
*
* @package docker-sqlite-wordpress
*/
Expand Down Expand Up @@ -53,33 +53,39 @@ function sqlite_wordpress_site_url_tool_is_enabled() {
}

/**
* Reads and validates the configured recovery token.
* Reads and validates the configured recovery credential.
*
* The *_FILE variant follows the Docker secrets convention and is preferred
* because environment variables are visible through container inspection.
* because direct token/password environment variables are visible through
* container inspection. Credential sources are mutually exclusive.
*
* @return string|null Configured token, or null when the tool is disabled.
* @throws RuntimeException When the token configuration is invalid.
* @return string|null Configured credential, or null when none is configured.
* @throws RuntimeException When the credential configuration is invalid.
*/
function sqlite_wordpress_site_url_tool_configured_token() {
function sqlite_wordpress_site_url_tool_configured_credential() {
$direct_token = getenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN' );
$token_file = getenv( 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE' );
$password = getenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD' );
$resolved = getenv( 'SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED' );
$direct_set = false !== $direct_token && '' !== trim( $direct_token );
$file_set = false !== $token_file && '' !== trim( $token_file );
$password_set = false !== $password && '' !== trim( $password );
$resolved_set = false !== $resolved && '' !== trim( $resolved );

if ( $direct_set && $file_set ) {
throw new RuntimeException( 'Set only one site URL update token source.' );
$source_count = ( $direct_set ? 1 : 0 ) + ( $file_set ? 1 : 0 ) + ( $password_set ? 1 : 0 );
if ( $source_count > 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 ) {
Expand All @@ -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;
Comment thread
soulteary marked this conversation as resolved.
$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;
}

/**
Expand Down Expand Up @@ -217,9 +227,9 @@ function sqlite_wordpress_site_url_tool_render( $title, $message, $status = 'inf
<div class="notice <?php echo sqlite_wordpress_site_url_tool_escape( $status_class ); ?>"><?php echo sqlite_wordpress_site_url_tool_escape( $message ); ?></div>
<?php if ( $show_form ) : ?>
<form method="post" autocomplete="off">
<label for="recovery-token">Recovery Token</label>
<input id="recovery-token" name="recovery_token" type="password" minlength="32" maxlength="1024" required autocomplete="off">
<small>The token is sent only in the POST body; do not put it in the URL.</small>
<label for="recovery-token">Recovery Token or Password</label>
<input id="recovery-token" name="recovery_token" type="password" minlength="16" maxlength="1024" required autocomplete="off">
<small>The credential is sent only in the POST body; do not put it in the URL.</small>

<label for="wordpress-url">WordPress Address (URL)</label>
<input id="wordpress-url" name="wordpress_url" type="url" maxlength="2048" required placeholder="https://example.com" value="<?php echo sqlite_wordpress_site_url_tool_escape( $wordpress_url ); ?>">
Expand All @@ -234,7 +244,7 @@ function sqlite_wordpress_site_url_tool_render( $title, $message, $status = 'inf
<?php elseif ( 'success' === $status_class ) : ?>
<p><strong>WordPress Address:</strong> <code><?php echo sqlite_wordpress_site_url_tool_escape( $wordpress_url ); ?></code></p>
<p><strong>Site Address:</strong> <code><?php echo sqlite_wordpress_site_url_tool_escape( $site_url ); ?></code></p>
<p>Disable the recovery tool now by removing its enable switch and token configuration, then restart the container.</p>
<p>Disable the recovery tool now by removing its enable switch and credential configuration, then restart the container.</p>
<?php endif; ?>
</section>
</main>
Expand Down Expand Up @@ -318,13 +328,13 @@ function sqlite_wordpress_site_url_tool_main() {
}

try {
$configured_token = sqlite_wordpress_site_url_tool_configured_token();
$configured_credential = sqlite_wordpress_site_url_tool_configured_credential();
} catch ( RuntimeException $error ) {
error_log( 'site URL recovery configuration error: ' . $error->getMessage() );
http_response_code( 503 );
sqlite_wordpress_site_url_tool_render(
'Site URL Recovery Unavailable',
'The recovery token configuration is invalid. Check the container logs.',
'The recovery credential configuration is invalid. Check the container logs.',
'error',
'',
'',
Expand All @@ -333,7 +343,7 @@ function sqlite_wordpress_site_url_tool_main() {
return;
}

if ( null === $configured_token ) {
if ( null === $configured_credential ) {
http_response_code( 404 );
echo 'Not Found';
return;
Expand All @@ -343,7 +353,7 @@ function sqlite_wordpress_site_url_tool_main() {
if ( 'GET' === $method ) {
sqlite_wordpress_site_url_tool_render(
'WordPress Site URL Recovery',
'Enter the configured recovery token and both new addresses. The update is committed as one SQLite transaction.'
'Enter the configured recovery token or password and both new addresses. The update is committed as one SQLite transaction.'
);
return;
}
Expand All @@ -355,12 +365,12 @@ function sqlite_wordpress_site_url_tool_main() {
return;
}

$provided_token = isset( $_POST['recovery_token'] ) && is_string( $_POST['recovery_token'] )
$provided_credential = isset( $_POST['recovery_token'] ) && is_string( $_POST['recovery_token'] )
? $_POST['recovery_token']
: '';
if ( strlen( $provided_token ) > 1024 || ! hash_equals( $configured_token, $provided_token ) ) {
if ( strlen( $provided_credential ) > 1024 || ! hash_equals( $configured_credential, $provided_credential ) ) {
http_response_code( 403 );
sqlite_wordpress_site_url_tool_render( 'Access Denied', 'The recovery token is invalid.', 'error' );
sqlite_wordpress_site_url_tool_render( 'Access Denied', 'The recovery credential is invalid.', 'error' );
return;
}

Expand Down