diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9dabb4e..9a86764 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2bcbf..cd46182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 942c0bd..3b2fbcd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` @@ -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 @@ -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 @@ -168,6 +174,9 @@ docker exec -it 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 diff --git a/Dockerfile b/Dockerfile index f8a34d4..0295082 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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" diff --git a/README.md b/README.md index 317fa46..abc11ac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 4df0f5f..ab5ecc8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index 409f37b..9ece0f3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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/`. @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index d0a51dd..be8f052 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}" @@ -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: diff --git a/docker-entrypoint-sqlite.sh b/docker-entrypoint-sqlite.sh index 3eff210..2da1967 100755 --- a/docker-entrypoint-sqlite.sh +++ b/docker-entrypoint-sqlite.sh @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/tests/image-smoke-user-password.php b/tests/image-smoke-user-password.php new file mode 100644 index 0000000..0e20861 --- /dev/null +++ b/tests/image-smoke-user-password.php @@ -0,0 +1,91 @@ + $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" ); diff --git a/tests/image-smoke.sh b/tests/image-smoke.sh index 6fc02e0..f6fe91c 100755 --- a/tests/image-smoke.sh +++ b/tests/image-smoke.sh @@ -43,6 +43,7 @@ docker run --rm --platform "${platform}" --entrypoint bash "${image}" -ceu ' test -f /usr/src/wordpress/wp-content/mu-plugins/sqlite-local-core-update.php test -f /usr/src/wordpress/wp-content/mu-plugins/sqlite-select-id-key-fix.php test -f /usr/src/wordpress/tool-update-site-url.php + test -f /usr/src/wordpress/tool-reset-user-password.php test -s "${package}" test -s "${package}.sha256" test "$(sha256sum "${package}" | awk "{print \$1}")" = "$(cat "${package}.sha256")" @@ -54,6 +55,7 @@ docker run --rm --platform "${platform}" --entrypoint bash "${image}" -ceu ' php -l /usr/src/wordpress/wp-content/db.php php -l /usr/src/wordpress/wp-content/mu-plugins/sqlite-local-core-update.php php -l /usr/src/wordpress/tool-update-site-url.php + php -l /usr/src/wordpress/tool-reset-user-password.php if [[ "${expected_parser}" == native ]]; then test -s /usr/local/lib/php/extensions/wp_mysql_parser.so else @@ -133,3 +135,7 @@ docker run --rm --platform "${platform}" "${image}" php -r ' docker run --rm --platform "${platform}" \ --volume "${repo_root}/tests/image-smoke-site-url.php:/tmp/image-smoke-site-url.php:ro" \ "${image}" php /tmp/image-smoke-site-url.php + +docker run --rm --platform "${platform}" \ + --volume "${repo_root}/tests/image-smoke-user-password.php:/tmp/image-smoke-user-password.php:ro" \ + "${image}" php /tmp/image-smoke-user-password.php diff --git a/tests/test-entrypoint-reconcile.sh b/tests/test-entrypoint-reconcile.sh index fd8ab84..a610d5f 100755 --- a/tests/test-entrypoint-reconcile.sh +++ b/tests/test-entrypoint-reconcile.sh @@ -30,21 +30,36 @@ mkdir -p "${stub_bin}" "${src_plugin}" "${dst_plugin}" "${dst_content}/database" chmod 0751 "${src_plugin}" printf '#!/usr/bin/env bash\nexit 0\n' > "${stub_bin}/docker-ensure-installed.sh" chmod +x "${stub_bin}/docker-ensure-installed.sh" -printf "#!/usr/bin/env bash\ntest \"\${SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED:-}\" = \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n" > "${stub_bin}/assert-site-url-token" -chmod +x "${stub_bin}/assert-site-url-token" +# These variables must expand when the generated child script runs. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'test "${SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED:-}" = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' \ + 'test "${SQLITE_WORDPRESS_USER_PASSWORD_RESET_TOKEN_RESOLVED:-}" = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' \ + > "${stub_bin}/assert-recovery-tokens" +chmod +x "${stub_bin}/assert-recovery-tokens" printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' > "${fixture_root}/site-url-token" chmod 600 "${fixture_root}/site-url-token" +printf 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n' > "${fixture_root}/user-password-token" +chmod 600 "${fixture_root}/user-password-token" recovery_state_file="${dst_content}/database/.ht.site-url-update-tool-state" recovery_lock_file="${recovery_state_file}.lock" printf 'used recovery state\n' > "${recovery_state_file}" printf 'recovery lock\n' > "${recovery_lock_file}" +password_state_file="${dst_content}/database/.ht.user-password-reset-tool-state" +password_lock_file="${password_state_file}.lock" +printf 'used password reset state\n' > "${password_state_file}" +printf 'password reset lock\n' > "${password_lock_file}" printf 'new drop-in\n' > "${src_content}/db.php" printf 'new recovery tool\n' > "${prepare_dir}/tool-update-site-url.php" +printf 'new password reset tool\n' > "${prepare_dir}/tool-reset-user-password.php" printf 'outside drop-in must remain unchanged\n' > "${fixture_root}/outside-drop-in" ln -s "${fixture_root}/outside-drop-in" "${dst_content}/db.php" printf 'outside recovery tool must remain unchanged\n' > "${fixture_root}/outside-recovery-tool" ln -s "${fixture_root}/outside-recovery-tool" "${docroot}/tool-update-site-url.php" +printf 'outside password reset tool must remain unchanged\n' > "${fixture_root}/outside-password-tool" +ln -s "${fixture_root}/outside-password-tool" "${docroot}/tool-reset-user-password.php" printf 'new integration file\n' > "${src_plugin}/current.php" printf 'old integration file\n' > "${dst_plugin}/current.php" printf 'removed upstream file\n' > "${dst_plugin}/stale.php" @@ -67,9 +82,10 @@ PATH="${stub_bin}:${PATH}" \ WORDPRESS_PREPARE_DIR="${prepare_dir}" \ WORDPRESS_DOCROOT="${docroot}" \ WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE="${fixture_root}/site-url-token" \ + WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE="${fixture_root}/user-password-token" \ APACHE_RUN_USER="$(id -u)" \ APACHE_RUN_GROUP="$(id -g)" \ - bash "${repo_root}/docker-entrypoint-sqlite.sh" assert-site-url-token + bash "${repo_root}/docker-entrypoint-sqlite.sh" assert-recovery-tokens cmp "${src_content}/db.php" "${dst_content}/db.php" test ! -L "${dst_content}/db.php" @@ -78,6 +94,10 @@ cmp "${prepare_dir}/tool-update-site-url.php" "${docroot}/tool-update-site-url.p test ! -L "${docroot}/tool-update-site-url.php" test "$(stat -c '%a' "${docroot}/tool-update-site-url.php")" = '644' grep -Fxq 'outside recovery tool must remain unchanged' "${fixture_root}/outside-recovery-tool" +cmp "${prepare_dir}/tool-reset-user-password.php" "${docroot}/tool-reset-user-password.php" +test ! -L "${docroot}/tool-reset-user-password.php" +test "$(stat -c '%a' "${docroot}/tool-reset-user-password.php")" = '644' +grep -Fxq 'outside password reset tool must remain unchanged' "${fixture_root}/outside-password-tool" diff -qr "${src_plugin}" "${dst_plugin}" test "$(stat -c '%a' "${dst_plugin}")" = "$(stat -c '%a' "${src_plugin}")" test ! -e "${dst_plugin}/stale.php" @@ -98,6 +118,8 @@ grep -Fxq 'outside loader must remain unchanged' "${fixture_root}/outside-loader test -f "${dst_content}/database/.ht.sqlite" test ! -e "${recovery_state_file}" test ! -e "${recovery_lock_file}" +test ! -e "${password_state_file}" +test ! -e "${password_lock_file}" assert_no_reconcile_artifacts # A persisted symlink must be replaced even when its target already has exactly @@ -242,13 +264,19 @@ assert_no_reconcile_artifacts # recovery configuration cannot reopen an authorization that was already used. printf 'used recovery state\n' > "${recovery_state_file}" printf 'recovery lock\n' > "${recovery_lock_file}" +printf 'used password reset state\n' > "${password_state_file}" +printf 'password reset lock\n' > "${password_lock_file}" PATH="${stub_bin}:${PATH}" \ WORDPRESS_PREPARE_DIR="${prepare_dir}" \ WORDPRESS_DOCROOT="${docroot}" \ WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true \ WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE="${fixture_root}/site-url-token" \ + WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED=true \ + WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE="${fixture_root}/user-password-token" \ APACHE_RUN_USER="$(id -u)" \ APACHE_RUN_GROUP="$(id -g)" \ - bash "${repo_root}/docker-entrypoint-sqlite.sh" assert-site-url-token + bash "${repo_root}/docker-entrypoint-sqlite.sh" assert-recovery-tokens grep -Fxq 'used recovery state' "${recovery_state_file}" grep -Fxq 'recovery lock' "${recovery_lock_file}" +grep -Fxq 'used password reset state' "${password_state_file}" +grep -Fxq 'password reset lock' "${password_lock_file}" diff --git a/tests/test-tool-reset-user-password.php b/tests/test-tool-reset-user-password.php new file mode 100644 index 0000000..0f15328 --- /dev/null +++ b/tests/test-tool-reset-user-password.php @@ -0,0 +1,142 @@ + 'ready', 'retry_after' => 0 ), + sqlite_wordpress_site_url_tool_availability( 1000000 ), + 'password tool has an independent ready authorization state' +); +$authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, $strong_password, 1000001 ); +user_password_tool_assert_same( 'accepted', $authorization['status'], 'correct credential reserves one password reset' ); +putenv( 'WORDPRESS_USER_PASSWORD_RESET_PASSWORD=' . $strong_password ); +sqlite_wordpress_user_password_tool_consume_operation( $authorization['operation_id'], 1000001 ); +user_password_tool_assert_same( 'false', getenv( 'WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED' ), 'consumed authorization disables the current worker' ); +user_password_tool_assert_same( false, getenv( 'WORDPRESS_USER_PASSWORD_RESET_PASSWORD' ), 'consumed authorization clears the current worker credential' ); +user_password_tool_assert_same( + array( 'status' => 'used', 'retry_after' => 0 ), + sqlite_wordpress_site_url_tool_availability( 1000002 ), + 'consumed password reset remains locked' +); + +foreach ( array( $state_file, $state_file . '.lock', $token_file ) as $fixture ) { + if ( file_exists( $fixture ) || is_link( $fixture ) ) { + unlink( $fixture ); + } +} +fwrite( STDOUT, "user password reset tool tests passed\n" ); diff --git a/tool-reset-user-password.php b/tool-reset-user-password.php new file mode 100644 index 0000000..ab4a2af --- /dev/null +++ b/tool-reset-user-password.php @@ -0,0 +1,469 @@ + 1024 ) { + throw new RuntimeException( sprintf( 'The user password reset %s must contain between %d and 1024 characters.', $credential_name, $minimum_length ) ); + } + if ( preg_match( '/[\x00-\x1F\x7F]/', $credential ) ) { + throw new RuntimeException( 'The user password reset credential must not contain control characters.' ); + } + + return $credential; +} + +/** + * Removes password-reset configuration from the current PHP worker. + * + * @return void + */ +function sqlite_wordpress_user_password_tool_disable_runtime_environment() { + putenv( 'WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED=false' ); + $_ENV['WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED'] = 'false'; + $_SERVER['WORDPRESS_USER_PASSWORD_RESET_TOOL_ENABLED'] = 'false'; + + foreach ( array( 'WORDPRESS_USER_PASSWORD_RESET_TOKEN_FILE', 'WORDPRESS_USER_PASSWORD_RESET_PASSWORD', 'SQLITE_WORDPRESS_USER_PASSWORD_RESET_TOKEN_RESOLVED' ) as $credential_name ) { + putenv( $credential_name ); + unset( $_ENV[ $credential_name ], $_SERVER[ $credential_name ] ); + } +} + +/** + * Consumes the shared one-shot authorization and disables this endpoint. + * + * @param string $operation_id Reserved operation id. + * @param int|null $now Optional Unix timestamp for tests. + * @return void + */ +function sqlite_wordpress_user_password_tool_consume_operation( $operation_id, $now = null ) { + sqlite_wordpress_site_url_tool_consume_operation( $operation_id, $now ); + sqlite_wordpress_user_password_tool_disable_runtime_environment(); +} + +/** + * Validates a submitted WordPress user id. + * + * @param mixed $value Submitted value. + * @return int Validated user id. + * @throws InvalidArgumentException When the id is invalid. + */ +function sqlite_wordpress_user_password_tool_validate_user_id( $value ) { + if ( ! is_string( $value ) || '' === $value || strlen( $value ) > 20 || ! ctype_digit( $value ) ) { + throw new InvalidArgumentException( 'Select a valid WordPress user.' ); + } + $user_id = (int) $value; + if ( $user_id < 1 || (string) $user_id !== ltrim( $value, '0' ) ) { + throw new InvalidArgumentException( 'Select a valid WordPress user.' ); + } + + return $user_id; +} + +/** + * Validates a new account password and its confirmation. + * + * @param mixed $password New password. + * @param mixed $confirmation Confirmation value. + * @return string Validated password. + * @throws InvalidArgumentException When the password is invalid. + */ +function sqlite_wordpress_user_password_tool_validate_password( $password, $confirmation ) { + if ( ! is_string( $password ) || ! is_string( $confirmation ) ) { + throw new InvalidArgumentException( 'Enter and confirm the new password.' ); + } + if ( strlen( $password ) < 12 || strlen( $password ) > 4096 ) { + throw new InvalidArgumentException( 'The new password must contain between 12 and 4096 characters.' ); + } + if ( 1 !== preg_match( '//u', $password ) || false !== strpos( $password, "\0" ) ) { + throw new InvalidArgumentException( 'The new password must be valid UTF-8 and must not contain a null byte.' ); + } + if ( ! hash_equals( $password, $confirmation ) ) { + throw new InvalidArgumentException( 'The new password and confirmation do not match.' ); + } + + return $password; +} + +/** + * Returns the selectable users without exposing email addresses or hashes. + * + * @return array User records. + */ +function sqlite_wordpress_user_password_tool_users() { + $users = get_users( + array( + 'orderby' => 'user_login', + 'order' => 'ASC', + 'fields' => array( 'ID', 'user_login', 'display_name' ), + ) + ); + + return is_array( $users ) ? $users : array(); +} + +/** + * Renders the standalone password reset page. + * + * @param string $title Page heading. + * @param string $message Status text. + * @param string $status info, error, or success. + * @param array $users Selectable users. + * @param int $selected_user_id Selected user id. + * @param bool $show_form Whether to render the form. + * @param string $reset_user_login Successfully reset login name. + * @return void + */ +function sqlite_wordpress_user_password_tool_render( $title, $message, $status = 'info', $users = array(), $selected_user_id = 0, $show_form = true, $reset_user_login = '' ) { + $status_class = in_array( $status, array( 'info', 'error', 'success' ), true ) ? $status : 'info'; + ?> + + + + + + <?php echo sqlite_wordpress_site_url_tool_escape( $title ); ?> + + + +
+
+

