- PHP 94.6%
- JavaScript 4.7%
- CSS 0.7%
|
All checks were successful
CI / Unit Tests (PHP 8.4) (push) Successful in 1m13s
CI / Unit Tests (PHP 8.5) (push) Successful in 1m12s
CI / Lint + Static Analysis (push) Successful in 48s
CI / Unit Tests (PHP 8.2) (push) Successful in 45s
CI / Unit Tests (PHP 8.3) (push) Successful in 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| .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.2+, 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. |
| Primary | Exactly one host is primary. Canonical redirects only fire on this host. |
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.
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.
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'). - Settings form uses
wp_nonce_field()+register_setting()with a sanitize callback. - 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.
Report security issues privately. See SECURITY.md
License
GPL-3.0-or-later. See LICENSE.