Security (low): request scheme downgrades https login, reset, admin and moderation URLs to http #9

Closed
opened 2026-10-15 22:36:02 -07:00 by eric · 0 comments
Owner

Severity: low (likelihood low, impact medium)
Found by: security audit run 3 (commit 01195c8), independently verified in a disposable local WordPress 7.1.3 lab
Fingerprint: multi-domain-redux:UrlRewriter.rewrite:scheme-replaced-with-request-scheme

Summary

UrlRewriter::rewrite is hooked at priority 99 on 21 URL filters (including network_site_url, site_url, home_url, login_url, admin_url). For every URL on a configured host it rebuilds the URL with the current request's scheme (HostResolver::scheme()) and discards the https scheme core chose, whether from FORCE_SSL_ADMIN for 'login'/'admin' schemes or from https home/siteurl stored in the database on a non-SSL request. With the plugin active, on any plain-http request and in CLI or system-cron runs (no HTTPS server variable), these URLs are emitted as http://. That includes the emailed password-reset link (core retrieve_password builds it with network_site_url(..., 'login')), wp_login_url, admin_url and the wp_login_form action. Reproduced in WordPress 7.1.3 with FORCE_SSL_ADMIN: with the plugin skipped core emailed https://network.test/wp-login.php?login=dummyv1&key=...&action=rp; with the plugin active it emailed http://network.test/wp-login.php?... and the front-end login form action changed from https to http. If the victim follows that link or submits that form before HTTPS is enforced (for example, no HSTS), the reset key or password crosses the network in cleartext; core's own http-to-https redirect on wp-login.php fires only after the request has been sent. The same downgrade reaches comment-moderation email: an unauthenticated visitor who submits a comment over plain http to wp-comments-post.php (core does not redirect that endpoint to https, unlike wp-login.php) makes core's moderator notification carry http:// Approve/Trash/Spam admin_url links, an http:// moderation-panel link and an http:// post permalink, and the post-submit 302 Location is http://; with the plugin inactive core emits all of them as https. In the lab this was a real web request on a site whose stored siteurl/home were https, which makes core define FORCE_SSL_ADMIN automatically.

Root cause

src/Rewrite/UrlRewriter.php:61 always uses $this->resolver->scheme() as the output scheme and never consults the input URL's own scheme ($parts['scheme']). That request scheme comes from HostHeader::detectScheme (src/Request/HostHeader.php:94-109), which returns 'http' whenever is_ssl() is false, X-Forwarded-Proto is untrusted or absent, and HTTPS is unset. The plugin thereby overrides the https decision of core's set_url_scheme/get_home_url, including FORCE_SSL_ADMIN. Host::scheme() (src/Config/Host.php:27), which returns https for clearnet hosts, is never used.

Expected behaviour

Rewriting should swap only the host to the visitor's host and never lower the scheme core chose. An https URL from core (FORCE_SSL_ADMIN or stored https URLs) should stay https on clearnet hosts whatever the request scheme; only onion hosts, which are http by design, should be emitted as http.

Source trace

  1. src/Rewrite/HookBindings.php:57 (HookBindings::register): rewriteUrlFilter is registered at priority 99 on every URL_FILTERS entry (network_site_url, site_url, home_url, login_url, admin_url, lostpassword_url, ...), after core's set_url_scheme has chosen https. The input arrives from the request context (a plain-http request, or CLI/cron with no HTTPS variable) while core builds a page or reset-email URL.
  2. src/Request/HostResolver.php:59 (HostResolver::resolve): The output scheme is taken from HostHeader::detectScheme for the current request (memoized per request), independent of the URL being rewritten or of FORCE_SSL_ADMIN.
  3. src/Request/HostHeader.php:108 (HostHeader::detectScheme): Returns 'http' whenever is_ssl() is false, X-Forwarded-Proto is untrusted or absent, and HTTPS is unset: plain-http requests, CLI and system cron.
  4. src/Rewrite/UrlRewriter.php:61 (UrlRewriter::rewrite): Returns scheme() . '://' . current host . tail, turning an https input URL (password-reset link, login form action) into http.

Conditions

  • system configuration: Plugin active with at least one clearnet host configured, and core would otherwise have chosen https: FORCE_SSL_ADMIN set, or home/siteurl stored as https while the request or process is not SSL. Without one of these, core already emits http and nothing is downgraded.
  • environmental dependency: The URL is generated without SSL detection: a reset or notification email sent from WP-CLI or system cron (php wp-cron.php); a plain-http front-end page containing a login form or login/admin links; or an http password-reset request on a site with stored https URLs that does not redirect wp-login.php. Under FORCE_SSL_ADMIN the web reset form on wp-login.php is redirected to https first, so its own email keeps https. A comment submitted over plain http to wp-comments-post.php is not redirected by core, so the moderator/post-author notification email built in that request is affected; anyone who can comment can trigger it.
  • network routing: To capture the key or password the attacker needs an on-path network position between the victim and the site, and neither the site nor the victim's browser enforces HSTS or otherwise upgrades that host to https before the request is sent.
  • user interaction: The victim clicks the http reset link or submits the http-targeted login form.

