Serve a single WordPress install on multiple hostnames - clearnet + Tor onion, www + apex, staging + production - and ensure every URL WordPress emits during a request matches the host the visitor came in on.
  • PHP 97.6%
  • JavaScript 2.1%
  • CSS 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Eric Schewe 1f1b520481
All checks were successful
CI / Lint + Static Analysis (push) Successful in 1m47s
CI / Unit Tests (PHP 8.4) (push) Successful in 1m45s
CI / Unit Tests (PHP 8.3) (push) Successful in 1m38s
CI / Unit Tests (PHP 8.5) (push) Successful in 1m49s
docs: note that Authorized Integrations can't restrict the Actions token
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:39:53 -07:00
.forgejo/workflows ci: revert action SHA pins and committed composer.lock 2026-10-16 15:22:22 -07:00
assets feat: initial plugin implementation per REWRITE_PLAN.md 2026-05-10 18:14:31 -07:00
languages chore: release 1.0.3 2026-10-16 15:59:24 -07:00
src feat: list sites awaiting network review in Network Admin 2026-10-16 15:56:18 -07:00
tests feat: list sites awaiting network review in Network Admin 2026-10-16 15:56:18 -07:00
views fix: show the update-check setting read-only on multisite sub-sites 2026-10-16 15:31:23 -07:00
.distignore chore: add release workflow and fix distignore 2026-06-28 10:08:40 -07:00
.gitignore ci: revert action SHA pins and committed composer.lock 2026-10-16 15:22:22 -07:00
.wp-env.json chore: raise minimum PHP to 8.3 2026-10-15 23:04:23 -07:00
CHANGELOG.md chore: release 1.0.3 2026-10-16 15:59:24 -07:00
CLAUDE.md docs: note that Authorized Integrations can't restrict the Actions token 2026-10-16 22:39:53 -07:00
composer.json ci: revert action SHA pins and committed composer.lock 2026-10-16 15:22:22 -07:00
LICENSE Initial commit 2026-05-10 17:57:54 -07:00
multi-domain-redux.php chore: release 1.0.3 2026-10-16 15:59:24 -07:00
package-lock.json test: green all quality gates and build wp-env integration harness 2026-06-25 20:39:37 -07:00
package.json fix: rewrite block output and pin guids to the primary host 2026-10-15 23:41:33 -07:00
phpcs.xml.dist chore: raise minimum PHP to 8.3 2026-10-15 23:04:23 -07:00
phpstan.neon test: green all quality gates and build wp-env integration harness 2026-06-25 20:39:37 -07:00
phpunit.xml test: green all quality gates and build wp-env integration harness 2026-06-25 20:39:37 -07:00
README.md feat: list sites awaiting network review in Network Admin 2026-10-16 15:56:18 -07:00
readme.txt chore: release 1.0.3 2026-10-16 15:59:24 -07:00
REWRITE_PLAN.md fix: stop unhooked emitters naming the primary host on aliases 2026-10-16 13:04:29 -07:00
SECURITY.md Initial commit 2026-05-10 17:57:54 -07:00
uninstall.php feat: list sites awaiting network review in Network Admin 2026-10-16 15:56:18 -07:00

Multi-Domain Redux

Serve a single WordPress install on multiple hostnames — clearnet + Tor onion, www + apex, staging + production — and ensure every URL WordPress emits during a request matches the host the visitor came in on.

  • No output buffering. All rewrites happen through WordPress filter hooks.
  • Tor onion friendly. Works behind a reverse proxy that forwards .onion traffic; preserves the onion host through redirects so visitors aren't bounced off Tor.
  • Safe by default. Unknown / spoofed Host headers fall back to the configured primary host. Trusted-proxy mode is opt-in via a wp-config.php constant.
  • PHP 8.3+, WordPress 6.4+.

AI Disclosure

Written by Claude Code (Opus 4.8)

Install

cd wp-content/plugins/
git clone https://git.pickysysadmin.ca/eric/multi-domain-redux.git multi-domain-redux
cd multi-domain-redux
composer install --no-dev

Or download the latest zip from releases and install it via Plugins → Add New → Upload.

Activate the plugin in Plugins → Installed Plugins, then go to Settings → Multi-Domain Redux and add your hostnames.

Configuration

Hosts

Each host row has:

Field Notes
Hostname Bare hostname, no scheme, no trailing slash. RFC 1123 or v3 onion ([a-z2-7]{56}.onion). Only add hostnames you own and control — every configured host is added to WordPress's allowed_redirect_hosts, permitting wp_safe_redirect() to redirect there.
Base path Optional path prefix (e.g. blog). Leave blank for site root.
Locale Optional. Drives the <link rel="alternate" hreflang="…"> tag for SEO. Hosts with no locale set are excluded from hreflang output entirely — use this to keep a host (e.g. a Tor onion mirror) out of the emitted tags. If any host has a locale, an x-default tag pointing at the primary host is added too; the tags are emitted on every host, so the localized hosts and the primary host are published on onion pages as well. With no locales set anywhere, no hreflang tags are emitted.
Primary Exactly one host is primary. Canonical redirects only fire on this host.

Every host is assumed to be served on its scheme's default port (443 for https, 80 for http). Hosts have no port setting. Rewritten URLs drop any explicit port and userinfo (user:pass@), so https://example.com:8443/x becomes https://<visitor's host>/x. A host that is only reachable on a non-default port isn't supported. Put a reverse proxy in front of it on 443/80.

Trusted-proxy mode

If WordPress is behind a reverse proxy (nginx terminating Tor, a CDN, etc.), the proxy sends the original host in X-Forwarded-Host. This plugin only honors that header when you explicitly opt in:

