Security (low): multisite host lists saved by sub-site admins before 1.0.3 still rewrite the network lost-password URL #21

Closed
opened 2026-10-16 14:35:37 -07:00 by eric · 0 comments
Owner

Severity: low (likelihood low, impact low)
Found by: security audit run 4 (commit 77fcd83, also applies to 341c63e). Reproduced in a sandboxed harness against the real plugin classes and checked against WordPress 7.1.3 core source. Independently verified twice.
Fingerprint: multi-domain-redux:Repository.load:multisite-stale-subsite-host-list-rewrites-network-lostpassword-url
Related: #6. This is a residual of that fix.

Summary

Up to 1.0.2, a multisite sub-site admin (manage_options) could save multi_domain_redux_settings for their own blog. The fix for #6 (unreleased 1.0.3) changes what happens to new saves: they now require manage_network_options, and network_home_url / network_site_url are no longer filtered on multisite.

The fix does not touch host lists that were already saved under the old rule. Repository::load still applies them on every request. Two things follow.

  • Lost-password link. On multisite, core's wp_lostpassword_url() builds its URL with network_site_url() and then passes it through the lostpassword_url filter, which the plugin still rewrites. Take a stale list that contains the network domain plus a host the old admin chose, marked primary. A visitor arriving on a Host that is not in the list (for example the blog's own subdomain or mapped domain) sees the "Lost your password?" link on that blog's wp-login.php pointing at the chosen host.
  • Redirect allowlist. The chosen host stays in allowed_redirect_hosts for that blog, so wp_safe_redirect() of a redirect_to parameter can send users there.

Reset-key emails are not affected. They are built from network_site_url(), which is no longer filtered, so reset keys are not exposed. On subdirectory networks, visitors arrive on the listed network domain, so the lost-password rewrite does not trigger. The allowlist entry still applies there.

Root cause

The fix tightened write authority without a lifecycle step. Existing per-blog options get no migration, no re-save requirement and no saved-by-super-admin marker (src/Config/Repository.php:18-24, src/Plugin.php:36).

The defence-in-depth network skip is keyed on filter names, not on the URL's host (src/Rewrite/HookBindings.php:67-70, :86). lostpassword_url (HookBindings.php:27) carries a network_site_url() value on multisite, and it is still rewritten. An unlisted Host falls back to the stale primary (src/Request/HostResolver.php:63).

Expected behaviour

On multisite, URL rewriting and the redirect allowlist should be driven only by a host list a network-trusted principal set. No per-blog list should rewrite a network-domain URL, whichever filter carries it. The 1.0.3 changelog states this guarantee for network-scoped URLs.

Source trace

  1. src/Config/Repository.php:19 (Repository::load): loads the blog option with no provenance check. A sub-site admin may have written it under 1.0.2.
  2. src/Plugin.php:36 (Plugin::boot): the stale settings feed HostResolver, UrlRewriter, RedirectGuard and HookBindings.
  3. src/Rewrite/HookBindings.php:86 (HookBindings::register): on multisite, only the network_home_url / network_site_url filter names are skipped. lostpassword_url (line 27) is still hooked.
  4. src/Request/HostResolver.php:63 (HostResolver::resolve): an unlisted Host resolves to the stale primary.
  5. src/Rewrite/UrlRewriter.php:64 (UrlRewriter::rewrite): the network-domain URL is re-emitted on that host.
  6. src/Rewrite/RedirectGuard.php:74: stale hosts are merged into allowed_redirect_hosts.

Core reference (WP 7.1.3): wp-includes/general-template.php:901-918 (wp_lostpassword_url()), called from wp-login.php:1195 / :1608.

Conditions

  • Multisite network with the plugin active on a subdomain or mapped-domain sub-site.
  • The sub-site's non-super admin saved a host list while 1.0.2 or earlier allowed it, and the list survived the upgrade. The list contains the network domain plus a host they chose, marked primary.
  • A user follows that blog's "Lost your password?" link, or a crafted redirect_to link.

Local reproduction (sandboxed harness, dummy data)

  • Blog option: {hosts:[{host:example.com},{host:attacker.test,is_primary:true}]}
  • HTTP_HOST=sub.example.com
  • is_multisite() true, not switched
  • The harness ran the real Repository::load(), HookBindings::register(), UrlRewriter and RedirectGuard. It applied lostpassword_url to the URL that core produces on multisite.

Observed:

network_site_url hooked: no
lostpassword_url: https://example.com/wp-login.php?action=lostpassword
              -> https://attacker.test/wp-login.php?action=lostpassword
allowed_redirect_hosts: sub.example.com, example.com, attacker.test

Suggested fix

  1. Host-based network guard. On multisite, rewriteUrlFilter() should leave any URL unchanged if its host is the current network's domain and not the current blog's domain, whichever filter carries it:

    if ( is_multisite() ) {
    	$h   = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
    	$net = get_network();
    	if ( $net && $h === strtolower( (string) $net->domain ) && $h !== strtolower( (string) get_site()->domain ) ) {
    		return $url;
    	}
    }
    
  2. Existing data. Add an upgrade step, or have the sanitizer write a saved_by_network marker, so that on multisite any per-blog list no super admin has re-saved is ignored, with an admin notice to super admins.

  3. Regression test. On multisite, a blog list holding the network domain plus another primary must not change lostpassword_url output for a network-domain input.

1.0.3 has not been released yet, so this can land before any site upgrades.