Lab reproduction (local, dummy data only)

Principal: A passive on-path network observer (no account) watching the victim's traffic to a clearnet host of a site that uses FORCE_SSL_ADMIN or stored https URLs. The plugin turned the password-reset email or login form target into http, exposing the reset key or password to that observer.

  1. In the lab, activate the plugin on blog 1 (network.test), set multi_domain_redux_settings hosts to [network.test (primary), www.network.test] and create dummy subscriber dummyv1.
  2. Emulate FORCE_SSL_ADMIN with a --require file and have pre_wp_mail print the reset-link line of the outgoing message instead of sending it.
  3. Run core retrieve_password('dummyv1') through WP-CLI with the plugin active and again with --skip-plugins=multi-domain-redux, and compare the scheme of the emailed action=rp link.
  4. Remove WP-CLI's own priority-0 site_url/admin_url hooks (a CLI-only override that masks FORCE_SSL_ADMIN), then compare wp_login_url(), admin_url() and the wp_login_form() action with and without the plugin.
  5. Control: set $_SERVER['HTTPS']='on' before WordPress loads and confirm the plugin keeps https.
  6. Comment-notification variant: set the site's home/siteurl to https (core then defines FORCE_SSL_ADMIN), configure hosts [network.test (primary)], POST an unauthenticated comment over plain http to /wp-comments-post.php, read the captured moderator email and the 302 Location; repeat with the plugin deactivated; delete the test comments and restore home/siteurl.
  7. Clean up: delete the option, deactivate the plugin on blog 1 and delete dummyv1.

Observed: With FORCE_SSL_ADMIN emulated and the plugin skipped, core emailed https://network.test/wp-login.php?login=dummyv1&key=<redacted>&action=rp&wp_lang=en_US; with the plugin active the same flow emailed http://network.test/wp-login.php?login=dummyv1&key=<redacted>&action=rp&wp_lang=en_US. wp_login_url, admin_url and the wp_login_form action went from https://network.test/... (plugin skipped) to http://network.test/... (plugin active). With stored https home/siteurl on a non-SSL run, home_url and site_url went from https to http. With HTTPS=on before load the plugin kept https. Network sniffing of a key was not attempted (the lab has no TLS). Comment variant (verifier v10, multisite lab on net2.test, real plain-http POST, stored https siteurl/home so core set FORCE_SSL_ADMIN, is_ssl false): with the plugin active the moderator email contained http://net2.test/wp-admin/comment.php?action=approve|trash|spam&c=7, http://net2.test/wp-admin/edit-comments.php?comment_status=moderated and http://net2.test/2026/10/16/hello-world/, and the 302 Location was http://; with the plugin deactivated every link and the Location were https://. Trackback and pingback paths were not exercised.

Fix

Never lower the scheme: for clearnet hosts keep https when either the input URL or the request is https, while onion hosts keep their fixed http. Apply the same rule in UrlRewriter::urlFor and ContentRewriter, and add a unit regression test that rewriting 'https://<configured>/wp-login.php?...' during an http request returns https.

Suggested change in src/Rewrite/UrlRewriter.php:

			$host = strtolower( (string) $parts['host'] );
			if ( null === $this->settings->findByHost( $host ) ) {
				return $url;
			}
			$scheme = $this->resolver->scheme();
			if ( ! $current->isOnion() && 'https' === strtolower( (string) ( $parts['scheme'] ?? '' ) ) ) {
				$scheme = 'https'; // Never downgrade a scheme core chose (FORCE_SSL_ADMIN, stored https URLs).
			}
			return $scheme . '://' . $current->host . $this->buildTail( $parts );

Priority rationale

  • Likelihood: Requires FORCE_SSL_ADMIN or stored https URLs, the reset link or login form generated without SSL detection (CLI/system-cron emails, plain-http front-end pages, or http-reachable wp-login.php), no HSTS, an on-path attacker, and a victim who clicks or submits. Under FORCE_SSL_ADMIN the ordinary web reset request is redirected to https first and is unaffected.
  • Impact: When the conditions hold, the plugin fully defeats the operator's explicit https choice (FORCE_SSL_ADMIN or https site URLs) for password-reset links and login form targets, and a sniffed reset key or password gives takeover of that one account. Exposure is limited to users who act on a downgraded link while the attacker has network position.