+
+ +
+ + + The recovery credential is sent only in the POST body. + + + + The list shows every user in this single-site WordPress installation. + + + + Use at least 12 characters. A longer generated password is recommended. + + + + + +
+ +

User:

+

The one-time authorization is now locked. Remove the enable switch and recovery credential, then recreate the container.

+ +
+
+ + + get_driver(); + if ( ! is_object( $driver ) || ! method_exists( $driver, 'beginTransaction' ) || ! method_exists( $driver, 'commit' ) || ! method_exists( $driver, 'rollBack' ) ) { + throw new RuntimeException( 'The active SQLite driver does not expose transaction support.' ); + } + + $in_transaction = false; + try { + $driver->beginTransaction(); + $in_transaction = true; + wp_set_password( $password, $user_id ); + + $stored_hash = $wpdb->get_var( $wpdb->prepare( "SELECT user_pass FROM {$wpdb->users} WHERE ID = %d", $user_id ) ); + if ( ! is_string( $stored_hash ) || '' === $stored_hash || ! wp_check_password( $password, $stored_hash, $user_id ) ) { + throw new RuntimeException( 'The updated password could not be verified.' ); + } + + $driver->commit(); + $in_transaction = false; + } catch ( Throwable $error ) { + if ( $in_transaction ) { + try { + $driver->rollBack(); + } catch ( Throwable $rollback_error ) { + error_log( 'user password reset rollback failed: ' . $rollback_error->getMessage() ); + } + } + clean_user_cache( $user_id ); + throw new RuntimeException( 'The WordPress user password was not reset.', 0, $error ); + } +} + +/** + * Loads WordPress for user enumeration and password updates. + * + * @return bool Whether WordPress was loaded successfully. + */ +function sqlite_wordpress_user_password_tool_load_wordpress() { + $wp_load = __DIR__ . '/wp-load.php'; + if ( ! is_file( $wp_load ) ) { + return false; + } + if ( ! defined( 'WP_USE_THEMES' ) ) { + define( 'WP_USE_THEMES', false ); + } + require_once $wp_load; + return true; +} + +/** + * Handles the standalone password-reset request. + * + * @return void + */ +function sqlite_wordpress_user_password_tool_main() { + sqlite_wordpress_site_url_tool_send_headers(); + if ( ! sqlite_wordpress_user_password_tool_is_enabled() ) { + http_response_code( 404 ); + echo 'Not Found'; + return; + } + + try { + $configured_credential = sqlite_wordpress_user_password_tool_configured_credential(); + } catch ( RuntimeException $error ) { + error_log( 'user password reset configuration error: ' . $error->getMessage() ); + http_response_code( 503 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset Unavailable', 'The recovery credential configuration is invalid. Check the container logs.', 'error', array(), 0, false ); + return; + } + if ( null === $configured_credential ) { + http_response_code( 404 ); + echo 'Not Found'; + return; + } + + try { + $availability = sqlite_wordpress_site_url_tool_availability(); + } catch ( RuntimeException $error ) { + error_log( 'user password reset state error: ' . $error->getMessage() ); + http_response_code( 503 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset Unavailable', 'The recovery authorization state is unavailable. Check the container logs.', 'error', array(), 0, false ); + return; + } + if ( 'used' === $availability['status'] ) { + http_response_code( 404 ); + echo 'Not Found'; + return; + } + if ( 'locked' === $availability['status'] ) { + header( 'Retry-After: ' . max( 1, (int) $availability['retry_after'] ) ); + http_response_code( 429 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset Temporarily Locked', 'Too many invalid recovery credentials were submitted. Wait 15 minutes before trying again.', 'error', array(), 0, false ); + return; + } + if ( 'busy' === $availability['status'] ) { + header( 'Retry-After: ' . max( 1, (int) $availability['retry_after'] ) ); + http_response_code( 409 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset In Progress', 'Another authenticated password reset request is already in progress.', 'error', array(), 0, false ); + return; + } + + if ( ! sqlite_wordpress_user_password_tool_load_wordpress() ) { + http_response_code( 503 ); + sqlite_wordpress_user_password_tool_render( 'WordPress Unavailable', 'wp-load.php was not found in the document root.', 'error', array(), 0, false ); + return; + } + if ( is_multisite() ) { + http_response_code( 409 ); + sqlite_wordpress_user_password_tool_render( 'Multisite Not Supported', 'This recovery tool only resets users in single-site installations.', 'error', array(), 0, false ); + return; + } + + $users = sqlite_wordpress_user_password_tool_users(); + if ( empty( $users ) ) { + http_response_code( 409 ); + sqlite_wordpress_user_password_tool_render( 'No Users Available', 'No WordPress users are available to reset.', 'error', array(), 0, false ); + return; + } + + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : 'GET'; + if ( 'GET' === $method ) { + sqlite_wordpress_user_password_tool_render( 'WordPress User Password Reset', 'Select a user, enter a new password, and provide the configured recovery credential.', 'info', $users ); + return; + } + if ( 'POST' !== $method ) { + header( 'Allow: GET, POST' ); + http_response_code( 405 ); + sqlite_wordpress_user_password_tool_render( 'Method Not Allowed', 'Use GET to open this page and POST to submit it.', 'error', array(), 0, false ); + return; + } + + $provided_credential = isset( $_POST['recovery_token'] ) && is_string( $_POST['recovery_token'] ) ? $_POST['recovery_token'] : ''; + try { + $authorization = sqlite_wordpress_site_url_tool_begin_operation( $configured_credential, $provided_credential ); + } catch ( RuntimeException $error ) { + error_log( 'user password reset state error: ' . $error->getMessage() ); + http_response_code( 503 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset Unavailable', 'The recovery authorization state could not be updated. Check the container logs.', 'error', array(), 0, false ); + return; + } + if ( 'used' === $authorization['status'] ) { + http_response_code( 404 ); + echo 'Not Found'; + return; + } + if ( 'locked' === $authorization['status'] ) { + error_log( 'user password reset authentication locked after repeated failures.' ); + header( 'Retry-After: ' . max( 1, (int) $authorization['retry_after'] ) ); + http_response_code( 429 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset Temporarily Locked', 'Too many invalid recovery credentials were submitted. Wait 15 minutes before trying again.', 'error', array(), 0, false ); + return; + } + if ( 'busy' === $authorization['status'] ) { + header( 'Retry-After: ' . max( 1, (int) $authorization['retry_after'] ) ); + http_response_code( 409 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset In Progress', 'Another authenticated password reset request is already in progress.', 'error', array(), 0, false ); + return; + } + $selected_user_id = isset( $_POST['user_id'] ) && is_string( $_POST['user_id'] ) && ctype_digit( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; + if ( 'accepted' !== $authorization['status'] ) { + error_log( 'user password reset authentication failed.' ); + http_response_code( 403 ); + sqlite_wordpress_user_password_tool_render( 'Access Denied', 'The recovery credential is invalid.', 'error', $users, $selected_user_id ); + return; + } + $operation_id = (string) $authorization['operation_id']; + + try { + $user_id = sqlite_wordpress_user_password_tool_validate_user_id( isset( $_POST['user_id'] ) ? $_POST['user_id'] : '' ); + $password = sqlite_wordpress_user_password_tool_validate_password( + isset( $_POST['new_password'] ) ? $_POST['new_password'] : '', + isset( $_POST['confirm_password'] ) ? $_POST['confirm_password'] : '' + ); + $user = get_userdata( $user_id ); + if ( false === $user ) { + throw new InvalidArgumentException( 'The selected WordPress user no longer exists.' ); + } + } catch ( InvalidArgumentException $error ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); + http_response_code( 422 ); + sqlite_wordpress_user_password_tool_render( 'Invalid Password Reset', $error->getMessage(), 'error', $users, $selected_user_id ); + return; + } + + try { + sqlite_wordpress_user_password_tool_consume_operation( $operation_id ); + $operation_id = ''; + sqlite_wordpress_user_password_tool_update_password( $user_id, $password ); + } catch ( Throwable $error ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); + error_log( 'user password reset failed: ' . $error->getMessage() ); + http_response_code( 500 ); + sqlite_wordpress_user_password_tool_render( 'Password Reset Failed', 'The password could not be reset. The one-time authorization is locked; check the container logs before deliberately re-enabling the tool.', 'error', array(), 0, false ); + return; + } + + sqlite_wordpress_user_password_tool_render( 'User Password Reset', 'The WordPress user password was reset successfully and existing login sessions were invalidated. The recovery endpoint has automatically locked.', 'success', array(), 0, false, (string) $user->user_login ); +} + +if ( ! defined( 'SQLITE_WORDPRESS_USER_PASSWORD_TOOL_TESTING' ) ) { + sqlite_wordpress_user_password_tool_main(); +}