Security (medium): trailing-dot Host header poisons host-normalizing page caches with primary-host pages #7

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

Severity: medium (likelihood medium, 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:HostHeader.normalize:trailing-dot-host-falls-back-to-primary

Summary

A request with 'Host: <configured alias>.' (the fully qualified form with a trailing dot, for example the configured .onion host followed by '.') is rejected by HostHeader::normalize, so HostResolver silently falls back to the configured primary clearnet host and every URL filter emits <request scheme>://<primary>/... into the page. A front cache keyed on a normalized host stores that response under the alias's key, because nginx's $host strips the single trailing dot while nginx forwards the raw request Host header to PHP as HTTP_HOST (fastcgi_pass_request_headers, on by default). Visitors who later arrive on the real alias host receive the cached page until it expires, with links, feeds, theme assets and fonts all pointing to http://<primary>. Reproduced in lab 3 (WordPress 7.1.3 single-site behind nginx 1.27, fastcgi_cache_key "$scheme$request_method$host$request_uri"): after one unauthenticated request to '/' with the trailing-dot onion Host, clean onion requests to '/' got X-Cache: HIT with 0 onion links and 30 www.example.test links, while a fresh-key control on the onion host had 28 onion links. The configured apex alias 'example.test.' behaves the same. This defeats the plugin's per-host rewriting for every visitor served the poisoned entry and sends onion visitors to clearnet http URLs.

Root cause

HostHeader::normalize (src/Request/HostHeader.php:47-77) does not remove the single trailing root dot that DNS and HTTP treat as equivalent, so the fully qualified form fails the RFC1123 and onion regexes (lines 16-17) and returns ''. HostResolver::resolve (src/Request/HostResolver.php:61-64) then replaces the empty or unmatched host with the primary. The plugin's notion of the host therefore diverges from standard front proxies and caches, which normalize the trailing dot away, so a response rendered for the primary host is served and cached under the alias's identity.

Expected behaviour

A Host value that differs from a configured host only by a single trailing root dot (and by case or port) should resolve to that configured host, so the page carries the URLs its normalized cache key implies. Only genuinely unknown hosts should fall back to the primary.

Source trace

  1. src/Request/HostResolver.php:56 (HostResolver::resolve): Reads $_SERVER for an unauthenticated front-end request; HTTP_HOST is the raw client Host 'aaaa...aaaa.onion.' forwarded unmodified by nginx as a request header (fastcgi_pass_request_headers).
  2. src/Request/HostHeader.php:30 (HostHeader::fromServer): HTTP_HOST is the only candidate when MULTI_DOMAIN_REDUX_TRUST_PROXY is unset and is passed to normalize().
  3. src/Request/HostHeader.php:73 (HostHeader::normalize): The trailing dot is not removed after lowercasing and port-stripping, so isValidHostname fails both the RFC1123 and ONION regexes and normalize returns ''.
  4. src/Request/HostResolver.php:63 (HostResolver::resolve): With an empty raw host, $matched becomes settings->primary(); the onion http pin (:67) does not apply and the request scheme (http) is kept.
  5. src/Rewrite/UrlRewriter.php:61 (UrlRewriter::rewrite): Every URL filter bound in HookBindings emits http://www.example.test/... in a 200 response, which nginx caches under the key httpGET<onion>/ derived from $host and serves to later clean onion requests.

Conditions

  • system configuration: A shared front cache keys on a trailing-dot-normalized host while forwarding the raw Host to PHP. Reproduced with nginx 1.27 fastcgi_cache_key "$scheme$request_method$host$request_uri" with the raw Host forwarded to PHP as HTTP_HOST by nginx default request-header passing, a common tutorial setup; this is a representative lab configuration, not the owner's deployment. Caches keyed on the raw Host ($http_host), or no shared cache, are not affected.
  • system configuration: Plugin active with at least one non-primary alias configured (onion or clearnet), and the response cacheable for anonymous visitors (the lab caches 200/301/302 for 10m and ignores Cache-Control/Set-Cookie).
  • network routing: The attacker reaches the cache with Host '<alias>.': for the onion alias, through the onion service or the same nginx if it is also exposed on clearnet. Any HTTP client can set the Host header.
  • system configuration: Single-site WordPress; core multisite routing does not resolve a trailing-dot host (302 to wp-signup.php), so the primary-host page is not produced there.

Lab reproduction (local, dummy data only)

Principal: Unauthenticated network client with no account or prior state, sending ordinary HTTP GETs to the site's front cache.

  1. Lab 3: activate multi-domain-redux on single-site WP 7.1.3 (home http://www.example.test) and configure hosts www.example.test (primary, en_US), example.test and aaaa...aaaa.onion via the multi_domain_redux_settings option (same structure as tests/fixtures/hosts.json).
  2. Control: request http://mdr-lab3-nginx/?v6ctl=<n> with Host <onion>: X-Cache MISS and a body with 28 http://<onion>/ links.
  3. Attacker: request http://mdr-lab3-nginx/ with Host '<onion>.': 200, X-Cache MISS, X-Cache-Key httpGET<onion>/ (dot removed by $host); body has 0 onion links and 30 www.example.test links.
  4. Victim: request http://mdr-lab3-nginx/ with Host <onion>: 200, X-Cache HIT, same key and same 69858-byte body, 0 onion links, 30 links such as http://www.example.test/sample-page/, /feed/ and theme font URLs.
  5. Repeat with Host 'example.test.' then 'example.test' on a fresh URI: the second request is a HIT with 0 apex links and 28 www links.
  6. Restore: deactivate the plugin and delete the option; poisoned entries expire after the configured 10m.

Observed: The trailing-dot request (MISS) and every later clean onion request to the same URI (HIT) shared cache key httpGET<onion>/, and the HIT responses carried the primary-host page: 0 onion links and 30 http://www.example.test/... links, versus 28 onion links on a fresh-key control. The apex alias reproduced the same way.

Fix

Canonicalize the host the way standard proxies do before validation: after port-stripping and lowercasing, remove exactly one trailing root dot, so '<alias>.' resolves to the configured alias and the rendered URLs match the normalized cache key. Add unit tests that normalize('<onion>.') returns '<onion>', normalize('example.test.') returns 'example.test', and normalize('example.test..') returns '' (still rejected). Defence in depth: document that caches should key on a host form the plugin also accepts.

Suggested change in src/Request/HostHeader.php:

		$raw = strtolower( $raw );

		// A single trailing root dot is the FQDN form of the same host; proxies and
		// caches (e.g. nginx $host) normalize it away, so must we, or the page we
		// render (primary fallback) is cached under the alias's key.
		if ( str_ends_with( $raw, '.' ) ) {
			$raw = substr( $raw, 0, -1 );
		}

		if ( ! $this->isValidHostname( $raw ) ) {
			return '';
		}
		return $raw;

Priority rationale

  • Likelihood: One unauthenticated request with no special tooling, repeatable to re-poison after each TTL; requires a host-normalizing front cache keyed on $host (a common tutorial default, but deployment-specific) and a single-site install.
  • Impact: Fully defeats the plugin's per-host URL rewriting, its core function, for every visitor served the poisoned entry; onion visitors receive pages whose navigation, feeds and assets point to the clearnet host over plain http, moving them off the onion service. Limited to link integrity on cached anonymous pages for the cache TTL; no secret disclosure, code execution or account effect by itself.
**Severity:** medium (likelihood medium, 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:HostHeader.normalize:trailing-dot-host-falls-back-to-primary` ## Summary A request with 'Host: &lt;configured alias&gt;.' (the fully qualified form with a trailing dot, for example the configured .onion host followed by '.') is rejected by HostHeader::normalize, so HostResolver silently falls back to the configured primary clearnet host and every URL filter emits &lt;request scheme&gt;://&lt;primary&gt;/... into the page. A front cache keyed on a normalized host stores that response under the alias's key, because nginx's $host strips the single trailing dot while nginx forwards the raw request Host header to PHP as HTTP_HOST (fastcgi_pass_request_headers, on by default). Visitors who later arrive on the real alias host receive the cached page until it expires, with links, feeds, theme assets and fonts all pointing to http://&lt;primary&gt;. Reproduced in lab 3 (WordPress 7.1.3 single-site behind nginx 1.27, fastcgi_cache_key "$scheme$request_method$host$request_uri"): after one unauthenticated request to '/' with the trailing-dot onion Host, clean onion requests to '/' got X-Cache: HIT with 0 onion links and 30 www.example.test links, while a fresh-key control on the onion host had 28 onion links. The configured apex alias 'example.test.' behaves the same. This defeats the plugin's per-host rewriting for every visitor served the poisoned entry and sends onion visitors to clearnet http URLs. ## Root cause HostHeader::normalize (src/Request/HostHeader.php:47-77) does not remove the single trailing root dot that DNS and HTTP treat as equivalent, so the fully qualified form fails the RFC1123 and onion regexes (lines 16-17) and returns ''. HostResolver::resolve (src/Request/HostResolver.php:61-64) then replaces the empty or unmatched host with the primary. The plugin's notion of the host therefore diverges from standard front proxies and caches, which normalize the trailing dot away, so a response rendered for the primary host is served and cached under the alias's identity. ## Expected behaviour A Host value that differs from a configured host only by a single trailing root dot (and by case or port) should resolve to that configured host, so the page carries the URLs its normalized cache key implies. Only genuinely unknown hosts should fall back to the primary. ## Source trace 1. `src/Request/HostResolver.php:56` (HostResolver::resolve): Reads $_SERVER for an unauthenticated front-end request; HTTP_HOST is the raw client Host 'aaaa...aaaa.onion.' forwarded unmodified by nginx as a request header (fastcgi_pass_request_headers). 2. `src/Request/HostHeader.php:30` (HostHeader::fromServer): HTTP_HOST is the only candidate when MULTI_DOMAIN_REDUX_TRUST_PROXY is unset and is passed to normalize(). 3. `src/Request/HostHeader.php:73` (HostHeader::normalize): The trailing dot is not removed after lowercasing and port-stripping, so isValidHostname fails both the RFC1123 and ONION regexes and normalize returns ''. 4. `src/Request/HostResolver.php:63` (HostResolver::resolve): With an empty raw host, $matched becomes settings-&gt;primary(); the onion http pin (:67) does not apply and the request scheme (http) is kept. 5. `src/Rewrite/UrlRewriter.php:61` (UrlRewriter::rewrite): Every URL filter bound in HookBindings emits http://www.example.test/... in a 200 response, which nginx caches under the key httpGET&lt;onion&gt;/ derived from $host and serves to later clean onion requests. ## Conditions - **system configuration**: A shared front cache keys on a trailing-dot-normalized host while forwarding the raw Host to PHP. Reproduced with nginx 1.27 fastcgi_cache_key "$scheme$request_method$host$request_uri" with the raw Host forwarded to PHP as HTTP_HOST by nginx default request-header passing, a common tutorial setup; this is a representative lab configuration, not the owner's deployment. Caches keyed on the raw Host ($http_host), or no shared cache, are not affected. - **system configuration**: Plugin active with at least one non-primary alias configured (onion or clearnet), and the response cacheable for anonymous visitors (the lab caches 200/301/302 for 10m and ignores Cache-Control/Set-Cookie). - **network routing**: The attacker reaches the cache with Host '&lt;alias&gt;.': for the onion alias, through the onion service or the same nginx if it is also exposed on clearnet. Any HTTP client can set the Host header. - **system configuration**: Single-site WordPress; core multisite routing does not resolve a trailing-dot host (302 to wp-signup.php), so the primary-host page is not produced there. ## Lab reproduction (local, dummy data only) **Principal:** Unauthenticated network client with no account or prior state, sending ordinary HTTP GETs to the site's front cache. 1. Lab 3: activate multi-domain-redux on single-site WP 7.1.3 (home http://www.example.test) and configure hosts www.example.test (primary, en_US), example.test and aaaa...aaaa.onion via the multi_domain_redux_settings option (same structure as tests/fixtures/hosts.json). 2. Control: request http://mdr-lab3-nginx/?v6ctl=&lt;n&gt; with Host &lt;onion&gt;: X-Cache MISS and a body with 28 http://&lt;onion&gt;/ links. 3. Attacker: request http://mdr-lab3-nginx/ with Host '&lt;onion&gt;.': 200, X-Cache MISS, X-Cache-Key httpGET&lt;onion&gt;/ (dot removed by $host); body has 0 onion links and 30 www.example.test links. 4. Victim: request http://mdr-lab3-nginx/ with Host &lt;onion&gt;: 200, X-Cache HIT, same key and same 69858-byte body, 0 onion links, 30 links such as http://www.example.test/sample-page/, /feed/ and theme font URLs. 5. Repeat with Host 'example.test.' then 'example.test' on a fresh URI: the second request is a HIT with 0 apex links and 28 www links. 6. Restore: deactivate the plugin and delete the option; poisoned entries expire after the configured 10m. **Observed:** The trailing-dot request (MISS) and every later clean onion request to the same URI (HIT) shared cache key httpGET&lt;onion&gt;/, and the HIT responses carried the primary-host page: 0 onion links and 30 http://www.example.test/... links, versus 28 onion links on a fresh-key control. The apex alias reproduced the same way. ## Fix Canonicalize the host the way standard proxies do before validation: after port-stripping and lowercasing, remove exactly one trailing root dot, so '&lt;alias&gt;.' resolves to the configured alias and the rendered URLs match the normalized cache key. Add unit tests that normalize('&lt;onion&gt;.') returns '&lt;onion&gt;', normalize('example.test.') returns 'example.test', and normalize('example.test..') returns '' (still rejected). Defence in depth: document that caches should key on a host form the plugin also accepts. Suggested change in `src/Request/HostHeader.php`: ```php $raw = strtolower( $raw ); // A single trailing root dot is the FQDN form of the same host; proxies and // caches (e.g. nginx $host) normalize it away, so must we, or the page we // render (primary fallback) is cached under the alias's key. if ( str_ends_with( $raw, '.' ) ) { $raw = substr( $raw, 0, -1 ); } if ( ! $this->isValidHostname( $raw ) ) { return ''; } return $raw; ``` ## Priority rationale - Likelihood: One unauthenticated request with no special tooling, repeatable to re-poison after each TTL; requires a host-normalizing front cache keyed on $host (a common tutorial default, but deployment-specific) and a single-site install. - Impact: Fully defeats the plugin's per-host URL rewriting, its core function, for every visitor served the poisoned entry; onion visitors receive pages whose navigation, feeds and assets point to the clearnet host over plain http, moving them off the onion service. Limited to link integrity on cached anonymous pages for the cache TTL; no secret disclosure, code execution or account effect by itself.
eric closed this issue 2026-10-15 23:21:34 -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#7
No description provided.