**Severity:** low (likelihood low, impact medium) **Found by:** security audit run 3 (commit `01195c8`), independently verified in a disposable local WordPress 7.1.3 lab **Fingerprint:** `multi-domain-redux:UrlRewriter.rewrite:scheme-replaced-with-request-scheme` ## Summary UrlRewriter::rewrite is hooked at priority 99 on 21 URL filters (including network_site_url, site_url, home_url, login_url, admin_url). For every URL on a configured host it rebuilds the URL with the current request's scheme (HostResolver::scheme()) and discards the https scheme core chose, whether from FORCE_SSL_ADMIN for 'login'/'admin' schemes or from https home/siteurl stored in the database on a non-SSL request. With the plugin active, on any plain-http request and in CLI or system-cron runs (no HTTPS server variable), these URLs are emitted as http://. That includes the emailed password-reset link (core retrieve_password builds it with network_site_url(..., 'login')), wp_login_url, admin_url and the wp_login_form action. Reproduced in WordPress 7.1.3 with FORCE_SSL_ADMIN: with the plugin skipped core emailed https://network.test/wp-login.php?login=dummyv1&key=...&action=rp; with the plugin active it emailed http://network.test/wp-login.php?... and the front-end login form action changed from https to http. If the victim follows that link or submits that form before HTTPS is enforced (for example, no HSTS), the reset key or password crosses the network in cleartext; core's own http-to-https redirect on wp-login.php fires only after the request has been sent. The same downgrade reaches comment-moderation email: an unauthenticated visitor who submits a comment over plain http to wp-comments-post.php (core does not redirect that endpoint to https, unlike wp-login.php) makes core's moderator notification carry http:// Approve/Trash/Spam admin_url links, an http:// moderation-panel link and an http:// post permalink, and the post-submit 302 Location is http://; with the plugin inactive core emits all of them as https. In the lab this was a real web request on a site whose stored siteurl/home were https, which makes core define FORCE_SSL_ADMIN automatically. ## Root cause src/Rewrite/UrlRewriter.php:61 always uses $this-&gt;resolver-&gt;scheme() as the output scheme and never consults the input URL's own scheme ($parts['scheme']). That request scheme comes from HostHeader::detectScheme (src/Request/HostHeader.php:94-109), which returns 'http' whenever is_ssl() is false, X-Forwarded-Proto is untrusted or absent, and HTTPS is unset. The plugin thereby overrides the https decision of core's set_url_scheme/get_home_url, including FORCE_SSL_ADMIN. Host::scheme() (src/Config/Host.php:27), which returns https for clearnet hosts, is never used. ## Expected behaviour Rewriting should swap only the host to the visitor's host and never lower the scheme core chose. An https URL from core (FORCE_SSL_ADMIN or stored https URLs) should stay https on clearnet hosts whatever the request scheme; only onion hosts, which are http by design, should be emitted as http. ## Source trace 1. `src/Rewrite/HookBindings.php:57` (HookBindings::register): rewriteUrlFilter is registered at priority 99 on every URL_FILTERS entry (network_site_url, site_url, home_url, login_url, admin_url, lostpassword_url, ...), after core's set_url_scheme has chosen https. The input arrives from the request context (a plain-http request, or CLI/cron with no HTTPS variable) while core builds a page or reset-email URL. 2. `src/Request/HostResolver.php:59` (HostResolver::resolve): The output scheme is taken from HostHeader::detectScheme for the current request (memoized per request), independent of the URL being rewritten or of FORCE_SSL_ADMIN. 3. `src/Request/HostHeader.php:108` (HostHeader::detectScheme): Returns 'http' whenever is_ssl() is false, X-Forwarded-Proto is untrusted or absent, and HTTPS is unset: plain-http requests, CLI and system cron. 4. `src/Rewrite/UrlRewriter.php:61` (UrlRewriter::rewrite): Returns scheme() . '://' . current host . tail, turning an https input URL (password-reset link, login form action) into http. ## Conditions - **system configuration**: Plugin active with at least one clearnet host configured, and core would otherwise have chosen https: FORCE_SSL_ADMIN set, or home/siteurl stored as https while the request or process is not SSL. Without one of these, core already emits http and nothing is downgraded. - **environmental dependency**: The URL is generated without SSL detection: a reset or notification email sent from WP-CLI or system cron (php wp-cron.php); a plain-http front-end page containing a login form or login/admin links; or an http password-reset request on a site with stored https URLs that does not redirect wp-login.php. Under FORCE_SSL_ADMIN the web reset form on wp-login.php is redirected to https first, so its own email keeps https. A comment submitted over plain http to wp-comments-post.php is not redirected by core, so the moderator/post-author notification email built in that request is affected; anyone who can comment can trigger it. - **network routing**: To capture the key or password the attacker needs an on-path network position between the victim and the site, and neither the site nor the victim's browser enforces HSTS or otherwise upgrades that host to https before the request is sent. - **user interaction**: The victim clicks the http reset link or submits the http-targeted login form. ## Lab reproduction (local, dummy data only) **Principal:** A passive on-path network observer (no account) watching the victim's traffic to a clearnet host of a site that uses FORCE_SSL_ADMIN or stored https URLs. The plugin turned the password-reset email or login form target into http, exposing the reset key or password to that observer. 1. In the lab, activate the plugin on blog 1 (network.test), set multi_domain_redux_settings hosts to [network.test (primary), www.network.test] and create dummy subscriber dummyv1. 2. Emulate FORCE_SSL_ADMIN with a --require file and have pre_wp_mail print the reset-link line of the outgoing message instead of sending it. 3. Run core retrieve_password('dummyv1') through WP-CLI with the plugin active and again with --skip-plugins=multi-domain-redux, and compare the scheme of the emailed action=rp link. 4. Remove WP-CLI's own priority-0 site_url/admin_url hooks (a CLI-only override that masks FORCE_SSL_ADMIN), then compare wp_login_url(), admin_url() and the wp_login_form() action with and without the plugin. 5. Control: set $_SERVER['HTTPS']='on' before WordPress loads and confirm the plugin keeps https. 6. Comment-notification variant: set the site's home/siteurl to https (core then defines FORCE_SSL_ADMIN), configure hosts [network.test (primary)], POST an unauthenticated comment over plain http to /wp-comments-post.php, read the captured moderator email and the 302 Location; repeat with the plugin deactivated; delete the test comments and restore home/siteurl. 7. Clean up: delete the option, deactivate the plugin on blog 1 and delete dummyv1. **Observed:** With FORCE_SSL_ADMIN emulated and the plugin skipped, core emailed https://network.test/wp-login.php?login=dummyv1&key=&lt;redacted&gt;&action=rp&wp_lang=en_US; with the plugin active the same flow emailed http://network.test/wp-login.php?login=dummyv1&key=&lt;redacted&gt;&action=rp&wp_lang=en_US. wp_login_url, admin_url and the wp_login_form action went from https://network.test/... (plugin skipped) to http://network.test/... (plugin active). With stored https home/siteurl on a non-SSL run, home_url and site_url went from https to http. With HTTPS=on before load the plugin kept https. Network sniffing of a key was not attempted (the lab has no TLS). Comment variant (verifier v10, multisite lab on net2.test, real plain-http POST, stored https siteurl/home so core set FORCE_SSL_ADMIN, is_ssl false): with the plugin active the moderator email contained http://net2.test/wp-admin/comment.php?action=approve|trash|spam&c=7, http://net2.test/wp-admin/edit-comments.php?comment_status=moderated and http://net2.test/2026/10/16/hello-world/, and the 302 Location was http://; with the plugin deactivated every link and the Location were https://. Trackback and pingback paths were not exercised. ## Fix Never lower the scheme: for clearnet hosts keep https when either the input URL or the request is https, while onion hosts keep their fixed http. Apply the same rule in UrlRewriter::urlFor and ContentRewriter, and add a unit regression test that rewriting 'https://&lt;configured&gt;/wp-login.php?...' during an http request returns https. Suggested change in `src/Rewrite/UrlRewriter.php`: ```php $host = strtolower( (string) $parts['host'] ); if ( null === $this->settings->findByHost( $host ) ) { return $url; } $scheme = $this->resolver->scheme(); if ( ! $current->isOnion() && 'https' === strtolower( (string) ( $parts['scheme'] ?? '' ) ) ) { $scheme = 'https'; // Never downgrade a scheme core chose (FORCE_SSL_ADMIN, stored https URLs). } return $scheme . '://' . $current->host . $this->buildTail( $parts ); ``` ## Priority rationale - Likelihood: Requires FORCE_SSL_ADMIN or stored https URLs, the reset link or login form generated without SSL detection (CLI/system-cron emails, plain-http front-end pages, or http-reachable wp-login.php), no HSTS, an on-path attacker, and a victim who clicks or submits. Under FORCE_SSL_ADMIN the ordinary web reset request is redirected to https first and is unaffected. - Impact: When the conditions hold, the plugin fully defeats the operator's explicit https choice (FORCE_SSL_ADMIN or https site URLs) for password-reset links and login form targets, and a sniffed reset key or password gives takeover of that one account. Exposure is limited to users who act on a downgraded link while the attacker has network position.
eric closed this issue 2026-10-15 23:26:46 -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#9
No description provided.