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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
wordpress
/.env
/secrets/
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@
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.
- 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.
- Ignored the documented local `.env` file and `secrets/` directory so recovery
credentials are not accidentally committed.

## [7.1.0] - 2026-08-29

### Changed
Expand Down
44 changes: 43 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -68,9 +69,47 @@ 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;
- 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.

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):

Expand All @@ -85,6 +124,9 @@ docker exec -it <container> 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

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 124 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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:

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
12 changes: 11 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down Expand Up @@ -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.
Loading