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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ jobs:
sqlite-diagnostics.php \
sqlite-local-core-update.php \
sqlite-select-id-key-fix.php \
tool-reset-user-password.php \
tool-update-site-url.php \
tests/image-smoke.sh \
tests/image-smoke-user-password.php \
tests/image-smoke-site-url.php; then
image_changed=false
fi
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [Unreleased]

### Added

- Added a disabled-by-default `/tool-reset-user-password.php` recovery page.
When explicitly enabled, it lists all single-site WordPress users in a
dropdown and resets the selected account to a confirmed new password.
- Gave the password reset endpoint independent TOKEN_FILE/PASSWORD credentials,
persistent global throttling, concurrent-operation exclusion, and one-shot
state. A consumed reset remains closed across PHP workers and container
restarts until the tool is deliberately disabled once.

## [2026.09.01-r1] - 2026-09-01

### Fixed
Expand Down
11 changes: 10 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ bash tests/test-validate-release.sh
php tests/test-sqlite-local-core-update.php
php tests/test-sqlite-select-id-key-fix.php
php tests/test-tool-update-site-url.php
php tests/test-tool-reset-user-password.php
./scripts/validate-release.sh 2026.09.01-r1
```

Expand All @@ -122,7 +123,8 @@ docker compose config --quiet
CI additionally lints every PHP and shell file, runs ShellCheck and actionlint,
and smoke-tests amd64, native arm64, and the 32-bit ARM pure-PHP fallback when
packaged runtime files change. Changes to
`tool-update-site-url.php`, its entrypoint state handling, or its documentation
`tool-update-site-url.php`, `tool-reset-user-password.php`, their entrypoint
state handling, or their documentation
must preserve these security properties:

- the endpoint is a 404 unless the exact enable switch and one valid credential
Expand All @@ -143,6 +145,10 @@ recovery smoke test used by CI:
docker run --rm \
--volume "${PWD}/tests/image-smoke-site-url.php:/tmp/image-smoke-site-url.php:ro" \
soulteary/sqlite-wordpress:dev php /tmp/image-smoke-site-url.php

docker run --rm \
--volume "${PWD}/tests/image-smoke-user-password.php:/tmp/image-smoke-user-password.php:ro" \
soulteary/sqlite-wordpress:dev php /tmp/image-smoke-user-password.php
```

### Verifying Key Functionality
Expand All @@ -168,6 +174,9 @@ docker exec -it <container> ls -l /var/www/html/wp-content/mu-plugins/
- Whether `/tool-update-site-url.php` is a 404 by default, accepts each
documented credential mode when enabled, updates both options atomically, and
becomes a 404 again immediately after one authenticated write attempt.
- Whether `/tool-reset-user-password.php` is a 404 by default, lists all
single-site users when enabled, resets only the selected account, invalidates
its previous password, and becomes a 404 after one authenticated write.

## Reporting Issues

Expand Down
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ COPY sqlite-database-integration-loader.php ${WORDPRESS_PREPARE_DIR}/wp-content/
# It returns 404 unless explicitly enabled with one strong credential.
COPY tool-update-site-url.php ${WORDPRESS_PREPARE_DIR}/tool-update-site-url.php

# Disabled-by-default emergency endpoint for selecting a local WordPress user
# and resetting its password. It has its own enable switch, credential, and
# persistent one-shot authorization state.
COPY tool-reset-user-password.php ${WORDPRESS_PREPARE_DIR}/tool-reset-user-password.php

