• 1.0.3 fc5f0cd88b

    1.0.3
    All checks were successful
    CI / Lint + Static Analysis (push) Successful in 1m48s
    CI / Unit Tests (PHP 8.5) (push) Successful in 1m40s
    Release / Verify tag, version and changelog (push) Successful in 3s
    Release / Unit Tests (PHP 8.4) (push) Successful in 1m34s
    Release / Unit Tests (PHP 8.5) (push) Successful in 2m6s
    CI / Unit Tests (PHP 8.3) (push) Successful in 1m41s
    CI / Unit Tests (PHP 8.4) (push) Successful in 1m41s
    Release / Lint + Static Analysis (push) Successful in 1m51s
    Release / Unit Tests (PHP 8.3) (push) Successful in 1m39s
    Release / Build zip and publish release (push) Successful in 3s
    Stable

    eric released this 2026-10-16 16:13:41 -07:00 | 1 commits to main since this release

    Multisite upgrade notice: every host list saved by 1.0.2 or earlier is ignored after upgrading, on every site including the main site, until a network administrator reviews and saves it on that site's settings page. Until then that site does not rewrite URLs, so onion and other alias visitors are served the site's stored (clearnet) URLs. Network Admin lists the affected sites (#25). Single-site installs are unaffected. See the #21 entry under Security.

    Added

    • Opt-in update check (#19). A new Check for new versions of this plugin setting (off by default, option multi_domain_redux_update_check) answers core's update_plugins_git.pickysysadmin.ca filter, which the existing Update URI header makes core call. It reads the highest release tag (X.Y.Z / X.Y.Z.W) from the repo's tags.rss, and newer versions appear on Plugins and Dashboard → Updates with one-click update from the release zip. Results are cached for 12 hours (1 hour after a failed fetch). The request uses a Multi-Domain-Redux/<version> user agent so core's default (which includes the site URL) doesn't disclose the site address to the forge. On multisite the main site's setting applies; sub-site settings pages show it read-only and don't save it (#22). Uninstall removes the option and the cache. A cached version that isn't a release-style tag (written by something other than the feed check) is discarded and refetched rather than used in the download URL (#23). A tag appears in the feed a few minutes before its release zip is attached, and an update attempted in that window fails with a download error.

    Changed

    • Minimum PHP version raised from 8.2 to 8.3 (Requires PHP: 8.3). PHP 8.2 reaches end of life in December 2026. Sites still on PHP 8.2 should stay on 1.0.2 until they upgrade PHP.
    • CI and release testing dropped PHP 8.2; unit tests now run on PHP 8.3 / 8.4 / 8.5.
    • Removed the unused UrlRewriter::urlFor() and a duplicate get_avatar_url filter registration.

    Security

    • A Host header with a trailing root dot (<alias>., the FQDN form of the same host) failed validation and fell back to the primary host. Behind a front cache keyed on the dot-normalized host (e.g. nginx fastcgi_cache_key using $host), one unauthenticated request could poison an alias's (including the onion's) cache entry with primary-host URLs for every later visitor. HostHeader::normalize() now strips exactly one trailing dot before validation, so <alias>. resolves to the configured alias; example.com.. is still rejected. The same normalization applies when saving the host list in settings.
    • Multisite: a sub-site administrator could set a host list that rewrote network-scoped URLs (password-reset links, network admin, sign-up, other blogs' URLs) onto a host they control. The settings page and options.php saves now require manage_network_options on multisite (manage_options on single-site is unchanged). As defence in depth, on multisite network_home_url / network_site_url are no longer filtered, and URLs for another blog (via blog_id or while switch_to_blog() is active) are left untouched.
    • Multisite: host lists saved before 1.0.3 are no longer applied (#21). The fix above only governs new saves; a list a sub-site administrator had already saved kept rewriting that site's lost-password link (which core builds from network_site_url() and passes through lostpassword_url) onto a host they chose, for visitors on an unlisted host such as the site's own subdomain, and kept that host in allowed_redirect_hosts. Stored settings are now version 2, written only by the settings page (which requires manage_network_options on multisite). On multisite a list with an older version is ignored until a network administrator reviews and saves it; until then an admin notice tells them so, and the settings page still shows the stored list. Network Admin also shows a notice listing every site (with the plugin active) whose list awaits review, each linked to its settings page (#25); the scan is cached for an hour in the multi_domain_redux_review_sites site transient, cleared when any site's list or plugin activation changes, and removed on uninstall. Lists written with wp option update need "version": 2 to apply on multisite. Single-site is unaffected. As defence in depth, on a sub-site whose domain differs from the network's, URLs on the network domain are left unchanged whichever filter carries them.
    • WP-Cron starvation via the onion host. Core's spawn_cron() builds its server-to-self loopback from site_url(), which the plugin rewrote to the visitor's host. A request arriving on the onion host took the 60-second doing_cron lock and then posted to http://<onion>/wp-cron.php, which the PHP host normally cannot resolve, so due jobs never ran and no other request could spawn cron until the lock expired. One unauthenticated request per minute (or ordinary onion traffic) could suppress scheduled posts, update checks and other cron work indefinitely. A cron_request filter now pins the loopback to the stored siteurl (keeping the doing_wp_cron query); targets on a host outside the configured list are left alone. Sites whose server cannot reach even its own siteurl should use DISABLE_WP_CRON with a system cron.
    • Scheme downgrade of login, password-reset, admin and moderation URLs. The URL rewriter replaced the scheme of every rewritten URL with the current request's scheme, discarding an https scheme core had chosen via FORCE_SSL_ADMIN or an https home/siteurl. On plain-http requests and in WP-CLI or system-cron runs, password-reset emails, wp_login_url(), admin_url(), the wp_login_form() action and comment-moderation email links were emitted as http://, exposing reset keys or passwords to an on-path observer where HSTS is not in force. UrlRewriter and ContentRewriter now never downgrade: an https URL stays https on clearnet hosts whatever the request scheme; onion hosts are still always http. This also fixes a redirect loop on plain-http wp-login.php / wp-admin under FORCE_SSL_ADMIN: core's https-upgrade Location was rewritten back to http://.
    • Request host persisted into block navigation and post guids, then replayed on every host. URLs core builds during a request follow that request's host, and some of them are stored: block-theme navigation fallback (a classic menu converted to a wp_navigation post on the first front-end render, by any anonymous visitor) and a new post's guid (set from get_permalink(), e.g. by an author opening post-new.php on the onion). Block output and guids were not rewritten on output, so one request could put onion links in the clearnet header navigation, and clearnet RSS <guid> / REST guid.rendered could disclose the onion address. Block output (render_block, widget_block_content) is now rewritten to the visitor's host like post content, and inline content rewriting now also normalises the scheme of URLs already on the visitor's host (never downgrading https on clearnet; onion stays http). Guids on a non-primary configured host are mapped to the primary host on output (get_the_guid, which also repairs guids already stored) and pinned there when a post is inserted. Primary-host guids are emitted unchanged so feed readers don't see existing items as new.
    • Hardening: the redirect allowlist no longer relaxes core's SSRF guard. Core's http_request_host_is_external filter consults allowed_redirect_hosts (via wp_validate_redirect()), so adding every configured host there also let wp_safe_remote_*() (pingback source fetches, oEmbed discovery) reach any configured host that resolves to a private address, on ports 80/443/8080. While that filter runs, the plugin now neither adds its hosts nor rewrites home_url(), so outbound requests trust only the stored home host, as without the plugin. wp_safe_redirect() to configured hosts is unchanged.
    • Hardening: under MULTI_DOMAIN_REDUX_TRUST_PROXY, comma-separated X-Forwarded-Host and X-Forwarded-Proto now use the rightmost trimmed entry (the value the nearest proxy wrote) instead of the first. Behind a proxy that appends rather than overwrites (e.g. Apache mod_proxy), the first entry is client-supplied, so the client could pick which configured host a page was rendered for. X-Forwarded-Proto: http, https or a padded https was also treated as http. A list in Host (or in a host saved on the settings page) is now rejected instead of being truncated to its first entry. The documented setup (proxy strips client X-Forwarded-Host) was not affected.
    • Hardening: stored host settings are re-validated on load. The settings sanitizer only runs on saves through the settings page, so values written by wp option update, direct database edits or other plugins reached the rewriters unchecked: an empty host row made inline content rewriting match every bare // (JS comments, http://), an empty primary emitted http:///path redirects and added '' to allowed_redirect_hosts, and a non-string host fatally broke every request. Settings::fromArray() now normalizes each hostname as the sanitizer does, drops rows that are empty, invalid, non-scalar or duplicated, blanks an invalid base path or locale, and enforces a single primary. As defence in depth, the URL and content rewriters never emit or match an empty host.
    • Hardening: inline content rewriting no longer matches a configured host that isn't at the start of a link. The pattern had no left boundary, so a //<host> path segment (http://example.com//example.com/x, /foo//example.com) was rewritten into the host, producing a broken URL like https://www.example.comhttps://www.example.com/x, and ftp:// / wss:// links to a configured host were mangled the same way. A match must now not follow a word character, ., :, / or -. Links after quotes, =, (, whitespace or entities are rewritten as before.
    • Hardening: fewer core emitters disclose the primary host on alias (e.g. onion) pages. The REST index url field (the raw siteurl option) and get_header_image() / get_background_image() (absolute URLs stored as theme mods) are now rewritten to the visitor's host via rest_index, theme_mod_header_image and theme_mod_background_image. The hreflang x-default link is now emitted only when at least one host has a locale; previously a site with no localized hosts published the primary host in an x-default link on every page of every alias. When hosts do have locales, the hreflang tags (including x-default → primary) still name those hosts on every alias, by design.

    Fixed

    • Onion hosts: login and wp-admin were unreachable under FORCE_SSL_ADMIN. Core redirected plain-http wp-login.php / wp-admin to https, which the rewriter (onion is always http) sent back to http://<onion>, looping, and auth_redirect() only accepted the secure auth cookie a plain-http login never sets. On a request for an onion host the plugin now calls force_ssl_admin( false ) and filters secure_signon_cookie to false, so the site can be administered over Tor, which already encrypts the connection end to end. Clearnet hosts are unchanged.
    • Uninstall on multisite now removes the plugin's option from every site, not only the first 100 (get_sites() default limit).
    • Canonical redirects are judged against the effective primary host (Settings::primary()) rather than the is_primary flag, so a host list with no flagged primary no longer suppresses redirect_canonical everywhere.

    Documentation

    • Configured hosts are assumed to be on their scheme's default port. Hosts have no port setting, and rewritten URLs drop any explicit port and userinfo (https://[email protected]:8443/x → https://<current>/x). A host reachable only on a non-default port isn't supported; put it behind a reverse proxy on 443/80.
    Downloads