**Severity:** low (likelihood low, impact low) **Found by:** security audit run 4 (commit `77fcd83`, also applies to `341c63e`). Reproduced in a sandboxed harness against the real plugin classes and checked against WordPress 7.1.3 core source. Independently verified twice. **Fingerprint:** `multi-domain-redux:Repository.load:multisite-stale-subsite-host-list-rewrites-network-lostpassword-url` **Related:** #6. This is a residual of that fix. ## Summary Up to 1.0.2, a multisite sub-site admin (`manage_options`) could save `multi_domain_redux_settings` for their own blog. The fix for #6 (unreleased 1.0.3) changes what happens to new saves: they now require `manage_network_options`, and `network_home_url` / `network_site_url` are no longer filtered on multisite. The fix does not touch host lists that were already saved under the old rule. `Repository::load` still applies them on every request. Two things follow. - **Lost-password link.** On multisite, core's `wp_lostpassword_url()` builds its URL with `network_site_url()` and then passes it through the `lostpassword_url` filter, which the plugin still rewrites. Take a stale list that contains the network domain plus a host the old admin chose, marked primary. A visitor arriving on a Host that is not in the list (for example the blog's own subdomain or mapped domain) sees the "Lost your password?" link on that blog's `wp-login.php` pointing at the chosen host. - **Redirect allowlist.** The chosen host stays in `allowed_redirect_hosts` for that blog, so `wp_safe_redirect()` of a `redirect_to` parameter can send users there. Reset-key emails are not affected. They are built from `network_site_url()`, which is no longer filtered, so reset keys are not exposed. On subdirectory networks, visitors arrive on the listed network domain, so the lost-password rewrite does not trigger. The allowlist entry still applies there. ## Root cause The fix tightened write authority without a lifecycle step. Existing per-blog options get no migration, no re-save requirement and no saved-by-super-admin marker (`src/Config/Repository.php:18-24`, `src/Plugin.php:36`). The defence-in-depth network skip is keyed on filter names, not on the URL's host (`src/Rewrite/HookBindings.php:67-70`, `:86`). `lostpassword_url` (`HookBindings.php:27`) carries a `network_site_url()` value on multisite, and it is still rewritten. An unlisted Host falls back to the stale primary (`src/Request/HostResolver.php:63`). ## Expected behaviour On multisite, URL rewriting and the redirect allowlist should be driven only by a host list a network-trusted principal set. No per-blog list should rewrite a network-domain URL, whichever filter carries it. The 1.0.3 changelog states this guarantee for network-scoped URLs. ## Source trace 1. `src/Config/Repository.php:19` (`Repository::load`): loads the blog option with no provenance check. A sub-site admin may have written it under 1.0.2. 2. `src/Plugin.php:36` (`Plugin::boot`): the stale settings feed HostResolver, UrlRewriter, RedirectGuard and HookBindings. 3. `src/Rewrite/HookBindings.php:86` (`HookBindings::register`): on multisite, only the `network_home_url` / `network_site_url` filter names are skipped. `lostpassword_url` (line 27) is still hooked. 4. `src/Request/HostResolver.php:63` (`HostResolver::resolve`): an unlisted Host resolves to the stale primary. 5. `src/Rewrite/UrlRewriter.php:64` (`UrlRewriter::rewrite`): the network-domain URL is re-emitted on that host. 6. `src/Rewrite/RedirectGuard.php:74`: stale hosts are merged into `allowed_redirect_hosts`. Core reference (WP 7.1.3): `wp-includes/general-template.php:901-918` (`wp_lostpassword_url()`), called from `wp-login.php:1195` / `:1608`. ## Conditions - Multisite network with the plugin active on a subdomain or mapped-domain sub-site. - The sub-site's non-super admin saved a host list while 1.0.2 or earlier allowed it, and the list survived the upgrade. The list contains the network domain plus a host they chose, marked primary. - A user follows that blog's "Lost your password?" link, or a crafted `redirect_to` link. ## Local reproduction (sandboxed harness, dummy data) - Blog option: `{hosts:[{host:example.com},{host:attacker.test,is_primary:true}]}` - `HTTP_HOST=sub.example.com` - `is_multisite()` true, not switched - The harness ran the real `Repository::load()`, `HookBindings::register()`, `UrlRewriter` and `RedirectGuard`. It applied `lostpassword_url` to the URL that core produces on multisite. Observed: ``` network_site_url hooked: no lostpassword_url: https://example.com/wp-login.php?action=lostpassword -> https://attacker.test/wp-login.php?action=lostpassword allowed_redirect_hosts: sub.example.com, example.com, attacker.test ``` ## Suggested fix 1. **Host-based network guard.** On multisite, `rewriteUrlFilter()` should leave any URL unchanged if its host is the current network's domain and not the current blog's domain, whichever filter carries it: ```php if ( is_multisite() ) { $h = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); $net = get_network(); if ( $net && $h === strtolower( (string) $net->domain ) && $h !== strtolower( (string) get_site()->domain ) ) { return $url; } } ``` 2. **Existing data.** Add an upgrade step, or have the sanitizer write a `saved_by_network` marker, so that on multisite any per-blog list no super admin has re-saved is ignored, with an admin notice to super admins. 3. **Regression test.** On multisite, a blog list holding the network domain plus another primary must not change `lostpassword_url` output for a network-domain input. 1.0.3 has not been released yet, so this can land before any site upgrades.
eric closed this issue 2026-10-16 15:28:55 -07:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eric/multi-domain-redux#21
No description provided.