# Self-healing entrypoint: the stock WordPress entrypoint only seeds a mounted
# volume when it is empty, so an already-initialized/old volume never receives
# the SQLite drop-in (wp-content/db.php) and WordPress falls back to MySQL
Expand Down Expand Up @@ -203,6 +208,7 @@ RUN test -f "${WORDPRESS_PREPARE_DIR}/wp-content/mu-plugins/sqlite-database-inte
test -f "${WORDPRESS_PREPARE_DIR}/wp-content/mu-plugins/sqlite-local-core-update.php" && \
test -s "/usr/src/wordpress-upgrades/wordpress-${WORDPRESS_VERSION}-no-content.zip" && \
test -f "${WORDPRESS_PREPARE_DIR}/tool-update-site-url.php" && \
test -f "${WORDPRESS_PREPARE_DIR}/tool-reset-user-password.php" && \
grep -q 'SQLITE_DB_DROPIN_VERSION' "${WORDPRESS_PREPARE_DIR}/wp-content/db.php" && \
! grep -q '{SQLITE_IMPLEMENTATION_FOLDER_PATH}' "${WORDPRESS_PREPARE_DIR}/wp-content/db.php"

Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,51 @@ and forwarded `Host` / `X-Forwarded-Proto` headers. The recovery tool changes
only the two database options and cannot repair content URLs or external proxy
configuration.

## Emergency User Password Reset Tool

The image also includes `/tool-reset-user-password.php` for local account
recovery when no administrator can sign in. Once explicitly enabled, the page
loads the single-site WordPress user list into a dropdown (login name and
display name), accepts a new password plus confirmation, and resets the
selected account. It does not display email addresses or existing password
hashes.

The endpoint is disabled by default and returns `404 Not Found` unless
`WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED` is exactly `true` and exactly one
independent recovery credential is configured:

| Variable | Minimum length | Notes |
| --- | ---: | --- |
| `WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE` | 32 characters | Preferred Docker secret or mounted token file. |
| `WORDPRESS_USER_PASSWORD_RESET_PASSWORD` | 24 characters | Direct recovery credential, visible in container metadata. |

It uses the same protection model as the site URL tool: five invalid recovery
credentials in 15 minutes cause a global 15-minute lockout, only one reset may
run at a time, and the first authenticated write attempt consumes the one-shot
authorization before changing SQLite. The user account's new password must be
12–4096 characters and invalidates its existing WordPress login sessions.

For a short-lived loopback-only reset using the direct credential mode:

```bash
export WORDPRESS_USER_PASSWORD_RESET_PASSWORD="$(openssl rand -base64 24)"
export WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED=true
docker compose up -d --force-recreate
```

Then open `http://localhost:8080/tool-reset-user-password.php`, choose the user,
enter and confirm the new account password, and submit the recovery credential.
After verifying the new login, remove both environment variables and recreate
the container. This disabled start safely removes
`wp-content/database/.ht.user-password-reset-tool-state` and its `.lock` file,
rearming a future recovery cycle without exposing the endpoint.

For shared hosts, use a new file containing `openssl rand -hex 32`, mount it
read-only, and set `WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE` instead of the
direct recovery password. Keep the endpoint bound to loopback or restricted by
a TLS reverse proxy and source-IP allowlist while enabled. The tool intentionally
refuses Multisite installations.

## Volume and Upgrade Notes

