From 76a553e0597bb0f7b726c6d0782fc4c6764f3071 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 30 Aug 2026 02:44:35 +0800 Subject: [PATCH 1/2] security: harden site URL recovery workflow --- .github/workflows/ci.yaml | 6 +- .gitignore | 2 + CHANGELOG.md | 13 + CONTRIBUTING.md | 42 ++- Dockerfile | 2 +- README.md | 143 +++++++-- RELEASING.md | 19 +- SECURITY.md | 12 +- docker-compose.yml | 23 ++ docker-entrypoint-sqlite.sh | 14 + tests/image-smoke-site-url.php | 94 ++++++ tests/test-entrypoint-reconcile.sh | 16 + tests/test-tool-update-site-url.php | 96 +++++- tool-update-site-url.php | 457 +++++++++++++++++++++++++++- 14 files changed, 904 insertions(+), 35 deletions(-) create mode 100644 tests/image-smoke-site-url.php diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bb678ac..69a1e2b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,7 +62,8 @@ jobs: sqlite-database-integration-loader.php \ sqlite-diagnostics.php \ sqlite-select-id-key-fix.php \ - tool-update-site-url.php; then + tool-update-site-url.php \ + tests/image-smoke-site-url.php; then image_changed=false fi echo "image_changed=${image_changed}" >> "${GITHUB_OUTPUT}" @@ -165,3 +166,6 @@ jobs: } $driver->rollBack(); ' + docker run --rm \ + --volume "${PWD}/tests/image-smoke-site-url.php:/tmp/image-smoke-site-url.php:ro" \ + sqlite-wordpress:test php /tmp/image-smoke-site-url.php diff --git a/.gitignore b/.gitignore index 5548067..6c20af7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ wordpress +/.env +/secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ecfc6..37da8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ updates the WordPress `siteurl` and `home` options after a domain, scheme, port, or path change. +### Security + +- Added a persistent global authentication throttle: five failed recovery + credentials in 15 minutes lock the endpoint for 15 minutes. +- Made each enabled recovery authorization one-shot. It is consumed before the + SQLite write, disables the current PHP worker, and remains locked for every + worker and container restart until an explicit disabled start rearms it. +- Increased the direct recovery password minimum from 16 to 24 characters and + documented generated credentials, private-network exposure, TLS, IP + allowlists, and reverse-proxy rate limiting. +- Ignored the documented local `.env` file and `secrets/` directory so recovery + credentials are not accidentally committed. + ## [7.1.0] - 2026-08-29 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfa692f..24f4426 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ Before contributing, please read this guide along with the [Code of Conduct](./C - [Before You Start](#before-you-start) - [Local Development Environment](#local-development-environment) - [Building and Verifying the Image](#building-and-verifying-the-image) + - [Repository Tests](#repository-tests) - [Reporting Issues](#reporting-issues) - [Submitting a Pull Request](#submitting-a-pull-request) - [Code Style Conventions](#code-style-conventions) @@ -68,9 +69,45 @@ This project supports `linux/amd64`, `linux/arm64`, `linux/arm/v7`, `linux/arm/v docker buildx build --platform linux/amd64,linux/arm64 -t soulteary/sqlite-wordpress:dev . ``` +### Repository Tests + +Run the complete fast test set before opening a pull request: + +```bash +bash tests/test-entrypoint-reconcile.sh +bash tests/test-validate-release.sh +php tests/test-sqlite-select-id-key-fix.php +php tests/test-tool-update-site-url.php +./scripts/validate-release.sh 7.1.0 +``` + +CI additionally lints every PHP and shell file, runs ShellCheck and actionlint, +and builds the amd64 image whenever packaged runtime files change. Changes to +`tool-update-site-url.php`, its entrypoint state handling, or its documentation +must preserve these security properties: + +- the endpoint is a 404 unless the exact enable switch and one valid credential + source are configured; +- the fifth invalid credential in 15 minutes starts a 15-minute global lockout; +- only one authenticated operation can run, and a write attempt consumes the + authorization before updating SQLite; +- the persistent used state blocks new PHP workers and container restarts; +- starting once with the enable value absent or not exactly `true` clears the + used state without enabling the endpoint. + +After building `soulteary/sqlite-wordpress:dev`, run the exact built-image +recovery smoke test used by CI: + +```bash +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 +``` + ### Verifying Key Functionality -After modifying the `Dockerfile` or the plugin, please confirm the following inside the container: +After modifying the `Dockerfile`, integration, or recovery tool, please confirm +the following inside the container: - Whether the native extension loads correctly per platform (`amd64` / `arm64` should load it; 32-bit ARM should fall back): @@ -85,6 +122,9 @@ docker exec -it ls -l /var/www/html/wp-content/mu-plugins/ ``` - Whether the SQLite database integration plugin completes installation and can create, read, update, and delete posts normally. +- 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. ## Reporting Issues diff --git a/Dockerfile b/Dockerfile index f98c41f..8a9b454 100644 --- a/Dockerfile +++ b/Dockerfile @@ -111,7 +111,7 @@ COPY sqlite-database-integration-loader.php ${WORDPRESS_PREPARE_DIR}/wp-content/ # Disabled-by-default emergency endpoint for repairing the database-backed # WordPress Address (`siteurl`) and Site Address (`home`) after a domain change. -# It returns 404 unless explicitly enabled and a strong token is supplied. +# It returns 404 unless explicitly enabled with one strong credential. COPY tool-update-site-url.php ${WORDPRESS_PREPARE_DIR}/tool-update-site-url.php # Self-healing entrypoint: the stock WordPress entrypoint only seeds a mounted diff --git a/README.md b/README.md index 7988fb1..51fc826 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,14 @@ Use the quick 1-minute initial installation, enjoy. ## Emergency Site URL Recovery Tool +> **Availability:** this tool was added to `main` after the immutable `7.1.0` +> release. The published `7.1.0` and current `latest` images do not contain it. +> Until the next release, build the current repository and use a local image: +> +> ```bash +> docker build -t sqlite-wordpress:site-url-recovery . +> ``` + The image includes `/tool-update-site-url.php` for recovering a site after an incorrect domain, scheme, port, or subdirectory was saved in WordPress. The standalone page remains reachable even when the normal site or `wp-admin` @@ -104,6 +112,8 @@ transaction: are located). - **Site Address (URL)** → the `home` option (the public visitor-facing URL). +### Security model + The endpoint is disabled by default and returns `404 Not Found` unless both of these independent conditions are met: @@ -119,10 +129,31 @@ Choose exactly one of these credential sources: | --- | ---: | --- | | `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. | +| `WORDPRESS_SITE_URL_UPDATE_PASSWORD` | 24 characters | Direct password; visible in container environment metadata. Use a generated value or long passphrase. | + +The generic variable `PASSWORD` is intentionally ignored to avoid collisions +with unrelated software. Do not configure more than one source at the same +time. + +Authentication is globally throttled across all PHP workers. Five failed +credentials within 15 minutes lock the endpoint for 15 minutes and return +`429 Too Many Requests`. A correct credential reserves one operation for five +minutes so concurrent requests cannot reuse it. Immediately before the SQLite +write, that authorization is permanently consumed: the current PHP worker sets +`WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=false`, removes its credential values, +and writes a one-shot state file beside the SQLite database. Other workers and +container restarts continue returning `404`, even though an application cannot +rewrite Docker's configured environment. + +The global lock prevents distributed guessing without trusting forwarded IP +headers, but an exposed attacker could deliberately trigger the temporary +lockout. Keep the endpoint on a private network or IP allowlist and add reverse +proxy rate limiting when it must be internet-reachable. Always use TLS outside +the local machine. + +### Preferred TOKEN_FILE example -Do not configure more than one source at the same time. To use the preferred -file-based token: +Create a random token file: ```bash mkdir -p secrets @@ -134,7 +165,8 @@ chmod 600 secrets/site-url-update-token services: wordpress: - image: soulteary/sqlite-wordpress:7.1.0 + build: . + image: sqlite-wordpress:site-url-recovery ports: - 8080:80 environment: @@ -150,27 +182,100 @@ secrets: file: ./secrets/site-url-update-token ``` -Recreate the container, then open -`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. +### PASSWORD Compose example + +`WORDPRESS_SITE_URL_UPDATE_PASSWORD` is intended only for short-lived local or +otherwise network-restricted recovery. Generate and export a fresh value rather +than committing it to Compose or `.env`: + +```bash +export WORDPRESS_SITE_URL_UPDATE_PASSWORD="$(openssl rand -base64 24)" +``` + +```yaml +services: + + wordpress: + build: . + image: sqlite-wordpress:site-url-recovery + ports: + - 8080:80 + environment: + WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED: "true" + WORDPRESS_SITE_URL_UPDATE_PASSWORD: "${WORDPRESS_SITE_URL_UPDATE_PASSWORD:?export a recovery password first}" + volumes: + - ./wordpress:/var/www/html +``` + +The repository's [`docker-compose.yml`](./docker-compose.yml) contains the same +PASSWORD settings with safe disabled/empty defaults and commented TOKEN_FILE +alternatives. Because direct passwords appear in `docker inspect`, prefer the +TOKEN_FILE example for shared hosts. + +### Recovery workflow and automatic shutdown + +1. Back up `wp-content/database/`. +2. Configure the exact enable value `true` and exactly one credential, then + recreate the container. +3. Open `http://localhost:8080/tool-update-site-url.php`. Enter the credential + and both new addresses. The credential is accepted only in the POST body; + never append it to the URL. +4. Verify both the public site and `wp-admin` at their new addresses. The first + authenticated write attempt consumes the authorization and automatically + hides the endpoint. This remains true even if the database update reports an + error, so inspect the container logs and current option values before doing + anything else. +5. Remove `WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED` and the selected credential + from Compose, then recreate the container. This disabled start clears the + internal used-state file while keeping the endpoint disabled. + +To deliberately rearm the tool later, first start the container once with the +enable setting removed or set to `false`. Then configure a new credential, set +the exact value `true`, and recreate it again. Merely restarting with the same +enabled configuration never clears a consumed authorization. + +The managed latch is +`wp-content/database/.ht.site-url-update-tool-state`. Do not delete or edit it +while the endpoint is enabled; the entrypoint removes it safely during the +documented disabled start. + +### Accepted URL rules + +| Rule | Accepted examples / behavior | +| --- | --- | +| Absolute HTTP(S) URL | `https://example.com`, `http://localhost:8080` | +| Optional port or subdirectory | `https://example.com:8443/wordpress` | +| Local and private hosts | Localhost, private IPv4, and bracketed IPv6 addresses are accepted. | +| Internationalized hostnames | Use their ASCII/Punycode form. | +| Trailing slash | Removed before the option is stored. | +| Rejected components | Embedded username/password, query string, fragment, whitespace, backslash, or `.` / `..` path segments. | + +Both fields are required even when their values are identical. Use different +values only when WordPress core is installed in a subdirectory, for example +`siteurl=https://example.com/wordpress` and `home=https://example.com`. + +### Troubleshooting + +Check detailed server-side errors with `docker compose logs wordpress`. The +endpoint intentionally avoids returning sensitive configuration details. + +| HTTP status | Meaning | Action | +| ---: | --- | --- | +| `404` | Disabled, missing credential, or one-shot authorization already used. | Check the exact enable value and follow the deliberate rearm sequence if a previous attempt consumed it. | +| `403` | Invalid credential before the failure threshold. | Check the selected TOKEN_FILE, TOKEN, or PASSWORD source; do not configure multiple sources. | +| `405` | Unsupported request method. | Open with GET and submit the form with POST. | +| `409` | Another authenticated request is active, Multisite is enabled, or `WP_HOME` / `WP_SITEURL` overrides the database. | Wait for the active request, or correct the unsupported WordPress configuration. | +| `422` | A submitted URL failed validation. | Apply the accepted URL rules above and submit both fields again. | +| `429` | Five failed credentials triggered the 15-minute global lockout. | Wait for `Retry-After`, inspect logs, and restrict network access before retrying. | +| `500` | The atomic SQLite update failed after the one-shot authorization was consumed. | Inspect logs and both option values, then deliberately rearm only if another attempt is required. | +| `503` | Invalid credential configuration, unreadable state/database directory, or missing WordPress runtime. | Check logs, file mounts, permissions, and mutually exclusive credential settings. | The tool intentionally refuses WordPress Multisite installations. It also refuses to write when `WP_HOME` or `WP_SITEURL` is defined in `wp-config.php`, because those constants override the database values; update or remove the constants instead. -### Volume / upgrade note +## Volume and Upgrade Notes Back up `wp-content/database/` before upgrading an existing site to the WordPress 7.1.0 / SQLite Database Integration 3.0.0 image. Version 3.0.0 diff --git a/RELEASING.md b/RELEASING.md index e1804a2..8fe6785 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,14 +4,22 @@ Release tags follow the bundled WordPress version, for example `7.1.0`. A tag pu ## Prepare -1. Update both WordPress `FROM` instructions, the SQLite integration version, README examples, Docker Compose, and `CHANGELOG.md` in a release pull request. +1. Update both WordPress `FROM` instructions, the SQLite integration version, + README examples, Docker Compose, and `CHANGELOG.md` in a release pull request. + Every feature-specific image tag in the README must name a published artifact + that actually contains that feature; remove any temporary "build from main" + availability notice only when the release image has been verified. 2. Run the local release consistency check: ```bash ./scripts/validate-release.sh 7.1.0 ``` -3. Complete the runtime checks described in the pull request, including the native parser and pure-PHP fallback paths. +3. Complete the runtime checks described in the pull request, including the + native parser and pure-PHP fallback paths. Run + `tests/image-smoke-site-url.php` against the built image and verify the + password throttle, atomic two-option update, persistent one-shot lock, and + default 404 behavior. 4. Prepare the GitHub Release notes from the matching `CHANGELOG.md` section. Include compatibility and migration notes, not only the generated pull-request list. 5. Merge the release pull request into `main` before creating the tag. @@ -44,7 +52,12 @@ The manual `Release` entry is only for an unpublished tag selected as the workfl docker buildx imagetools inspect ghcr.io/soulteary/sqlite-wordpress:7.1.0 ``` -2. Pull the version tag from each registry and run the WordPress installation and SQLite diagnostics smoke tests. +2. Pull the version tag from each registry and run the WordPress installation, + SQLite diagnostics, and site URL recovery smoke tests. Exercise both + TOKEN_FILE and PASSWORD configuration, confirm the fifth invalid credential + returns 429, confirm a successful write is followed by 404, and perform a + disabled start before checking that a new credential can deliberately rearm + the tool. 3. Create the GitHub Release from the existing matching tag and copy the full `CHANGELOG.md` entry, including compatibility notes, into its notes. Keep it as a draft until image verification is complete. Do not force-push or retarget a published release tag. If published contents must change, prepare a new patch release. If a workflow fails, rerun only the failed jobs so successful preflight and digest artifacts remain associated with the same run. diff --git a/SECURITY.md b/SECURITY.md index fa6f995..caf7c28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ This policy covers issues **introduced by this project**, including: - The `Dockerfile` and the way the image is assembled (build stages, permissions, bundled files). - The companion must-use plugin `sqlite-select-id-key-fix.php`. -- The disabled-by-default, token-protected `/tool-update-site-url.php` +- The disabled-by-default, credential-protected `/tool-update-site-url.php` recovery endpoint. - The packaging and configuration of the native `wp_mysql_parser` extension. - The release workflows under `.github/workflows/`. @@ -77,3 +77,13 @@ While not vulnerabilities in this image, the following practices reduce your exp `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. +- The recovery endpoint globally locks for 15 minutes after five invalid + credentials in one 15-minute window. This blocks distributed guessing but can + also let an exposed attacker temporarily delay the operator. Restrict the + endpoint with a private network or reverse-proxy IP allowlist and apply an + additional proxy rate limit whenever practical. +- A recovery authorization is one-shot. It is consumed immediately before the + database write and remains locked across PHP workers and container restarts. + 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. diff --git a/docker-compose.yml b/docker-compose.yml index 84c24f2..fba73bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,31 @@ services: wordpress: image: soulteary/sqlite-wordpress:7.1.0 # or use: ghcr.io/soulteary/sqlite-wordpress:7.1.0 + # The recovery tool was added after 7.1.0. Until the next release, run + # `docker build -t sqlite-wordpress:site-url-recovery .` and replace the + # image above with `sqlite-wordpress:site-url-recovery`. restart: always ports: - 8080:80 + environment: + # Emergency site URL recovery stays disabled during normal operation. + # To use the short-lived PASSWORD mode, export both host variables and + # run `docker compose up -d --force-recreate`. Generate the password with: + # `export WORDPRESS_SITE_URL_UPDATE_PASSWORD="$(openssl rand -base64 24)"` + # `export WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true` + WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED: "${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-false}" + WORDPRESS_SITE_URL_UPDATE_PASSWORD: "${WORDPRESS_SITE_URL_UPDATE_PASSWORD:-}" + + # TOKEN_FILE is preferred for shared or internet-reachable hosts. Uncomment + # this line and the service/top-level secrets blocks below. Leave PASSWORD + # unset or empty: TOKEN_FILE, TOKEN, and PASSWORD are mutually exclusive. + # WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE: /run/secrets/site-url-update-token + # secrets: + # - site-url-update-token volumes: - ./wordpress:/var/www/html + +# Preferred TOKEN_FILE definition: +# secrets: +# site-url-update-token: +# file: ./secrets/site-url-update-token diff --git a/docker-entrypoint-sqlite.sh b/docker-entrypoint-sqlite.sh index 750043d..550a6cd 100644 --- a/docker-entrypoint-sqlite.sh +++ b/docker-entrypoint-sqlite.sh @@ -126,6 +126,20 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then mkdir -p "$dst_content/database" [ -e "$dst_content/database/.ht.sqlite" ] || : > "$dst_content/database/.ht.sqlite" + # A successful recovery request creates a persistent one-shot latch beside the + # 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" + if [ "${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-}" != 'true' ] \ + && { [ -e "$recovery_state_file" ] || [ -L "$recovery_state_file" ]; }; then + if [ -d "$recovery_state_file" ] && [ ! -L "$recovery_state_file" ]; then + echo >&2 "sqlite: recovery state path must not be a directory: $recovery_state_file" + exit 1 + fi + rm -f -- "$recovery_state_file" + fi + # Best-effort ownership/permissions so www-data can create/write the DB. if [ "$(id -u)" = '0' ]; then user="${APACHE_RUN_USER:-www-data}" diff --git a/tests/image-smoke-site-url.php b/tests/image-smoke-site-url.php new file mode 100644 index 0000000..b400fab --- /dev/null +++ b/tests/image-smoke-site-url.php @@ -0,0 +1,94 @@ + $credential, + 'wordpress_url' => $siteurl, + 'site_url' => $home, + ); + } + + http_response_code( 200 ); + ob_start(); + sqlite_wordpress_site_url_tool_main(); + $body = ob_get_clean(); + + return array( + 'status' => http_response_code(), + 'body' => false === $body ? '' : $body, + ); +} + +$credential = str_repeat( 'p', 24 ); +$state_file = '/var/www/html/wp-content/database/.ht.site-url-update-tool-state'; +if ( is_link( $state_file ) || ( file_exists( $state_file ) && ! unlink( $state_file ) ) ) { + fwrite( STDERR, "FAIL: could not reset recovery state fixture\n" ); + exit( 1 ); +} + +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true' ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD=' . $credential ); +define( 'SQLITE_WORDPRESS_SITE_URL_TOOL_TESTING', true ); +require '/var/www/html/tool-update-site-url.php'; + +for ( $attempt = 1; $attempt <= 5; ++$attempt ) { + $response = sqlite_wordpress_image_smoke_request( + 'POST', + 'incorrect-recovery-password', + 'https://new.example/wordpress', + 'https://new.example' + ); + $expected = $attempt < 5 ? 403 : 429; + sqlite_wordpress_image_smoke_assert( $expected === $response['status'], "failed attempt {$attempt} returns {$expected}" ); +} + +sqlite_wordpress_image_smoke_assert( unlink( $state_file ), 'test fixture can reset the lockout state' ); +$response = sqlite_wordpress_image_smoke_request( + 'POST', + $credential, + 'https://new.example/wordpress', + 'https://new.example' +); +sqlite_wordpress_image_smoke_assert( 200 === $response['status'], 'valid recovery request succeeds' ); +sqlite_wordpress_image_smoke_assert( false !== strpos( $response['body'], 'Site URLs Updated' ), 'success page is rendered' ); +sqlite_wordpress_image_smoke_assert( 'https://new.example/wordpress' === get_option( 'siteurl' ), 'siteurl is updated in the built image' ); +sqlite_wordpress_image_smoke_assert( 'https://new.example' === get_option( 'home' ), 'home is updated in the built image' ); +sqlite_wordpress_image_smoke_assert( 'false' === getenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED' ), 'current PHP worker is disabled after use' ); +sqlite_wordpress_image_smoke_assert( file_exists( $state_file ), 'persistent one-shot state is created' ); + +// Simulate a fresh worker inheriting the unchanged Docker configuration. The +// persistent used flag must still hide the endpoint. +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true' ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD=' . $credential ); +$response = sqlite_wordpress_image_smoke_request( 'GET' ); +sqlite_wordpress_image_smoke_assert( 404 === $response['status'], 'fresh worker cannot reopen a used authorization' ); +sqlite_wordpress_image_smoke_assert( 'Not Found' === $response['body'], 'used endpoint fails closed without disclosing its state' ); + +fwrite( STDOUT, "site URL recovery image smoke passed\n" ); diff --git a/tests/test-entrypoint-reconcile.sh b/tests/test-entrypoint-reconcile.sh index 8875d31..b9dc6a9 100755 --- a/tests/test-entrypoint-reconcile.sh +++ b/tests/test-entrypoint-reconcile.sh @@ -21,6 +21,8 @@ printf "#!/usr/bin/env bash\ntest \"\${SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RE chmod +x "${stub_bin}/assert-site-url-token" printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' > "${fixture_root}/site-url-token" chmod 600 "${fixture_root}/site-url-token" +recovery_state_file="${dst_content}/database/.ht.site-url-update-tool-state" +printf 'used recovery state\n' > "${recovery_state_file}" printf 'new drop-in\n' > "${src_content}/db.php" printf 'new recovery tool\n' > "${prepare_dir}/tool-update-site-url.php" @@ -76,5 +78,19 @@ test ! -L "${dst_content}/mu-plugins/sqlite-database-integration-loader.php" 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 "${dst_content}/mu-plugins/.sqlite-database-integration.previous" test ! -e "${dst_content}/mu-plugins/.sqlite-database-integration.new.$$" + +# An exact enabled start preserves the one-shot latch, so restarting the same +# recovery configuration cannot reopen an authorization that was already used. +printf 'used recovery state\n' > "${recovery_state_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" \ + APACHE_RUN_USER="$(id -u)" \ + APACHE_RUN_GROUP="$(id -g)" \ + bash "${repo_root}/docker-entrypoint-sqlite.sh" assert-site-url-token +grep -Fxq 'used recovery state' "${recovery_state_file}" diff --git a/tests/test-tool-update-site-url.php b/tests/test-tool-update-site-url.php index 4f29891..a2c4b2c 100644 --- a/tests/test-tool-update-site-url.php +++ b/tests/test-tool-update-site-url.php @@ -3,7 +3,14 @@ * Regression tests for the standalone site URL recovery tool. */ +$site_url_tool_state_file = tempnam( sys_get_temp_dir(), 'site-url-state-' ); +if ( false === $site_url_tool_state_file ) { + fwrite( STDERR, "FAIL: could not create state fixture\n" ); + exit( 1 ); +} + define( 'SQLITE_WORDPRESS_SITE_URL_TOOL_TESTING', true ); +define( 'SQLITE_WORDPRESS_SITE_URL_TOOL_STATE_FILE', $site_url_tool_state_file ); require dirname( __DIR__ ) . '/tool-update-site-url.php'; /** @@ -94,7 +101,7 @@ function () { 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' ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD=' . str_repeat( 'p', 23 ) ); site_url_tool_assert_throws( function () { sqlite_wordpress_site_url_tool_configured_credential(); @@ -102,7 +109,7 @@ function () { RuntimeException::class, 'weak password is rejected' ); -$strong_password = str_repeat( 'p', 16 ); +$strong_password = str_repeat( 'p', 24 ); 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' ); @@ -129,6 +136,90 @@ function () { putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED' ); unlink( $token_file ); +file_put_contents( $site_url_tool_state_file, '' ); +$state_time = 1000000; +site_url_tool_assert_same( + array( 'status' => 'ready', 'retry_after' => 0 ), + sqlite_wordpress_site_url_tool_availability( $state_time ), + 'new authorization state is ready' +); +for ( $attempt = 1; $attempt <= 4; ++$attempt ) { + $authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, 'incorrect-password', $state_time + $attempt ); + site_url_tool_assert_same( 'denied', $authorization['status'], 'failed credential is denied before threshold ' . $attempt ); +} +$authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, 'incorrect-password', $state_time + 5 ); +site_url_tool_assert_same( 'locked', $authorization['status'], 'fifth failed credential locks recovery' ); +site_url_tool_assert_same( 900, $authorization['retry_after'], 'lockout lasts 15 minutes' ); +site_url_tool_assert_same( + array( 'status' => 'locked', 'retry_after' => 800 ), + sqlite_wordpress_site_url_tool_availability( $state_time + 105 ), + 'lockout is shared by later requests' +); +$authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, $strong_password, $state_time + 105 ); +site_url_tool_assert_same( 'locked', $authorization['status'], 'correct credential cannot bypass active lockout' ); +site_url_tool_assert_same( + array( 'status' => 'ready', 'retry_after' => 0 ), + sqlite_wordpress_site_url_tool_availability( $state_time + 906 ), + 'expired lockout resets automatically' +); + +$authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, $strong_password, $state_time + 907 ); +site_url_tool_assert_same( 'accepted', $authorization['status'], 'correct credential reserves recovery operation' ); +$operation_id = $authorization['operation_id']; +site_url_tool_assert_same( 32, strlen( $operation_id ), 'reservation uses a random 128-bit id' ); +site_url_tool_assert_same( + array( 'status' => 'busy', 'retry_after' => 300 ), + sqlite_wordpress_site_url_tool_availability( $state_time + 907 ), + 'parallel recovery requests are blocked' +); +$parallel = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, $strong_password, $state_time + 907 ); +site_url_tool_assert_same( 'busy', $parallel['status'], 'second correct request cannot reserve the same authorization' ); +site_url_tool_assert_same( false, sqlite_wordpress_site_url_tool_cancel_operation( str_repeat( '0', 32 ), $state_time + 908 ), 'wrong reservation cannot be cancelled' ); +site_url_tool_assert_same( true, sqlite_wordpress_site_url_tool_cancel_operation( $operation_id, $state_time + 908 ), 'validation failure releases reservation' ); +site_url_tool_assert_same( + array( 'status' => 'ready', 'retry_after' => 0 ), + sqlite_wordpress_site_url_tool_availability( $state_time + 908 ), + 'released reservation can be retried' +); + +$authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, $strong_password, $state_time + 909 ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true' ); +putenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD=' . $strong_password ); +sqlite_wordpress_site_url_tool_consume_operation( $authorization['operation_id'], $state_time + 909 ); +site_url_tool_assert_same( 'false', getenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED' ), 'consumed authorization disables the current PHP worker' ); +site_url_tool_assert_same( false, getenv( 'WORDPRESS_SITE_URL_UPDATE_PASSWORD' ), 'consumed authorization removes the current worker credential' ); +site_url_tool_assert_same( + array( 'status' => 'used', 'retry_after' => 0 ), + sqlite_wordpress_site_url_tool_availability( $state_time + 910 ), + 'consumed authorization stays locked for every worker' +); +$authorization = sqlite_wordpress_site_url_tool_begin_operation( $strong_password, $strong_password, $state_time + 910 ); +site_url_tool_assert_same( 'used', $authorization['status'], 'used authorization cannot be replayed' ); +site_url_tool_assert_same( 0600, fileperms( $site_url_tool_state_file ) & 0777, 'authorization state is owner-only' ); +site_url_tool_assert_throws( + function () { + sqlite_wordpress_site_url_tool_decode_state( '{"version":1}' ); + }, + RuntimeException::class, + 'malformed authorization state fails closed' +); +unlink( $site_url_tool_state_file ); +$state_symlink_target = tempnam( sys_get_temp_dir(), 'site-url-state-target-' ); +if ( false === $state_symlink_target || ! symlink( $state_symlink_target, $site_url_tool_state_file ) ) { + fwrite( STDERR, "FAIL: could not create state symlink fixture\n" ); + exit( 1 ); +} +site_url_tool_assert_throws( + function () use ( $state_time ) { + sqlite_wordpress_site_url_tool_availability( $state_time + 911 ); + }, + RuntimeException::class, + 'symbolic-link authorization state fails closed' +); +unlink( $site_url_tool_state_file ); +unlink( $state_symlink_target ); +file_put_contents( $site_url_tool_state_file, '' ); + $valid_urls = array( 'public URL' => array( 'https://example.com/', 'https://example.com' ), 'local URL' => array( 'http://localhost:8080/wordpress/', 'http://localhost:8080/wordpress' ), @@ -304,4 +395,5 @@ function () { 'option caches are cleared after rollback' ); +unlink( $site_url_tool_state_file ); fwrite( STDOUT, "tool-update-site-url tests passed\n" ); diff --git a/tool-update-site-url.php b/tool-update-site-url.php index f8d2b1b..d7d5f6a 100644 --- a/tool-update-site-url.php +++ b/tool-update-site-url.php @@ -52,6 +52,379 @@ function sqlite_wordpress_site_url_tool_is_enabled() { return false !== $enabled && hash_equals( 'true', $enabled ); } +/** + * Returns the persistent state file used for throttling and one-shot locking. + * + * The production path lives beside the SQLite database so every Apache worker + * and subsequent container start observes the same state. Tests may override + * it with a constant without exposing another runtime environment setting. + * + * @return string State file path. + */ +function sqlite_wordpress_site_url_tool_state_file() { + if ( defined( 'SQLITE_WORDPRESS_SITE_URL_TOOL_STATE_FILE' ) ) { + return (string) constant( 'SQLITE_WORDPRESS_SITE_URL_TOOL_STATE_FILE' ); + } + + return __DIR__ . '/wp-content/database/.ht.site-url-update-tool-state'; +} + +/** + * Returns a new recovery authorization state. + * + * @return array Default state. + */ +function sqlite_wordpress_site_url_tool_default_state() { + return array( + 'version' => 1, + 'failed_attempts' => 0, + 'failure_window_started' => 0, + 'locked_until' => 0, + 'operation_id' => '', + 'operation_expires' => 0, + 'used' => false, + 'used_at' => 0, + ); +} + +/** + * Validates persisted state and fails closed if it was corrupted. + * + * @param string $raw Raw state file contents. + * @return array Validated state. + * @throws RuntimeException When persisted state is malformed. + */ +function sqlite_wordpress_site_url_tool_decode_state( $raw ) { + if ( '' === $raw ) { + return sqlite_wordpress_site_url_tool_default_state(); + } + + $state = json_decode( $raw, true ); + if ( ! is_array( $state ) || 1 !== ( isset( $state['version'] ) ? $state['version'] : null ) ) { + throw new RuntimeException( 'The site URL recovery state is invalid.' ); + } + + $integer_fields = array( + 'failed_attempts', + 'failure_window_started', + 'locked_until', + 'operation_expires', + 'used_at', + ); + foreach ( $integer_fields as $field ) { + if ( ! isset( $state[ $field ] ) || ! is_int( $state[ $field ] ) || $state[ $field ] < 0 ) { + throw new RuntimeException( 'The site URL recovery state is invalid.' ); + } + } + + if ( ! array_key_exists( 'operation_id', $state ) || ! is_string( $state['operation_id'] ) || ( '' !== $state['operation_id'] && ! preg_match( '/^[a-f0-9]{32}$/D', $state['operation_id'] ) ) ) { + throw new RuntimeException( 'The site URL recovery state is invalid.' ); + } + if ( ! array_key_exists( 'used', $state ) || ! is_bool( $state['used'] ) ) { + throw new RuntimeException( 'The site URL recovery state is invalid.' ); + } + + return $state; +} + +/** + * Expires old failure windows, lockouts, and abandoned operations. + * + * @param array $state Current state. + * @param int $now Current Unix timestamp. + * @return array Normalized state. + */ +function sqlite_wordpress_site_url_tool_normalize_state( $state, $now ) { + $window_seconds = 15 * 60; + + if ( $state['locked_until'] > 0 && $state['locked_until'] <= $now ) { + $state['failed_attempts'] = 0; + $state['failure_window_started'] = 0; + $state['locked_until'] = 0; + } + if ( $state['failure_window_started'] > 0 && $state['failure_window_started'] + $window_seconds <= $now ) { + $state['failed_attempts'] = 0; + $state['failure_window_started'] = 0; + } + if ( $state['operation_expires'] > 0 && $state['operation_expires'] <= $now ) { + $state['operation_id'] = ''; + $state['operation_expires'] = 0; + } + if ( $state['used'] ) { + $state['operation_id'] = ''; + $state['operation_expires'] = 0; + } + + return $state; +} + +/** + * Reads, exclusively locks, updates, and persists authorization state. + * + * @param callable $callback Receives state and returns state plus result. + * @return mixed Callback result. + * @throws RuntimeException When the state cannot be accessed safely. + */ +function sqlite_wordpress_site_url_tool_with_state( $callback ) { + $state_file = sqlite_wordpress_site_url_tool_state_file(); + $state_dir = dirname( $state_file ); + if ( ! is_dir( $state_dir ) || ! is_writable( $state_dir ) ) { + throw new RuntimeException( 'The site URL recovery state directory is not writable.' ); + } + if ( is_link( $state_file ) ) { + throw new RuntimeException( 'The site URL recovery state must not be a symbolic link.' ); + } + + $handle = fopen( $state_file, 'c+b' ); + if ( false === $handle ) { + throw new RuntimeException( 'The site URL recovery state could not be opened.' ); + } + + try { + clearstatcache( true, $state_file ); + $resolved_dir = realpath( $state_dir ); + $resolved_file = realpath( $state_file ); + if ( is_link( $state_file ) || false === $resolved_dir || false === $resolved_file || dirname( $resolved_file ) !== $resolved_dir ) { + throw new RuntimeException( 'The site URL recovery state path is unsafe.' ); + } + if ( ! chmod( $state_file, 0600 ) ) { + throw new RuntimeException( 'The site URL recovery state permissions could not be restricted.' ); + } + if ( ! flock( $handle, LOCK_EX ) ) { + throw new RuntimeException( 'The site URL recovery state could not be locked.' ); + } + + rewind( $handle ); + $raw = stream_get_contents( $handle, 4097 ); + if ( false === $raw || strlen( $raw ) > 4096 ) { + throw new RuntimeException( 'The site URL recovery state is invalid.' ); + } + $state = sqlite_wordpress_site_url_tool_decode_state( $raw ); + $update = call_user_func( $callback, $state ); + if ( ! is_array( $update ) || ! isset( $update['state'] ) || ! array_key_exists( 'result', $update ) ) { + throw new RuntimeException( 'The site URL recovery state update is invalid.' ); + } + + $payload = json_encode( $update['state'], JSON_UNESCAPED_SLASHES ); + if ( false === $payload || ! ftruncate( $handle, 0 ) ) { + throw new RuntimeException( 'The site URL recovery state could not be saved.' ); + } + rewind( $handle ); + $offset = 0; + $length = strlen( $payload ); + while ( $offset < $length ) { + $written = fwrite( $handle, substr( $payload, $offset ) ); + if ( false === $written || 0 === $written ) { + throw new RuntimeException( 'The site URL recovery state could not be saved.' ); + } + $offset += $written; + } + if ( ! fflush( $handle ) ) { + throw new RuntimeException( 'The site URL recovery state could not be saved.' ); + } + + return $update['result']; + } finally { + flock( $handle, LOCK_UN ); + fclose( $handle ); + } +} + +/** + * Returns whether a new request may use the recovery authorization. + * + * @param int|null $now Optional Unix timestamp for deterministic tests. + * @return array Availability status and retry delay. + */ +function sqlite_wordpress_site_url_tool_availability( $now = null ) { + $now = null === $now ? time() : max( 0, (int) $now ); + + return sqlite_wordpress_site_url_tool_with_state( + function ( $state ) use ( $now ) { + $state = sqlite_wordpress_site_url_tool_normalize_state( $state, $now ); + $status = 'ready'; + $retry = 0; + if ( $state['used'] ) { + $status = 'used'; + } elseif ( $state['locked_until'] > $now ) { + $status = 'locked'; + $retry = $state['locked_until'] - $now; + } elseif ( '' !== $state['operation_id'] && $state['operation_expires'] > $now ) { + $status = 'busy'; + $retry = $state['operation_expires'] - $now; + } + + return array( + 'state' => $state, + 'result' => array( 'status' => $status, 'retry_after' => $retry ), + ); + } + ); +} + +/** + * Authenticates a request and reserves the single recovery operation. + * + * Five failures in one 15-minute window globally lock the endpoint for 15 + * minutes. The global counter prevents distributed guessing without trusting + * proxy-controlled client-IP headers. + * + * @param string $configured_credential Configured secret. + * @param string $provided_credential Submitted secret. + * @param int|null $now Optional Unix timestamp for tests. + * @return array Authentication status and operation id. + */ +function sqlite_wordpress_site_url_tool_begin_operation( $configured_credential, $provided_credential, $now = null ) { + $now = null === $now ? time() : max( 0, (int) $now ); + + return sqlite_wordpress_site_url_tool_with_state( + function ( $state ) use ( $configured_credential, $provided_credential, $now ) { + $state = sqlite_wordpress_site_url_tool_normalize_state( $state, $now ); + if ( $state['used'] ) { + return array( 'state' => $state, 'result' => array( 'status' => 'used', 'retry_after' => 0, 'operation_id' => '' ) ); + } + if ( $state['locked_until'] > $now ) { + return array( 'state' => $state, 'result' => array( 'status' => 'locked', 'retry_after' => $state['locked_until'] - $now, 'operation_id' => '' ) ); + } + if ( '' !== $state['operation_id'] && $state['operation_expires'] > $now ) { + return array( 'state' => $state, 'result' => array( 'status' => 'busy', 'retry_after' => $state['operation_expires'] - $now, 'operation_id' => '' ) ); + } + + $credential_matches = strlen( $provided_credential ) <= 1024 && hash_equals( $configured_credential, $provided_credential ); + if ( ! $credential_matches ) { + if ( 0 === $state['failure_window_started'] ) { + $state['failure_window_started'] = $now; + } + ++$state['failed_attempts']; + $status = 'denied'; + $retry = 0; + if ( $state['failed_attempts'] >= 5 ) { + $state['locked_until'] = $now + ( 15 * 60 ); + $status = 'locked'; + $retry = 15 * 60; + } + + return array( 'state' => $state, 'result' => array( 'status' => $status, 'retry_after' => $retry, 'operation_id' => '' ) ); + } + + $state['failed_attempts'] = 0; + $state['failure_window_started'] = 0; + $state['locked_until'] = 0; + $state['operation_id'] = bin2hex( random_bytes( 16 ) ); + $state['operation_expires'] = $now + ( 5 * 60 ); + + return array( + 'state' => $state, + 'result' => array( 'status' => 'accepted', 'retry_after' => 0, 'operation_id' => $state['operation_id'] ), + ); + } + ); +} + +/** + * Releases a reserved operation that did not reach the database write. + * + * @param string $operation_id Reserved operation id. + * @param int|null $now Optional Unix timestamp for tests. + * @return bool Whether the reservation was released. + */ +function sqlite_wordpress_site_url_tool_cancel_operation( $operation_id, $now = null ) { + $now = null === $now ? time() : max( 0, (int) $now ); + + return sqlite_wordpress_site_url_tool_with_state( + function ( $state ) use ( $operation_id, $now ) { + $state = sqlite_wordpress_site_url_tool_normalize_state( $state, $now ); + $released = false; + if ( ! $state['used'] && '' !== $state['operation_id'] && hash_equals( $state['operation_id'], $operation_id ) ) { + $state['operation_id'] = ''; + $state['operation_expires'] = 0; + $released = true; + } + + return array( 'state' => $state, 'result' => $released ); + } + ); +} + +/** + * Disables the recovery switch and removes credentials from this PHP worker. + * + * Docker's configured environment remains immutable; the persistent used flag + * is what closes the endpoint for every other worker and future restart. + * + * @return void + */ +function sqlite_wordpress_site_url_tool_disable_runtime_environment() { + putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=false' ); + $_ENV['WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED'] = 'false'; + $_SERVER['WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED'] = 'false'; + + $credential_names = array( + 'WORDPRESS_SITE_URL_UPDATE_TOKEN', + 'WORDPRESS_SITE_URL_UPDATE_TOKEN_FILE', + 'WORDPRESS_SITE_URL_UPDATE_PASSWORD', + 'SQLITE_WORDPRESS_SITE_URL_UPDATE_TOKEN_RESOLVED', + ); + foreach ( $credential_names as $credential_name ) { + putenv( $credential_name ); + unset( $_ENV[ $credential_name ], $_SERVER[ $credential_name ] ); + } +} + +/** + * Permanently consumes the current one-shot authorization before the write. + * + * @param string $operation_id Reserved operation id. + * @param int|null $now Optional Unix timestamp for tests. + * @return void + * @throws RuntimeException When the reservation cannot be consumed. + */ +function sqlite_wordpress_site_url_tool_consume_operation( $operation_id, $now = null ) { + $now = null === $now ? time() : max( 0, (int) $now ); + + $consumed = sqlite_wordpress_site_url_tool_with_state( + function ( $state ) use ( $operation_id, $now ) { + $state = sqlite_wordpress_site_url_tool_normalize_state( $state, $now ); + if ( $state['used'] || '' === $state['operation_id'] || ! hash_equals( $state['operation_id'], $operation_id ) ) { + return array( 'state' => $state, 'result' => false ); + } + + $state['used'] = true; + $state['used_at'] = $now; + $state['failed_attempts'] = 0; + $state['failure_window_started'] = 0; + $state['locked_until'] = 0; + $state['operation_id'] = ''; + $state['operation_expires'] = 0; + + return array( 'state' => $state, 'result' => true ); + } + ); + if ( ! $consumed ) { + throw new RuntimeException( 'The one-time site URL recovery authorization is no longer available.' ); + } + + sqlite_wordpress_site_url_tool_disable_runtime_environment(); +} + +/** + * Best-effort release for validation and compatibility failures. + * + * @param string $operation_id Reserved operation id. + * @return void + */ +function sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ) { + if ( '' === $operation_id ) { + return; + } + + try { + sqlite_wordpress_site_url_tool_cancel_operation( $operation_id ); + } catch ( Throwable $error ) { + error_log( 'site URL recovery reservation release failed: ' . $error->getMessage() ); + } +} + /** * Reads and validates the configured recovery credential. * @@ -100,7 +473,7 @@ function sqlite_wordpress_site_url_tool_configured_credential() { } } elseif ( $password_set ) { $direct_token = $password; - $minimum_length = 16; + $minimum_length = 24; $credential_name = 'password'; } @@ -228,7 +601,7 @@ function sqlite_wordpress_site_url_tool_render( $title, $message, $status = 'inf
- + The credential is sent only in the POST body; do not put it in the URL. @@ -244,7 +617,7 @@ function sqlite_wordpress_site_url_tool_render( $title, $message, $status = 'inf

WordPress Address:

Site Address:

-

Disable the recovery tool now by removing its enable switch and credential configuration, then restart the container.

+

The one-time authorization is now locked. Remove the enable switch and credential configuration, then recreate the container to finish cleanup.

@@ -349,6 +722,39 @@ function sqlite_wordpress_site_url_tool_main() { return; } + try { + $availability = sqlite_wordpress_site_url_tool_availability(); + } catch ( RuntimeException $error ) { + error_log( 'site URL recovery state error: ' . $error->getMessage() ); + http_response_code( 503 ); + sqlite_wordpress_site_url_tool_render( + 'Site URL Recovery Unavailable', + 'The recovery authorization state is unavailable. Check the container logs and database-directory permissions.', + 'error', + '', + '', + 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_site_url_tool_render( 'Recovery Temporarily Locked', 'Too many invalid credentials were submitted. Wait 15 minutes before trying again.', 'error', '', '', false ); + return; + } + if ( 'busy' === $availability['status'] ) { + header( 'Retry-After: ' . max( 1, (int) $availability['retry_after'] ) ); + http_response_code( 409 ); + sqlite_wordpress_site_url_tool_render( 'Recovery Already In Progress', 'Another authenticated recovery request is already in progress.', 'error', '', '', false ); + return; + } + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : 'GET'; if ( 'GET' === $method ) { sqlite_wordpress_site_url_tool_render( @@ -368,11 +774,39 @@ function sqlite_wordpress_site_url_tool_main() { $provided_credential = isset( $_POST['recovery_token'] ) && is_string( $_POST['recovery_token'] ) ? $_POST['recovery_token'] : ''; - if ( strlen( $provided_credential ) > 1024 || ! hash_equals( $configured_credential, $provided_credential ) ) { + try { + $authorization = sqlite_wordpress_site_url_tool_begin_operation( $configured_credential, $provided_credential ); + } catch ( RuntimeException $error ) { + error_log( 'site URL recovery state error: ' . $error->getMessage() ); + http_response_code( 503 ); + sqlite_wordpress_site_url_tool_render( 'Site URL Recovery Unavailable', 'The recovery authorization state could not be updated. Check the container logs.', 'error', '', '', false ); + return; + } + if ( 'used' === $authorization['status'] ) { + http_response_code( 404 ); + echo 'Not Found'; + return; + } + if ( 'locked' === $authorization['status'] ) { + error_log( 'site URL recovery authentication locked after repeated failures.' ); + header( 'Retry-After: ' . max( 1, (int) $authorization['retry_after'] ) ); + http_response_code( 429 ); + sqlite_wordpress_site_url_tool_render( 'Recovery Temporarily Locked', 'Too many invalid credentials were submitted. Wait 15 minutes before trying again.', 'error', '', '', false ); + return; + } + if ( 'busy' === $authorization['status'] ) { + header( 'Retry-After: ' . max( 1, (int) $authorization['retry_after'] ) ); + http_response_code( 409 ); + sqlite_wordpress_site_url_tool_render( 'Recovery Already In Progress', 'Another authenticated recovery request is already in progress.', 'error', '', '', false ); + return; + } + if ( 'accepted' !== $authorization['status'] ) { + error_log( 'site URL recovery authentication failed.' ); http_response_code( 403 ); sqlite_wordpress_site_url_tool_render( 'Access Denied', 'The recovery credential is invalid.', 'error' ); return; } + $operation_id = (string) $authorization['operation_id']; $wordpress_input = isset( $_POST['wordpress_url'] ) ? $_POST['wordpress_url'] : ''; $site_input = isset( $_POST['site_url'] ) ? $_POST['site_url'] : ''; @@ -381,6 +815,7 @@ function sqlite_wordpress_site_url_tool_main() { $wordpress_url = sqlite_wordpress_site_url_tool_validate_url( $wordpress_input, 'WordPress Address (URL)' ); $site_url = sqlite_wordpress_site_url_tool_validate_url( $site_input, 'Site Address (URL)' ); } catch ( InvalidArgumentException $error ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); http_response_code( 422 ); sqlite_wordpress_site_url_tool_render( 'Invalid Address', @@ -394,6 +829,7 @@ function sqlite_wordpress_site_url_tool_main() { $wp_load = __DIR__ . '/wp-load.php'; if ( ! is_file( $wp_load ) ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); http_response_code( 503 ); sqlite_wordpress_site_url_tool_render( 'WordPress Unavailable', 'wp-load.php was not found in the document root.', 'error', '', '', false ); return; @@ -403,11 +839,13 @@ function sqlite_wordpress_site_url_tool_main() { require_once $wp_load; if ( is_multisite() ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); http_response_code( 409 ); sqlite_wordpress_site_url_tool_render( 'Multisite Not Supported', 'This recovery tool only updates single-site installations.', 'error', $wordpress_url, $site_url ); return; } if ( defined( 'WP_SITEURL' ) || defined( 'WP_HOME' ) ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); http_response_code( 409 ); sqlite_wordpress_site_url_tool_render( 'Configuration Override Detected', @@ -422,6 +860,7 @@ function sqlite_wordpress_site_url_tool_main() { $wordpress_url = sanitize_option( 'siteurl', $wordpress_url ); $site_url = sanitize_option( 'home', $site_url ); if ( is_wp_error( $wordpress_url ) || is_wp_error( $site_url ) ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); http_response_code( 422 ); sqlite_wordpress_site_url_tool_render( 'Invalid Address', 'WordPress rejected one or both addresses.', 'error' ); return; @@ -430,23 +869,27 @@ function sqlite_wordpress_site_url_tool_main() { try { $wordpress_url = sqlite_wordpress_site_url_tool_validate_url( (string) $wordpress_url, 'WordPress Address (URL)' ); $site_url = sqlite_wordpress_site_url_tool_validate_url( (string) $site_url, 'Site Address (URL)' ); + sqlite_wordpress_site_url_tool_consume_operation( $operation_id ); + $operation_id = ''; sqlite_wordpress_site_url_tool_update_options( $wordpress_url, $site_url ); } catch ( Throwable $error ) { + sqlite_wordpress_site_url_tool_cancel_operation_safely( $operation_id ); error_log( 'site URL recovery update failed: ' . $error->getMessage() ); http_response_code( 500 ); sqlite_wordpress_site_url_tool_render( 'Update Failed', - 'The atomic update could not be completed. Check the container logs and verify both settings before retrying.', + 'The atomic update could not be completed. The one-time authorization is locked; check the container logs and verify both settings before deliberately re-enabling the tool.', 'error', $wordpress_url, - $site_url + $site_url, + false ); return; } sqlite_wordpress_site_url_tool_render( 'Site URLs Updated', - 'WordPress Address and Site Address were updated successfully.', + 'WordPress Address and Site Address were updated successfully. The recovery endpoint has automatically locked its one-time authorization.', 'success', $wordpress_url, $site_url, From e7e2dea44afdb1790fbf4dead873a18e78897d8e Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 30 Aug 2026 02:53:33 +0800 Subject: [PATCH 2/2] fix: persist recovery state atomically --- CHANGELOG.md | 3 + CONTRIBUTING.md | 2 + docker-entrypoint-sqlite.sh | 18 ++-- tests/image-smoke-site-url.php | 10 +- tests/test-entrypoint-reconcile.sh | 5 + tests/test-tool-update-site-url.php | 28 +++++- tool-update-site-url.php | 146 ++++++++++++++++++++++------ 7 files changed, 167 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37da8bb..5731706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - Made each enabled recovery authorization one-shot. It is consumed before the SQLite write, disables the current PHP worker, and remains locked for every worker and container restart until an explicit disabled start rearms it. +- Persisted throttle and one-shot state with a stable file lock, synchronized + same-directory temporary file, atomic replacement, and directory sync; + interrupted, empty, missing, or malformed existing state now fails closed. - Increased the direct recovery password minimum from 16 to 24 characters and documented generated credentials, private-network exposure, TLS, IP allowlists, and reverse-proxy rate limiting. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24f4426..529f128 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,6 +92,8 @@ must preserve these security properties: - only one authenticated operation can run, and a write attempt consumes the authorization before updating SQLite; - the persistent used state blocks new PHP workers and container restarts; +- state replacement is atomic and synchronized, while interrupted, empty, + missing, malformed, or symbolic-link state fails closed; - starting once with the enable value absent or not exactly `true` clears the used state without enabling the endpoint. diff --git a/docker-entrypoint-sqlite.sh b/docker-entrypoint-sqlite.sh index 550a6cd..928c6ec 100644 --- a/docker-entrypoint-sqlite.sh +++ b/docker-entrypoint-sqlite.sh @@ -131,13 +131,17 @@ if [ -d "$src_content" ] && [ -d "$DOCROOT" ]; then # 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" - if [ "${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-}" != 'true' ] \ - && { [ -e "$recovery_state_file" ] || [ -L "$recovery_state_file" ]; }; then - if [ -d "$recovery_state_file" ] && [ ! -L "$recovery_state_file" ]; then - echo >&2 "sqlite: recovery state path must not be a directory: $recovery_state_file" - exit 1 - fi - rm -f -- "$recovery_state_file" + recovery_lock_file="${recovery_state_file}.lock" + if [ "${WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED:-}" != 'true' ]; then + 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 + echo >&2 "sqlite: recovery state path must not be a directory: $recovery_path" + exit 1 + fi + rm -f -- "$recovery_path" + fi + done fi # Best-effort ownership/permissions so www-data can create/write the DB. diff --git a/tests/image-smoke-site-url.php b/tests/image-smoke-site-url.php index b400fab..21cef33 100644 --- a/tests/image-smoke-site-url.php +++ b/tests/image-smoke-site-url.php @@ -48,9 +48,12 @@ function sqlite_wordpress_image_smoke_request( $method, $credential = '', $siteu $credential = str_repeat( 'p', 24 ); $state_file = '/var/www/html/wp-content/database/.ht.site-url-update-tool-state'; -if ( is_link( $state_file ) || ( file_exists( $state_file ) && ! unlink( $state_file ) ) ) { - fwrite( STDERR, "FAIL: could not reset recovery state fixture\n" ); - exit( 1 ); +$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 recovery state fixture\n" ); + exit( 1 ); + } } putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED=true' ); @@ -70,6 +73,7 @@ function sqlite_wordpress_image_smoke_request( $method, $credential = '', $siteu } sqlite_wordpress_image_smoke_assert( unlink( $state_file ), 'test fixture can reset the lockout state' ); +sqlite_wordpress_image_smoke_assert( unlink( $lock_file ), 'test fixture can reset the lockout guard' ); $response = sqlite_wordpress_image_smoke_request( 'POST', $credential, diff --git a/tests/test-entrypoint-reconcile.sh b/tests/test-entrypoint-reconcile.sh index b9dc6a9..800775b 100755 --- a/tests/test-entrypoint-reconcile.sh +++ b/tests/test-entrypoint-reconcile.sh @@ -22,7 +22,9 @@ chmod +x "${stub_bin}/assert-site-url-token" printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' > "${fixture_root}/site-url-token" chmod 600 "${fixture_root}/site-url-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}" printf 'new drop-in\n' > "${src_content}/db.php" printf 'new recovery tool\n' > "${prepare_dir}/tool-update-site-url.php" @@ -79,12 +81,14 @@ 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 "${dst_content}/mu-plugins/.sqlite-database-integration.previous" test ! -e "${dst_content}/mu-plugins/.sqlite-database-integration.new.$$" # An exact enabled start preserves the one-shot latch, so restarting the same # 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}" PATH="${stub_bin}:${PATH}" \ WORDPRESS_PREPARE_DIR="${prepare_dir}" \ WORDPRESS_DOCROOT="${docroot}" \ @@ -94,3 +98,4 @@ PATH="${stub_bin}:${PATH}" \ APACHE_RUN_GROUP="$(id -g)" \ bash "${repo_root}/docker-entrypoint-sqlite.sh" assert-site-url-token grep -Fxq 'used recovery state' "${recovery_state_file}" +grep -Fxq 'recovery lock' "${recovery_lock_file}" diff --git a/tests/test-tool-update-site-url.php b/tests/test-tool-update-site-url.php index a2c4b2c..75fee94 100644 --- a/tests/test-tool-update-site-url.php +++ b/tests/test-tool-update-site-url.php @@ -8,6 +8,7 @@ fwrite( STDERR, "FAIL: could not create state fixture\n" ); exit( 1 ); } +unlink( $site_url_tool_state_file ); define( 'SQLITE_WORDPRESS_SITE_URL_TOOL_TESTING', true ); define( 'SQLITE_WORDPRESS_SITE_URL_TOOL_STATE_FILE', $site_url_tool_state_file ); @@ -136,7 +137,6 @@ function () { putenv( 'WORDPRESS_SITE_URL_UPDATE_TOOL_ENABLED' ); unlink( $token_file ); -file_put_contents( $site_url_tool_state_file, '' ); $state_time = 1000000; site_url_tool_assert_same( array( 'status' => 'ready', 'retry_after' => 0 ), @@ -203,7 +203,22 @@ function () { RuntimeException::class, 'malformed authorization state fails closed' ); +file_put_contents( $site_url_tool_state_file, '' ); +site_url_tool_assert_throws( + function () use ( $state_time ) { + sqlite_wordpress_site_url_tool_availability( $state_time + 911 ); + }, + RuntimeException::class, + 'unexpectedly empty authorization state fails closed' +); unlink( $site_url_tool_state_file ); +site_url_tool_assert_throws( + function () use ( $state_time ) { + sqlite_wordpress_site_url_tool_availability( $state_time + 912 ); + }, + RuntimeException::class, + 'missing authorization state with an existing lock fails closed' +); $state_symlink_target = tempnam( sys_get_temp_dir(), 'site-url-state-target-' ); if ( false === $state_symlink_target || ! symlink( $state_symlink_target, $site_url_tool_state_file ) ) { fwrite( STDERR, "FAIL: could not create state symlink fixture\n" ); @@ -211,14 +226,13 @@ function () { } site_url_tool_assert_throws( function () use ( $state_time ) { - sqlite_wordpress_site_url_tool_availability( $state_time + 911 ); + sqlite_wordpress_site_url_tool_availability( $state_time + 913 ); }, RuntimeException::class, 'symbolic-link authorization state fails closed' ); unlink( $site_url_tool_state_file ); unlink( $state_symlink_target ); -file_put_contents( $site_url_tool_state_file, '' ); $valid_urls = array( 'public URL' => array( 'https://example.com/', 'https://example.com' ), @@ -395,5 +409,11 @@ function () { 'option caches are cleared after rollback' ); -unlink( $site_url_tool_state_file ); +if ( file_exists( $site_url_tool_state_file ) || is_link( $site_url_tool_state_file ) ) { + unlink( $site_url_tool_state_file ); +} +$site_url_tool_lock_file = $site_url_tool_state_file . '.lock'; +if ( file_exists( $site_url_tool_lock_file ) || is_link( $site_url_tool_lock_file ) ) { + unlink( $site_url_tool_lock_file ); +} fwrite( STDOUT, "tool-update-site-url tests passed\n" ); diff --git a/tool-update-site-url.php b/tool-update-site-url.php index d7d5f6a..ec7c47c 100644 --- a/tool-update-site-url.php +++ b/tool-update-site-url.php @@ -96,7 +96,7 @@ function sqlite_wordpress_site_url_tool_default_state() { */ function sqlite_wordpress_site_url_tool_decode_state( $raw ) { if ( '' === $raw ) { - return sqlite_wordpress_site_url_tool_default_state(); + throw new RuntimeException( 'The site URL recovery state is unexpectedly empty.' ); } $state = json_decode( $raw, true ); @@ -158,6 +158,82 @@ function sqlite_wordpress_site_url_tool_normalize_state( $state, $now ) { return $state; } +/** + * Atomically persists state through a same-directory temporary file. + * + * The temporary file is synced before rename, then the containing directory is + * synced so a successful call survives process or host interruption. The old + * complete state remains visible until the atomic rename. + * + * @param string $state_file Destination state file. + * @param string $state_dir Resolved state directory. + * @param string $payload Encoded state. + * @return void + * @throws RuntimeException When durable persistence fails. + */ +function sqlite_wordpress_site_url_tool_write_state( $state_file, $state_dir, $payload ) { + $temp_file = tempnam( $state_dir, '.ht.site-url-state.tmp-' ); + if ( false === $temp_file ) { + throw new RuntimeException( 'A temporary site URL recovery state could not be created.' ); + } + + $temp_handle = null; + $renamed = false; + try { + $resolved_temp = realpath( $temp_file ); + if ( false === $resolved_temp || dirname( $resolved_temp ) !== $state_dir || is_link( $temp_file ) ) { + throw new RuntimeException( 'The temporary site URL recovery state path is unsafe.' ); + } + + $temp_handle = fopen( $temp_file, 'wb' ); + if ( false === $temp_handle || ! chmod( $temp_file, 0600 ) ) { + throw new RuntimeException( 'The temporary site URL recovery state could not be opened safely.' ); + } + + $offset = 0; + $length = strlen( $payload ); + while ( $offset < $length ) { + $written = fwrite( $temp_handle, substr( $payload, $offset ) ); + if ( false === $written || 0 === $written ) { + throw new RuntimeException( 'The site URL recovery state could not be saved.' ); + } + $offset += $written; + } + if ( ! fflush( $temp_handle ) || ! fsync( $temp_handle ) ) { + throw new RuntimeException( 'The site URL recovery state could not be synchronized.' ); + } + fclose( $temp_handle ); + $temp_handle = null; + + if ( ! rename( $temp_file, $state_file ) ) { + throw new RuntimeException( 'The site URL recovery state could not be replaced atomically.' ); + } + $renamed = true; + if ( ! chmod( $state_file, 0600 ) ) { + throw new RuntimeException( 'The site URL recovery state permissions could not be restricted.' ); + } + + $directory_handle = fopen( $state_dir, 'r' ); + if ( false === $directory_handle ) { + throw new RuntimeException( 'The site URL recovery state directory could not be synchronized.' ); + } + try { + if ( ! fsync( $directory_handle ) ) { + throw new RuntimeException( 'The site URL recovery state directory could not be synchronized.' ); + } + } finally { + fclose( $directory_handle ); + } + } finally { + if ( is_resource( $temp_handle ) ) { + fclose( $temp_handle ); + } + if ( ! $renamed && ( file_exists( $temp_file ) || is_link( $temp_file ) ) ) { + unlink( $temp_file ); + } + } +} + /** * Reads, exclusively locks, updates, and persists authorization state. * @@ -171,57 +247,65 @@ function sqlite_wordpress_site_url_tool_with_state( $callback ) { if ( ! is_dir( $state_dir ) || ! is_writable( $state_dir ) ) { throw new RuntimeException( 'The site URL recovery state directory is not writable.' ); } - if ( is_link( $state_file ) ) { - throw new RuntimeException( 'The site URL recovery state must not be a symbolic link.' ); + + $resolved_dir = realpath( $state_dir ); + if ( false === $resolved_dir ) { + throw new RuntimeException( 'The site URL recovery state directory is invalid.' ); + } + $lock_file = $state_file . '.lock'; + $lock_existed = file_exists( $lock_file ) || is_link( $lock_file ); + if ( is_link( $lock_file ) ) { + throw new RuntimeException( 'The site URL recovery lock must not be a symbolic link.' ); } - $handle = fopen( $state_file, 'c+b' ); + $handle = fopen( $lock_file, 'c+b' ); if ( false === $handle ) { - throw new RuntimeException( 'The site URL recovery state could not be opened.' ); + throw new RuntimeException( 'The site URL recovery lock could not be opened.' ); } try { - clearstatcache( true, $state_file ); - $resolved_dir = realpath( $state_dir ); - $resolved_file = realpath( $state_file ); - if ( is_link( $state_file ) || false === $resolved_dir || false === $resolved_file || dirname( $resolved_file ) !== $resolved_dir ) { - throw new RuntimeException( 'The site URL recovery state path is unsafe.' ); + clearstatcache( true, $lock_file ); + $resolved_lock = realpath( $lock_file ); + if ( is_link( $lock_file ) || false === $resolved_lock || dirname( $resolved_lock ) !== $resolved_dir ) { + throw new RuntimeException( 'The site URL recovery lock path is unsafe.' ); } - if ( ! chmod( $state_file, 0600 ) ) { - throw new RuntimeException( 'The site URL recovery state permissions could not be restricted.' ); + if ( ! chmod( $lock_file, 0600 ) ) { + throw new RuntimeException( 'The site URL recovery lock permissions could not be restricted.' ); } if ( ! flock( $handle, LOCK_EX ) ) { throw new RuntimeException( 'The site URL recovery state could not be locked.' ); } - rewind( $handle ); - $raw = stream_get_contents( $handle, 4097 ); - if ( false === $raw || strlen( $raw ) > 4096 ) { - throw new RuntimeException( 'The site URL recovery state is invalid.' ); + clearstatcache( true, $state_file ); + if ( is_link( $state_file ) ) { + throw new RuntimeException( 'The site URL recovery state must not be a symbolic link.' ); } - $state = sqlite_wordpress_site_url_tool_decode_state( $raw ); + if ( file_exists( $state_file ) ) { + $resolved_file = realpath( $state_file ); + if ( false === $resolved_file || dirname( $resolved_file ) !== $resolved_dir || ! is_file( $state_file ) || ! is_readable( $state_file ) ) { + throw new RuntimeException( 'The site URL recovery state path is unsafe.' ); + } + $raw = file_get_contents( $state_file, false, null, 0, 4097 ); + if ( false === $raw || strlen( $raw ) > 4096 ) { + throw new RuntimeException( 'The site URL recovery state is invalid.' ); + } + $state = sqlite_wordpress_site_url_tool_decode_state( $raw ); + } elseif ( $lock_existed ) { + throw new RuntimeException( 'The site URL recovery state is unexpectedly missing.' ); + } else { + $state = sqlite_wordpress_site_url_tool_default_state(); + } + $update = call_user_func( $callback, $state ); if ( ! is_array( $update ) || ! isset( $update['state'] ) || ! array_key_exists( 'result', $update ) ) { throw new RuntimeException( 'The site URL recovery state update is invalid.' ); } $payload = json_encode( $update['state'], JSON_UNESCAPED_SLASHES ); - if ( false === $payload || ! ftruncate( $handle, 0 ) ) { - throw new RuntimeException( 'The site URL recovery state could not be saved.' ); - } - rewind( $handle ); - $offset = 0; - $length = strlen( $payload ); - while ( $offset < $length ) { - $written = fwrite( $handle, substr( $payload, $offset ) ); - if ( false === $written || 0 === $written ) { - throw new RuntimeException( 'The site URL recovery state could not be saved.' ); - } - $offset += $written; - } - if ( ! fflush( $handle ) ) { + if ( false === $payload ) { throw new RuntimeException( 'The site URL recovery state could not be saved.' ); } + sqlite_wordpress_site_url_tool_write_state( $state_file, $resolved_dir, $payload ); return $update['result']; } finally {