Security (medium): trailing-dot Host header poisons host-normalizing page caches with primary-host pages #7
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: medium (likelihood medium, impact medium)
Found by: security audit run 3 (commit
01195c8), independently verified in a disposable local WordPress 7.1.3 labFingerprint:
multi-domain-redux:HostHeader.normalize:trailing-dot-host-falls-back-to-primarySummary
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
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).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().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 ''.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.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
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.
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:Priority rationale