Back up `wp-content/database/` before upgrading an existing site to the
Expand Down
3 changes: 2 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ authoritative control.
php tests/test-sqlite-local-core-update.php
php tests/test-sqlite-select-id-key-fix.php
php tests/test-tool-update-site-url.php
php tests/test-tool-reset-user-password.php
./scripts/validate-release.sh 2026.09.01-r1
```

4. Let pull-request CI test amd64, native arm64, and the 32-bit ARM pure-PHP
fallback. Verify the local core archive and updater, SQLite CRUD, diagnostics,
and site URL recovery behavior.
site URL recovery, and user password reset behavior.
5. Merge the verified release pull request to `main`. Prepare GitHub Release
notes from its matching changelog section, including upgrade and compatibility
notes.
Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ This policy covers issues **introduced by this project**, including:
`sqlite-select-id-key-fix.php`, and image-local core update components.
- The disabled-by-default, credential-protected `/tool-update-site-url.php`
recovery endpoint and its persistent throttle/one-shot state handling.
- The disabled-by-default, credential-protected
`/tool-reset-user-password.php` endpoint and its independent persistent
throttle/one-shot state handling.
- The packaging and configuration of the native `wp_mysql_parser` extension.
- The release workflows under `.github/workflows/`.

Expand Down Expand Up @@ -109,3 +112,10 @@ While not vulnerabilities in this image, the following practices reduce your exp
Do not rely on the in-process environment change to rewrite Docker Compose;
remove the enable switch and credential and recreate the container after use.
Rearm only through the documented disabled-start sequence with a new secret.
- Apply the same restrictions to the user password reset endpoint. Leave
`WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED` disabled normally, prefer
`WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE`, and restrict
`/tool-reset-user-password.php` to loopback or a TLS/IP-allowlisted proxy.
While enabled, its dropdown reveals WordPress login and display names to
anyone who can reach the page, so keep the enable window short. Remove the
reset credential and recreate the container immediately after use.
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ services:
WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED: "${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-false}"
WORDPRESS_SITE_URL_UPDATE_PASSWORD: "${WORDPRESS_SITE_URL_UPDATE_PASSWORD:-}"

# Emergency local user password reset. Enable only for one short-lived
# reset session, then remove both variables and recreate the container.
# `export WORDPRESS_USER_PASSWORD_RESET_PASSWORD="$(openssl rand -base64 24)"`
# `export WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED=true`
WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED: "${WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED:-false}"
WORDPRESS_USER_PASSWORD_RESET_PASSWORD: "${WORDPRESS_USER_PASSWORD_RESET_PASSWORD:-}"

# The matching checksummed WordPress core package is used by the normal
# updater by default. Set the exact value false to require remote packages.
WORDPRESS_LOCAL_CORE_UPDATE_ENABLED: "${WORDPRESS_LOCAL_CORE_UPDATE_ENABLED:-true}"
Expand All @@ -27,6 +34,7 @@ services:
# this line and the service/top-level secrets blocks below. Leave PASSWORD
# unset or empty: TOKEN_FILE and PASSWORD are mutually exclusive.
# WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE: /run/secrets/site-url-update-token
# WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE: /run/secrets/user-password-reset-token
# secrets:
# - site-url-update-token
volumes:
Expand Down
52 changes: 39 additions & 13 deletions docker-entrypoint-sqlite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ if [ -n "${WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE:-}" ] \
unset resolved_site_url_update_token
fi

if [ -n "${WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE:-}" ] \
&& [ -f "${WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE}" ] \
&& [ -r "${WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE}" ]; then
resolved_user_password_reset_token="$(< "${WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE}")"
export SQLITE_WORDPRESS_USER_PASSWORD_RESET_TOKEN_RESOLVED="${resolved_user_password_reset_token}"
unset resolved_user_password_reset_token
fi

# Run the stock entrypoint's setup so it performs its volume seeding /
# wp-config generation but does NOT start Apache/php-fpm. The stock script only
# runs that setup when invoked as apache2*/php-fpm or under the name
Expand All @@ -43,15 +51,20 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then

# Keep the disabled-by-default recovery endpoint available on existing
# volumes too. Never follow a persisted symlink while copying as root.
managed_root_file="tool-update-site-url.php"
if [ -f "${WORDPRESS_PREPARE_DIR}/${managed_root_file}" ]; then
if [ -L "${DOCROOT}/${managed_root_file}" ] || ! cmp -s "${WORDPRESS_PREPARE_DIR}/${managed_root_file}" "${DOCROOT}/${managed_root_file}"; then
managed_root_files=(
"tool-update-site-url.php"
"tool-reset-user-password.php"
)
for managed_root_file in "${managed_root_files[@]}"; do
if [ -f "${WORDPRESS_PREPARE_DIR}/${managed_root_file}" ]; then
if [ -L "${DOCROOT}/${managed_root_file}" ] || ! cmp -s "${WORDPRESS_PREPARE_DIR}/${managed_root_file}" "${DOCROOT}/${managed_root_file}"; then
rm -f "${DOCROOT}/${managed_root_file}"
cp -f "${WORDPRESS_PREPARE_DIR}/${managed_root_file}" "${DOCROOT}/${managed_root_file}"
fi
else
rm -f "${DOCROOT}/${managed_root_file}"
cp -f "${WORDPRESS_PREPARE_DIR}/${managed_root_file}" "${DOCROOT}/${managed_root_file}"
fi
else
rm -f "${DOCROOT}/${managed_root_file}"
fi
done

# The SQLite drop-in itself. Without this file WordPress uses MySQL.
if [ -f "$src_content/db.php" ]; then
Expand Down Expand Up @@ -214,9 +227,16 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then
# SQLite database. Keep it while the exact enable switch remains active so a
# container restart cannot reopen the endpoint. A deliberately disabled start
# clears the latch and rearms a future enable cycle.
recovery_state_file="$dst_content/database/.ht.site-url-update-tool-state"
recovery_lock_file="${recovery_state_file}.lock"
if [ "${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-}" != 'true' ]; then
clear_recovery_state_if_disabled() {
local recovery_enabled="$1"
local recovery_state_file="$2"
local recovery_lock_file
local recovery_path

recovery_lock_file="${recovery_state_file}.lock"
if [ "${recovery_enabled}" = 'true' ]; then
return
fi
for recovery_path in "$recovery_state_file" "$recovery_lock_file"; do
if [ -e "$recovery_path" ] || [ -L "$recovery_path" ]; then
if [ -d "$recovery_path" ] && [ ! -L "$recovery_path" ]; then
Expand All @@ -226,7 +246,13 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then
rm -f -- "$recovery_path"
fi
done
fi
}
clear_recovery_state_if_disabled \
"${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-false}" \
"$dst_content/database/.ht.site-url-update-tool-state"
clear_recovery_state_if_disabled \
"${WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED:-false}" \
"$dst_content/database/.ht.user-password-reset-tool-state"

# Best-effort ownership/permissions so www-data can create/write the DB.
if [ "$(id -u)" = '0' ]; then
Expand All @@ -236,10 +262,10 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then
group="${group#\#}"
# Keep the security-sensitive recovery endpoint root-owned and read-only
# to the web process. WordPress-owned content remains writable as before.
chown root:root "${DOCROOT}/${managed_root_file}" 2>/dev/null || true
chown root:root "${managed_root_files[@]/#/${DOCROOT}/}" 2>/dev/null || true
chown -R "$user:$group" "$dst_content/db.php" "$dst_content/mu-plugins" "$dst_content/database" 2>/dev/null || true
fi
chmod 644 "${DOCROOT}/${managed_root_file}" 2>/dev/null || true
chmod 644 "${managed_root_files[@]/#/${DOCROOT}/}" 2>/dev/null || true
chmod 755 "$dst_content/database" 2>/dev/null || true
chmod 640 "$dst_content/database/.ht.sqlite" 2>/dev/null || true
fi
Expand Down
91 changes: 91 additions & 0 deletions tests/image-smoke-user-password.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
/**
* Built-image smoke test for user listing, password reset, and one-shot lock.
*/

/**
* @param bool $condition Condition that must be true.
* @param string $message Failure description.
* @return void
*/
function sqlite_wordpress_user_password_smoke_assert( $condition, $message ) {
if ( ! $condition ) {
fwrite( STDERR, "FAIL: {$message}\n" );
exit( 1 );
}
}

/**
* @param string $method HTTP method.
* @param string $credential Recovery credential.
* @param string $user_id Selected user id.
* @param string $password New password.
* @param string $confirmation Password confirmation.
* @return array{status:int,body:string} Response snapshot.
*/
function sqlite_wordpress_user_password_smoke_request( $method, $credential = '', $user_id = '', $password = '', $confirmation = '' ) {
$_SERVER['REQUEST_METHOD'] = $method;
$_POST = array();
if ( 'POST' === $method ) {
$_POST = array(
'recovery_token' => $credential,
'user_id' => $user_id,
'new_password' => $password,
'confirm_password' => $confirmation,
);
}
http_response_code( 200 );
ob_start();
sqlite_wordpress_user_password_tool_main();
$body = ob_get_clean();
return array( 'status' => http_response_code(), 'body' => false === $body ? '' : $body );
}

require '/var/www/html/wp-load.php';
$old_password = 'old generated password 123';
$new_password = 'new generated password 456';
$user_id = wp_insert_user(
array(
'user_login' => 'recovery-smoke-user',
'display_name' => 'Recovery Smoke User',
'user_pass' => $old_password,
)
);
sqlite_wordpress_user_password_smoke_assert( ! is_wp_error( $user_id ), 'password-reset fixture user is created' );

$credential = str_repeat( 'p', 24 );
$state_file = '/var/www/html/wp-content/database/.ht.user-password-reset-tool-state';
$lock_file = $state_file . '.lock';
foreach ( array( $state_file, $lock_file ) as $recovery_file ) {
if ( is_link( $recovery_file ) || ( file_exists( $recovery_file ) && ! unlink( $recovery_file ) ) ) {
fwrite( STDERR, "FAIL: could not reset password recovery state fixture\n" );
exit( 1 );
}
}

putenv( 'WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED=true' );
putenv( 'WORDPRESS_USER_PASSWORD_RESET_PASSWORD=' . $credential );
define( 'SQLITE_WORDPRESS_USER_PASSWORD_TOOL_TESTING', true );
require '/var/www/html/tool-reset-user-password.php';

$response = sqlite_wordpress_user_password_smoke_request( 'GET' );
sqlite_wordpress_user_password_smoke_assert( 200 === $response['status'], 'enabled password reset form loads' );
sqlite_wordpress_user_password_smoke_assert( false !== strpos( $response['body'], 'recovery-smoke-user' ), 'user dropdown includes the fixture login' );
sqlite_wordpress_user_password_smoke_assert( false === strpos( $response['body'], $old_password ), 'rendered form does not expose the old password' );

$response = sqlite_wordpress_user_password_smoke_request( 'POST', $credential, (string) $user_id, $new_password, $new_password );
sqlite_wordpress_user_password_smoke_assert( 200 === $response['status'], 'valid password reset request succeeds' );
sqlite_wordpress_user_password_smoke_assert( false !== strpos( $response['body'], 'User Password Reset' ), 'password reset success page is rendered' );
$updated_user = get_userdata( $user_id );
sqlite_wordpress_user_password_smoke_assert( $updated_user instanceof WP_User, 'updated user remains available' );
sqlite_wordpress_user_password_smoke_assert( wp_check_password( $new_password, $updated_user->user_pass, $user_id ), 'new password is stored' );
sqlite_wordpress_user_password_smoke_assert( ! wp_check_password( $old_password, $updated_user->user_pass, $user_id ), 'old password no longer works' );
sqlite_wordpress_user_password_smoke_assert( file_exists( $state_file ), 'password reset one-shot state is created' );

putenv( 'WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED=true' );
putenv( 'WORDPRESS_USER_PASSWORD_RESET_PASSWORD=' . $credential );
$response = sqlite_wordpress_user_password_smoke_request( 'GET' );
sqlite_wordpress_user_password_smoke_assert( 404 === $response['status'], 'fresh worker configuration cannot reopen a used password reset authorization' );
sqlite_wordpress_user_password_smoke_assert( 'Not Found' === $response['body'], 'used password reset endpoint fails closed' );

fwrite( STDOUT, "user password reset image smoke passed\n" );
Loading