- PHP 97.6%
- JavaScript 2.1%
- CSS 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| assets | ||
| languages | ||
| src | ||
| tests | ||
| views | ||
| .distignore | ||
| .gitignore | ||
| .wp-env.json | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| composer.json | ||
| LICENSE | ||
| multi-domain-redux.php | ||
| package-lock.json | ||
| package.json | ||
| phpcs.xml.dist | ||
| phpstan.neon | ||
| phpunit.xml | ||
| README.md | ||
| readme.txt | ||
| REWRITE_PLAN.md | ||
| SECURITY.md | ||
| uninstall.php | ||
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
.oniontraffic; preserves the onion host through redirects so visitors aren't bounced off Tor. - Safe by default. Unknown / spoofed
Hostheaders fall back to the configured primary host. Trusted-proxy mode is opt-in via awp-config.phpconstant. - 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_optionson 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": 2to 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 fromsettings_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/$_POSTaccess withoutwp_unslash()+ sanitization. X-Forwarded-Hosthonored only when operator opts in via constant.- Onion hosts are always
http. On an onion requestFORCE_SSL_ADMINis lifted (force_ssl_admin( false )) so login and wp-admin work over Tor, which already encrypts the connection end to end. Clearnet hosts keepFORCE_SSL_ADMIN, and anhttpsURL is never downgraded there.
Report security issues privately. See SECURITY.md
License
GPL-3.0-or-later. See LICENSE.