// wp-config.php
define( 'MULTI_DOMAIN_REDUX_TRUST_PROXY', true );

Without this constant, only HTTP_HOST is consulted. Never enable it unless your stack actually has a proxy in front of WordPress; otherwise an attacker can spoof the host via X-Forwarded-Host.

Proxy configuration requirement: your reverse proxy must strip X-Forwarded-Host from inbound client requests and inject it only server-side. If clients can reach WordPress with this header set directly, enabling the constant provides no protection against host spoofing.

nginx example:

# Strip any client-supplied value; inject the real vhost server-side.
proxy_set_header X-Forwarded-Host $host;

Headers honored under TRUST_PROXY: X-Forwarded-Host, X-Forwarded-Proto. No other non-standard headers are read.

Update checks

Off by default. Tick Check for new versions of this plugin on the settings page and WordPress will fetch the repo's tag feed whenever it checks for plugin updates (about twice a day; the result is cached for 12 hours, a failed fetch for 1 hour). A newer release shows up on Plugins and Dashboard → Updates like any other plugin update, and Update now installs the release zip from Forgejo.

The request sends a Multi-Domain-Redux/<version> user agent instead of core's default, which would include your site address (possibly an onion). Nothing else about the site is sent. On multisite, the main site's setting applies network-wide.

The feed lists a tag as soon as it is pushed, a few minutes before the release workflow attaches the zip. An update attempted in that window fails with a download error. Try again once the release is published.

Shortcode

[multi_domain]

Outputs the current resolved hostname, escaped.

Architecture

src/
├── Plugin.php                      # composition root
├── Config/{Host,Settings,Repository}
├── Request/{HostHeader,HostResolver}
├── Rewrite/{UrlRewriter,ContentRewriter,RedirectGuard,HookBindings}
├── Seo/HreflangEmitter
├── Shortcode/CurrentHostShortcode
├── Admin/{SettingsPage,SettingsRenderer,SettingsSanitizer,Assets}
└── Support/Locales

See REWRITE_PLAN.md for the full design document.

Caching plugins

The rewriter runs on filter hooks. Page caches that store rendered HTML keyed only by URL path (not host) will serve cross-host responses. Either:

  • Vary the cache key on Host (recommended), or
  • Disable full-page caching for non-primary hosts.

The plugin normalizes the Host it sees the way nginx's $host does: lowercased, port stripped, one trailing root dot removed (example.com. → example.com). Key your cache on that normalized form, or on the raw $http_host. Don't key on a host form the plugin would reject. If you do, a request with an unrecognized host renders the primary-host page, and that page gets cached under an alias's key.

Plugins that emit URLs in inline JavaScript

The rewriter binds to WordPress URL filters (script_loader_src, style_loader_src, etc.), so any enqueued asset is rewritten to the visitor's host. It cannot reach URLs baked into inline <script> blobs, because the design deliberately avoids output buffering.

Enlighter (EnlighterJS): with Dynamic resource loading enabled (its default), Enlighter does not enqueue enlighterjs.min.js/CSS — it prints an inline EnlighterJSINIT config whose resources array holds absolute clearnet URLs, then loads them from JavaScript at runtime. Those URLs slip past the rewriter, so Tor visitors fetch assets over the clearnet host.

Fix: in Enlighter settings, disable "Dynamic resource loading" and flush its cache. Enlighter then enqueues the assets as normal <script src>/<link href> tags, which the rewriter handles.

The same applies to any plugin that emits asset URLs inside inline JavaScript rather than via the enqueue pipeline — prefer the plugin's "enqueue normally" / "no dynamic loading" option.

Development

composer install
composer lint              # phpcs
composer stan              # phpstan
composer test              # phpunit (unit)

Integration tests

The integration suite drives a live WordPress booted by wp-env (Docker required). It forges the HTTP Host header against the loopback and asserts every emitted URL — and wp_redirect() Location headers — uses the request host.

npm install                # one-time: fetch @wordpress/env
npm run env:start          # boot WordPress (first run pulls Docker images)
npm run env:seed           # load tests/fixtures/hosts.json into the plugin option
composer test:integration  # run the suite against http://localhost:8889
npm run env:stop           # tear down when done

The suite skips itself automatically if wp-env isn't reachable.

Security

  • Every admin handler checks current_user_can('manage_options') — manage_network_options on multisite, since the host list affects network-wide URLs.
  • On multisite, a host list saved before 1.0.3 (when a sub-site admin could save it) is not applied until a network admin re-saves it; a notice on that site's admin says so, and Network Admin lists every affected site with a link to its settings page. After upgrading a network from 1.0.2, that is every site with a list, so rewriting is off network-wide until each is re-saved. Lists written outside the settings page (e.g. wp option update) must carry "version": 2 to apply on multisite. URLs on the network's domain are never rewritten on a sub-site whose own domain differs.
  • Settings form uses register_setting() with a sanitize callback; the nonce comes from settings_fields().
  • Hostnames validated against RFC 1123 + v3 onion regex; duplicates and garbage rejected.
  • Locales restricted to a static whitelist.
  • All view output escaped at the boundary (esc_attr, esc_html, esc_url).
  • No $_GET / $_POST access without wp_unslash() + sanitization.
  • X-Forwarded-Host honored only when operator opts in via constant.
  • Onion hosts are always http. On an onion request FORCE_SSL_ADMIN is lifted (force_ssl_admin( false )) so login and wp-admin work over Tor, which already encrypts the connection end to end. Clearnet hosts keep FORCE_SSL_ADMIN, and an https URL is never downgraded there.

Report security issues privately. See SECURITY.md

License

GPL-3.0-or-later. See LICENSE.