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 94.6%
  • JavaScript 4.7%
  • CSS 0.7%
Find a file
Eric Schewe 675c2fddfe
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
chore: bump version to 1.0.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:33:52 -07:00
.forgejo/workflows ci: drop wp-env integration job (incompatible with DinD runner) 2026-06-25 22:34:56 -07:00
assets feat: initial plugin implementation per REWRITE_PLAN.md 2026-05-10 18:14:31 -07:00
languages chore: bump version to 1.0.2 2026-06-27 15:33:52 -07:00
src fix: rewrite URLs with explicit port numbers in post content 2026-06-27 15:18:33 -07:00
tests fix: rewrite URLs with explicit port numbers in post content 2026-06-27 15:18:33 -07:00
views docs: warn that configured hosts are added to the redirect allowlist 2026-06-27 15:29:14 -07:00
.distignore docs: reflect Forgejo migration; move CI to .forgejo/workflows 2026-06-25 21:25:27 -07:00
.gitignore feat: initial plugin implementation per REWRITE_PLAN.md 2026-05-10 18:14:31 -07:00
.wp-env.json chore: bump PHP floor to 8.2, test matrix 8.2-8.5 2026-06-25 22:08:15 -07:00
CHANGELOG.md chore: bump version to 1.0.2 2026-06-27 15:33:52 -07:00
CLAUDE.md ci: drop wp-env integration job (incompatible with DinD runner) 2026-06-25 22:34:56 -07:00
composer.json refactor: rename vendor namespace Fizica -> Pickysysadmin 2026-06-25 22:21:35 -07:00
LICENSE Initial commit 2026-05-10 17:57:54 -07:00
multi-domain-redux.php chore: bump version to 1.0.2 2026-06-27 15:33:52 -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 test(integration): cover the_content render + inline rewrite 2026-06-25 23:25:37 -07:00
phpcs.xml.dist chore: bump PHP floor to 8.2, test matrix 8.2-8.5 2026-06-25 22:08:15 -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 docs: warn that configured hosts are added to the redirect allowlist 2026-06-27 15:29:14 -07:00
readme.txt chore: bump version to 1.0.2 2026-06-27 15:33:52 -07:00
REWRITE_PLAN.md chore: bump PHP floor to 8.2, test matrix 8.2-8.5 2026-06-25 22:08:15 -07:00
SECURITY.md Initial commit 2026-05-10 17:57:54 -07:00
uninstall.php refactor: rename vendor namespace Fizica -> Pickysysadmin 2026-06-25 22:21:35 -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.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 / $_POST access without wp_unslash() + sanitization.
  • X-Forwarded-Host honored only when operator opts in via constant.

Report security issues privately. See SECURITY.md

License

GPL-3.0-or-later. See LICENSE.